@iann29/rastro 0.9.0 → 0.10.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 (257) hide show
  1. package/README.md +9 -1
  2. package/agent/integration.md +23 -1
  3. package/agent/manifest.json +4 -4
  4. package/dist/client/federation.d.ts +114 -9
  5. package/dist/client/federation.d.ts.map +1 -1
  6. package/dist/client/federation.js +54 -2
  7. package/dist/client/federation.js.map +1 -1
  8. package/dist/client/index.d.ts +637 -8
  9. package/dist/client/index.d.ts.map +1 -1
  10. package/dist/client/index.js +385 -9
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/component/_generated/api.d.ts +14 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -1
  14. package/dist/component/_generated/api.js.map +1 -1
  15. package/dist/component/_generated/component.d.ts +277 -1
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/_generated/server.d.ts +4 -0
  18. package/dist/component/_generated/server.d.ts.map +1 -1
  19. package/dist/component/_generated/server.js.map +1 -1
  20. package/dist/component/constants.d.ts +5 -0
  21. package/dist/component/constants.d.ts.map +1 -1
  22. package/dist/component/constants.js +10 -0
  23. package/dist/component/constants.js.map +1 -1
  24. package/dist/component/convex.config.d.ts +4 -0
  25. package/dist/component/convex.config.js +9 -0
  26. package/dist/component/convex.config.js.map +1 -1
  27. package/dist/component/errors.d.ts +1 -1
  28. package/dist/component/errors.d.ts.map +1 -1
  29. package/dist/component/errors.js.map +1 -1
  30. package/dist/component/leads.d.ts +69 -3
  31. package/dist/component/leads.d.ts.map +1 -1
  32. package/dist/component/leads.js +419 -16
  33. package/dist/component/leads.js.map +1 -1
  34. package/dist/component/meta.d.ts +352 -0
  35. package/dist/component/meta.d.ts.map +1 -0
  36. package/dist/component/meta.js +1239 -0
  37. package/dist/component/meta.js.map +1 -0
  38. package/dist/component/metaCapi.d.ts +162 -0
  39. package/dist/component/metaCapi.d.ts.map +1 -0
  40. package/dist/component/metaCapi.js +754 -0
  41. package/dist/component/metaCapi.js.map +1 -0
  42. package/dist/component/metaFake.d.ts +5 -0
  43. package/dist/component/metaFake.d.ts.map +1 -0
  44. package/dist/component/metaFake.js +225 -0
  45. package/dist/component/metaFake.js.map +1 -0
  46. package/dist/component/metaGraph.d.ts +165 -0
  47. package/dist/component/metaGraph.d.ts.map +1 -0
  48. package/dist/component/metaGraph.js +507 -0
  49. package/dist/component/metaGraph.js.map +1 -0
  50. package/dist/component/metaSync.d.ts +186 -0
  51. package/dist/component/metaSync.d.ts.map +1 -0
  52. package/dist/component/metaSync.js +961 -0
  53. package/dist/component/metaSync.js.map +1 -0
  54. package/dist/component/pipeline.d.ts +98 -0
  55. package/dist/component/pipeline.d.ts.map +1 -0
  56. package/dist/component/pipeline.js +324 -0
  57. package/dist/component/pipeline.js.map +1 -0
  58. package/dist/component/schema.d.ts +404 -2
  59. package/dist/component/schema.d.ts.map +1 -1
  60. package/dist/component/schema.js +168 -2
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/secrets.d.ts +15 -0
  63. package/dist/component/secrets.d.ts.map +1 -0
  64. package/dist/component/secrets.js +67 -0
  65. package/dist/component/secrets.js.map +1 -0
  66. package/dist/component/validators.d.ts +1052 -15
  67. package/dist/component/validators.d.ts.map +1 -1
  68. package/dist/component/validators.js +307 -3
  69. package/dist/component/validators.js.map +1 -1
  70. package/dist/tracker/generated.d.ts +1 -1
  71. package/dist/tracker/generated.d.ts.map +1 -1
  72. package/dist/tracker/generated.js +1 -1
  73. package/dist/tracker/generated.js.map +1 -1
  74. package/dist/ui/campaigns/CampaignsView.d.ts +48 -0
  75. package/dist/ui/campaigns/CampaignsView.d.ts.map +1 -0
  76. package/dist/ui/campaigns/CampaignsView.js +47 -0
  77. package/dist/ui/campaigns/CampaignsView.js.map +1 -0
  78. package/dist/ui/campaigns/Conversions.d.ts +16 -0
  79. package/dist/ui/campaigns/Conversions.d.ts.map +1 -0
  80. package/dist/ui/campaigns/Conversions.js +40 -0
  81. package/dist/ui/campaigns/Conversions.js.map +1 -0
  82. package/dist/ui/campaigns/Drawer.d.ts +24 -0
  83. package/dist/ui/campaigns/Drawer.d.ts.map +1 -0
  84. package/dist/ui/campaigns/Drawer.js +120 -0
  85. package/dist/ui/campaigns/Drawer.js.map +1 -0
  86. package/dist/ui/campaigns/Icons.d.ts +35 -0
  87. package/dist/ui/campaigns/Icons.d.ts.map +1 -0
  88. package/dist/ui/campaigns/Icons.js +41 -0
  89. package/dist/ui/campaigns/Icons.js.map +1 -0
  90. package/dist/ui/campaigns/Summary.d.ts +21 -0
  91. package/dist/ui/campaigns/Summary.d.ts.map +1 -0
  92. package/dist/ui/campaigns/Summary.js +56 -0
  93. package/dist/ui/campaigns/Summary.js.map +1 -0
  94. package/dist/ui/campaigns/Thumbnail.d.ts +11 -0
  95. package/dist/ui/campaigns/Thumbnail.d.ts.map +1 -0
  96. package/dist/ui/campaigns/Thumbnail.js +23 -0
  97. package/dist/ui/campaigns/Thumbnail.js.map +1 -0
  98. package/dist/ui/campaigns/Tree.d.ts +12 -0
  99. package/dist/ui/campaigns/Tree.d.ts.map +1 -0
  100. package/dist/ui/campaigns/Tree.js +105 -0
  101. package/dist/ui/campaigns/Tree.js.map +1 -0
  102. package/dist/ui/campaigns/glyphs.d.ts +20 -0
  103. package/dist/ui/campaigns/glyphs.d.ts.map +1 -0
  104. package/dist/ui/campaigns/glyphs.js +19 -0
  105. package/dist/ui/campaigns/glyphs.js.map +1 -0
  106. package/dist/ui/campaigns/index.d.ts +4 -0
  107. package/dist/ui/campaigns/index.d.ts.map +1 -0
  108. package/dist/ui/campaigns/index.js +3 -0
  109. package/dist/ui/campaigns/index.js.map +1 -0
  110. package/dist/ui/campaigns/labels.d.ts +53 -0
  111. package/dist/ui/campaigns/labels.d.ts.map +1 -0
  112. package/dist/ui/campaigns/labels.js +169 -0
  113. package/dist/ui/campaigns/labels.js.map +1 -0
  114. package/dist/ui/campaigns/model.d.ts +99 -0
  115. package/dist/ui/campaigns/model.d.ts.map +1 -0
  116. package/dist/ui/campaigns/model.js +230 -0
  117. package/dist/ui/campaigns/model.js.map +1 -0
  118. package/dist/ui/campaigns/modes.d.ts +18 -0
  119. package/dist/ui/campaigns/modes.d.ts.map +1 -0
  120. package/dist/ui/campaigns/modes.js +7 -0
  121. package/dist/ui/campaigns/modes.js.map +1 -0
  122. package/dist/ui/index.d.ts +21 -0
  123. package/dist/ui/index.d.ts.map +1 -0
  124. package/dist/ui/index.js +19 -0
  125. package/dist/ui/index.js.map +1 -0
  126. package/dist/ui/integrations/AccountPicker.d.ts +26 -0
  127. package/dist/ui/integrations/AccountPicker.d.ts.map +1 -0
  128. package/dist/ui/integrations/AccountPicker.js +97 -0
  129. package/dist/ui/integrations/AccountPicker.js.map +1 -0
  130. package/dist/ui/integrations/ClientCards.d.ts +17 -0
  131. package/dist/ui/integrations/ClientCards.d.ts.map +1 -0
  132. package/dist/ui/integrations/ClientCards.js +38 -0
  133. package/dist/ui/integrations/ClientCards.js.map +1 -0
  134. package/dist/ui/integrations/ClientsTable.d.ts +36 -0
  135. package/dist/ui/integrations/ClientsTable.d.ts.map +1 -0
  136. package/dist/ui/integrations/ClientsTable.js +127 -0
  137. package/dist/ui/integrations/ClientsTable.js.map +1 -0
  138. package/dist/ui/integrations/Connect.d.ts +29 -0
  139. package/dist/ui/integrations/Connect.d.ts.map +1 -0
  140. package/dist/ui/integrations/Connect.js +49 -0
  141. package/dist/ui/integrations/Connect.js.map +1 -0
  142. package/dist/ui/integrations/Connection.d.ts +37 -0
  143. package/dist/ui/integrations/Connection.d.ts.map +1 -0
  144. package/dist/ui/integrations/Connection.js +94 -0
  145. package/dist/ui/integrations/Connection.js.map +1 -0
  146. package/dist/ui/integrations/Dialogs.d.ts +18 -0
  147. package/dist/ui/integrations/Dialogs.d.ts.map +1 -0
  148. package/dist/ui/integrations/Dialogs.js +25 -0
  149. package/dist/ui/integrations/Dialogs.js.map +1 -0
  150. package/dist/ui/integrations/EventsFeed.d.ts +15 -0
  151. package/dist/ui/integrations/EventsFeed.d.ts.map +1 -0
  152. package/dist/ui/integrations/EventsFeed.js +57 -0
  153. package/dist/ui/integrations/EventsFeed.js.map +1 -0
  154. package/dist/ui/integrations/EventsSheet.d.ts +28 -0
  155. package/dist/ui/integrations/EventsSheet.d.ts.map +1 -0
  156. package/dist/ui/integrations/EventsSheet.js +121 -0
  157. package/dist/ui/integrations/EventsSheet.js.map +1 -0
  158. package/dist/ui/integrations/Icons.d.ts +36 -0
  159. package/dist/ui/integrations/Icons.d.ts.map +1 -0
  160. package/dist/ui/integrations/Icons.js +38 -0
  161. package/dist/ui/integrations/Icons.js.map +1 -0
  162. package/dist/ui/integrations/IntegrationsView.d.ts +14 -0
  163. package/dist/ui/integrations/IntegrationsView.d.ts.map +1 -0
  164. package/dist/ui/integrations/IntegrationsView.js +113 -0
  165. package/dist/ui/integrations/IntegrationsView.js.map +1 -0
  166. package/dist/ui/integrations/Overlay.d.ts +18 -0
  167. package/dist/ui/integrations/Overlay.d.ts.map +1 -0
  168. package/dist/ui/integrations/Overlay.js +36 -0
  169. package/dist/ui/integrations/Overlay.js.map +1 -0
  170. package/dist/ui/integrations/RowMenu.d.ts +15 -0
  171. package/dist/ui/integrations/RowMenu.d.ts.map +1 -0
  172. package/dist/ui/integrations/RowMenu.js +47 -0
  173. package/dist/ui/integrations/RowMenu.js.map +1 -0
  174. package/dist/ui/integrations/UnlinkDialog.d.ts +18 -0
  175. package/dist/ui/integrations/UnlinkDialog.d.ts.map +1 -0
  176. package/dist/ui/integrations/UnlinkDialog.js +29 -0
  177. package/dist/ui/integrations/UnlinkDialog.js.map +1 -0
  178. package/dist/ui/integrations/format.d.ts +28 -0
  179. package/dist/ui/integrations/format.d.ts.map +1 -0
  180. package/dist/ui/integrations/format.js +101 -0
  181. package/dist/ui/integrations/format.js.map +1 -0
  182. package/dist/ui/integrations/index.d.ts +5 -0
  183. package/dist/ui/integrations/index.d.ts.map +1 -0
  184. package/dist/ui/integrations/index.js +3 -0
  185. package/dist/ui/integrations/index.js.map +1 -0
  186. package/dist/ui/integrations/model.d.ts +84 -0
  187. package/dist/ui/integrations/model.d.ts.map +1 -0
  188. package/dist/ui/integrations/model.js +131 -0
  189. package/dist/ui/integrations/model.js.map +1 -0
  190. package/dist/ui/integrations/overlay.d.ts +10 -0
  191. package/dist/ui/integrations/overlay.d.ts.map +1 -0
  192. package/dist/ui/integrations/overlay.js +77 -0
  193. package/dist/ui/integrations/overlay.js.map +1 -0
  194. package/dist/ui/integrations/parts.d.ts +36 -0
  195. package/dist/ui/integrations/parts.d.ts.map +1 -0
  196. package/dist/ui/integrations/parts.js +85 -0
  197. package/dist/ui/integrations/parts.js.map +1 -0
  198. package/dist/ui/integrations/types.d.ts +96 -0
  199. package/dist/ui/integrations/types.d.ts.map +1 -0
  200. package/dist/ui/integrations/types.js +2 -0
  201. package/dist/ui/integrations/types.js.map +1 -0
  202. package/dist/ui/metrics.d.ts +106 -0
  203. package/dist/ui/metrics.d.ts.map +1 -0
  204. package/dist/ui/metrics.js +166 -0
  205. package/dist/ui/metrics.js.map +1 -0
  206. package/dist/ui/portfolio/PortfolioView.d.ts +26 -0
  207. package/dist/ui/portfolio/PortfolioView.d.ts.map +1 -0
  208. package/dist/ui/portfolio/PortfolioView.js +216 -0
  209. package/dist/ui/portfolio/PortfolioView.js.map +1 -0
  210. package/dist/ui/portfolio/SpendChart.d.ts +10 -0
  211. package/dist/ui/portfolio/SpendChart.d.ts.map +1 -0
  212. package/dist/ui/portfolio/SpendChart.js +196 -0
  213. package/dist/ui/portfolio/SpendChart.js.map +1 -0
  214. package/dist/ui/portfolio/alerts.d.ts +74 -0
  215. package/dist/ui/portfolio/alerts.d.ts.map +1 -0
  216. package/dist/ui/portfolio/alerts.js +291 -0
  217. package/dist/ui/portfolio/alerts.js.map +1 -0
  218. package/dist/ui/portfolio/figures.d.ts +79 -0
  219. package/dist/ui/portfolio/figures.d.ts.map +1 -0
  220. package/dist/ui/portfolio/figures.js +103 -0
  221. package/dist/ui/portfolio/figures.js.map +1 -0
  222. package/dist/ui/portfolio/format.d.ts +8 -0
  223. package/dist/ui/portfolio/format.d.ts.map +1 -0
  224. package/dist/ui/portfolio/format.js +28 -0
  225. package/dist/ui/portfolio/format.js.map +1 -0
  226. package/dist/ui/sample.d.ts +474 -0
  227. package/dist/ui/sample.d.ts.map +1 -0
  228. package/dist/ui/sample.js +553 -0
  229. package/dist/ui/sample.js.map +1 -0
  230. package/dist/ui/seed.d.ts +188 -0
  231. package/dist/ui/seed.d.ts.map +1 -0
  232. package/dist/ui/seed.js +563 -0
  233. package/dist/ui/seed.js.map +1 -0
  234. package/dist/ui/ui.css +3356 -0
  235. package/docs/federation.md +46 -1
  236. package/docs/meta-ads.md +746 -0
  237. package/docs/upgrading.md +95 -0
  238. package/llms.txt +3 -0
  239. package/package.json +15 -3
  240. package/src/component/_generated/api.ts +14 -0
  241. package/src/component/_generated/component.ts +323 -1
  242. package/src/component/_generated/server.ts +4 -0
  243. package/src/component/constants.ts +10 -0
  244. package/src/component/convex.config.ts +9 -0
  245. package/src/component/errors.ts +5 -1
  246. package/src/component/leads.ts +517 -17
  247. package/src/component/meta.ts +1461 -0
  248. package/src/component/metaCapi.ts +921 -0
  249. package/src/component/metaFake.ts +277 -0
  250. package/src/component/metaGraph.ts +722 -0
  251. package/src/component/metaSync.ts +1164 -0
  252. package/src/component/pipeline.ts +392 -0
  253. package/src/component/schema.ts +197 -1
  254. package/src/component/secrets.ts +98 -0
  255. package/src/component/validators.ts +376 -3
  256. package/src/tracker/generated.ts +1 -1
  257. package/src/ui/seed.ts +755 -0
@@ -0,0 +1,754 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import { env, internalAction, internalMutation, } from "./_generated/server.js";
4
+ import { DAY_MS } from "./constants.js";
5
+ import { metaGraph, MetaGraphError, } from "./metaGraph.js";
6
+ import { decryptSecret } from "./secrets.js";
7
+ import { metaConversionEventNameValidator, metaUserDataValidator, } from "./validators.js";
8
+ /** Meta refuses events older than this; the sender gives up first. */
9
+ export const MAX_EVENT_AGE_MS = 7 * DAY_MS;
10
+ export const MAX_SEND_ATTEMPTS = 3;
11
+ /** The wait before the second and the third attempt. */
12
+ export const RETRY_DELAYS_MS = [60_000, 10 * 60_000];
13
+ /**
14
+ * A pending row whose attempt was due this long ago was lost (an action
15
+ * outlives neither this nor the longest retry wait): the sweep sends it again.
16
+ */
17
+ export const STALL_MS = 15 * 60_000;
18
+ const SWEEP_BATCH = 25;
19
+ const MAX_PHONES = 10;
20
+ const MAX_LEAD_CONVERSIONS = 20;
21
+ const SHA256_HEX = /^[a-f0-9]{64}$/;
22
+ export async function sha256Hex(value) {
23
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
24
+ return [...new Uint8Array(digest)]
25
+ .map((byte) => byte.toString(16).padStart(2, "0"))
26
+ .join("");
27
+ }
28
+ /** SHA-256 of the empty string: a hash of nothing, which matches nobody. */
29
+ const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
30
+ const digestOf = (value) => {
31
+ if (typeof value !== "string")
32
+ return null;
33
+ const hash = value.trim().toLowerCase();
34
+ return SHA256_HEX.test(hash) ? hash : null;
35
+ };
36
+ function ignored() {
37
+ // Never the value itself: it may be a plain phone number.
38
+ console.warn("[rastro] meta ignored: expected { ph: SHA-256 hex digests, externalId?: a SHA-256 hex digest }");
39
+ return null;
40
+ }
41
+ /**
42
+ * The host's hashes, checked, or null when there is nothing to send with.
43
+ * Never throws and never echoes what it got: anything but SHA-256 hex digests
44
+ * in the documented shape (a plain phone number, a wrong type, more than
45
+ * MAX_PHONES phones) drops the whole `meta`, so the lead's own write goes on
46
+ * without the Conversions API. The hash of an empty string is left out.
47
+ */
48
+ export function parseMetaUserData(value) {
49
+ if (value === undefined || value === null)
50
+ return null;
51
+ if (typeof value !== "object" || Array.isArray(value))
52
+ return ignored();
53
+ const fields = value;
54
+ const phones = fields.ph ?? [];
55
+ if (!Array.isArray(phones) || phones.length > MAX_PHONES)
56
+ return ignored();
57
+ const ph = [];
58
+ for (const item of phones) {
59
+ const hash = digestOf(item);
60
+ if (hash === null)
61
+ return ignored();
62
+ if (hash !== EMPTY_SHA256 && !ph.includes(hash))
63
+ ph.push(hash);
64
+ }
65
+ let externalId;
66
+ if (fields.externalId !== undefined) {
67
+ const hash = digestOf(fields.externalId);
68
+ if (hash === null)
69
+ return ignored();
70
+ if (hash !== EMPTY_SHA256)
71
+ externalId = hash;
72
+ }
73
+ if (ph.length === 0 && externalId === undefined)
74
+ return null;
75
+ return { ph, ...(externalId ? { externalId } : {}) };
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // The pixel and its token.
79
+ /**
80
+ * The site's pixel when it may send: enabled, and its token not refused (its
81
+ * own, or its connection's, which must still list the pixel: a pixel marked
82
+ * `PIXEL_NOT_GRANTED` queues nothing). `meta.loadPixelToken` resolves the
83
+ * token itself, by the same rules.
84
+ */
85
+ async function sendingPixel(ctx, siteId) {
86
+ const pixel = await ctx.db
87
+ .query("sitePixels")
88
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
89
+ .unique();
90
+ if (!pixel?.enabled)
91
+ return null;
92
+ if (pixel.source === "connection") {
93
+ const connection = pixel.connectionId
94
+ ? await ctx.db.get("metaConnections", pixel.connectionId)
95
+ : null;
96
+ if (connection?.status !== "active")
97
+ return null;
98
+ return connection.pixels?.some((row) => row.pixelId === pixel.pixelId)
99
+ ? pixel
100
+ : null;
101
+ }
102
+ return pixel.invalidSince === undefined ? pixel : null;
103
+ }
104
+ /** Meta refused the pixel's own token: the pixel stops until set again. */
105
+ async function refusePixelToken(ctx, pixel, message) {
106
+ await ctx.db.patch("sitePixels", pixel._id, {
107
+ invalidSince: Date.now(),
108
+ lastError: message.slice(0, 300),
109
+ });
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Counts.
113
+ /** The count documents per site, so concurrent writes rarely meet. */
114
+ export const COUNT_SHARDS = 8;
115
+ /** An event's count shard: fixed by its id, so every write of it agrees. */
116
+ const shardOf = (eventId) => Number.parseInt(eventId.slice(0, 8), 16) % COUNT_SHARDS;
117
+ /**
118
+ * Moves an event's counts; returns the pending count its shard had before,
119
+ * which tells `enqueue` whether the sweep may be idle.
120
+ */
121
+ async function bumpCounts(ctx, row, delta) {
122
+ const shard = shardOf(row.eventId);
123
+ const doc = await ctx.db
124
+ .query("metaConversionCounts")
125
+ .withIndex("by_siteId_and_shard", (range) => range.eq("siteId", row.siteId).eq("shard", shard))
126
+ .unique();
127
+ const next = {
128
+ pending: (doc?.pending ?? 0) + (delta.pending ?? 0),
129
+ sent: (doc?.sent ?? 0) + (delta.sent ?? 0),
130
+ failed: (doc?.failed ?? 0) + (delta.failed ?? 0),
131
+ };
132
+ if (doc)
133
+ await ctx.db.patch("metaConversionCounts", doc._id, next);
134
+ else {
135
+ await ctx.db.insert("metaConversionCounts", {
136
+ siteId: row.siteId,
137
+ shard,
138
+ ...next,
139
+ });
140
+ }
141
+ return doc?.pending ?? 0;
142
+ }
143
+ /** The days `last30d` covers: today (UTC) and the 29 before. */
144
+ export const SENT_WINDOW_DAYS = 30;
145
+ const utcDay = (time) => Math.floor(time / DAY_MS) * DAY_MS;
146
+ /** Moves the sent count of an event's UTC day, in its shard. */
147
+ async function bumpDaily(ctx, row, sentAt, delta) {
148
+ const shard = shardOf(row.eventId);
149
+ const dayStart = utcDay(sentAt);
150
+ const doc = await ctx.db
151
+ .query("metaConversionDaily")
152
+ .withIndex("by_siteId_and_dayStart_and_shard", (range) => range
153
+ .eq("siteId", row.siteId)
154
+ .eq("dayStart", dayStart)
155
+ .eq("shard", shard))
156
+ .unique();
157
+ if (doc) {
158
+ await ctx.db.patch("metaConversionDaily", doc._id, {
159
+ [row.eventName]: Math.max(doc[row.eventName] + delta, 0),
160
+ });
161
+ }
162
+ else if (delta > 0) {
163
+ await ctx.db.insert("metaConversionDaily", {
164
+ siteId: row.siteId,
165
+ dayStart,
166
+ shard,
167
+ Purchase: 0,
168
+ QualifiedLead: 0,
169
+ Lead: 0,
170
+ Schedule: 0,
171
+ [row.eventName]: 1,
172
+ });
173
+ }
174
+ }
175
+ /**
176
+ * The site's queue by status and, given the reader's `now`, what Meta
177
+ * accepted in the last 30 UTC days by event and today. Without `now` (a
178
+ * query never reads the clock) those two are null.
179
+ */
180
+ export async function conversionCounts(ctx, siteId, now) {
181
+ const shards = await ctx.db
182
+ .query("metaConversionCounts")
183
+ .withIndex("by_siteId_and_shard", (range) => range.eq("siteId", siteId))
184
+ .take(COUNT_SHARDS);
185
+ const total = { pending: 0, sent: 0, failed: 0 };
186
+ for (const shard of shards) {
187
+ total.pending += shard.pending;
188
+ total.sent += shard.sent;
189
+ total.failed += shard.failed;
190
+ }
191
+ if (now === undefined)
192
+ return { ...total, last30d: null, sentToday: null };
193
+ const today = utcDay(now);
194
+ const days = await ctx.db
195
+ .query("metaConversionDaily")
196
+ .withIndex("by_siteId_and_dayStart_and_shard", (range) => range
197
+ .eq("siteId", siteId)
198
+ .gte("dayStart", today - (SENT_WINDOW_DAYS - 1) * DAY_MS)
199
+ .lte("dayStart", today))
200
+ .take(SENT_WINDOW_DAYS * COUNT_SHARDS);
201
+ const last30d = {
202
+ Purchase: 0,
203
+ QualifiedLead: 0,
204
+ Lead: 0,
205
+ Schedule: 0,
206
+ };
207
+ let sentToday = 0;
208
+ for (const day of days) {
209
+ last30d.Purchase += day.Purchase;
210
+ last30d.QualifiedLead += day.QualifiedLead;
211
+ last30d.Lead += day.Lead;
212
+ last30d.Schedule += day.Schedule;
213
+ if (day.dayStart === today) {
214
+ sentToday += day.Purchase + day.QualifiedLead + day.Lead + day.Schedule;
215
+ }
216
+ }
217
+ return { ...total, last30d, sentToday };
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Enqueueing.
221
+ async function enqueue(ctx, args) {
222
+ const userData = parseMetaUserData(args.meta);
223
+ if (!userData)
224
+ return false;
225
+ if (!(await sendingPixel(ctx, args.siteId)))
226
+ return false;
227
+ const now = Date.now();
228
+ const eventId = await sha256Hex(`${args.leadId}:${args.eventName}`);
229
+ const fields = {
230
+ eventTime: args.eventTime,
231
+ ...(args.value ?? {}),
232
+ userData,
233
+ status: "pending",
234
+ attempts: 0,
235
+ nextAttemptAt: now,
236
+ };
237
+ const seen = await ctx.db
238
+ .query("metaConversionOutbox")
239
+ .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", eventId))
240
+ .unique();
241
+ // Queued or delivered: once per lead per event. A failed one is not
242
+ // delivered, so the host's next call with hashes opens it again.
243
+ if (seen && seen.status !== "failed")
244
+ return false;
245
+ let outboxId;
246
+ let pendingBefore;
247
+ if (seen) {
248
+ outboxId = seen._id;
249
+ await ctx.db.patch("metaConversionOutbox", seen._id, {
250
+ ...fields,
251
+ attemptStartedAt: undefined,
252
+ lastError: undefined,
253
+ fbtraceId: undefined,
254
+ });
255
+ pendingBefore = await bumpCounts(ctx, seen, { failed: -1, pending: 1 });
256
+ }
257
+ else {
258
+ outboxId = await ctx.db.insert("metaConversionOutbox", {
259
+ siteId: args.siteId,
260
+ leadId: args.leadId,
261
+ eventId,
262
+ eventName: args.eventName,
263
+ ...fields,
264
+ createdAt: now,
265
+ });
266
+ pendingBefore = await bumpCounts(ctx, { siteId: args.siteId, eventId }, { pending: 1 });
267
+ }
268
+ await ctx.scheduler.runAfter(0, internal.metaCapi.send, { outboxId });
269
+ // A shard with pending rows already has the sweep running for them (it
270
+ // stops only when none is pending), so only an idle shard reads its state.
271
+ if (pendingBefore === 0)
272
+ await kickSweep(ctx, now + STALL_MS + 1_000);
273
+ return true;
274
+ }
275
+ /**
276
+ * Queues the event for a lead that entered a column the site's event map
277
+ * points at. Once per lead per event: a lead that comes back to the column,
278
+ * or reached the event another way, is not sent again. Without `meta` or an
279
+ * active pixel nothing is queued. Returns whether a row was queued.
280
+ */
281
+ export async function enqueueColumnEvent(ctx, args) {
282
+ return await enqueue(ctx, {
283
+ siteId: args.siteId,
284
+ leadId: args.leadId,
285
+ eventName: args.eventName,
286
+ eventTime: args.at,
287
+ meta: args.meta,
288
+ });
289
+ }
290
+ /** `Lead` for a lead just recorded, when the pixel sends new leads. */
291
+ export async function enqueueNewLead(ctx, args) {
292
+ if (args.meta === undefined)
293
+ return false;
294
+ const pixel = await sendingPixel(ctx, args.siteId);
295
+ if (!pixel?.sendLeadEvent)
296
+ return false;
297
+ return await enqueue(ctx, { ...args, eventName: "Lead" });
298
+ }
299
+ /** `Purchase` for a sale the host recorded, with its value. */
300
+ export async function enqueuePurchase(ctx, args) {
301
+ // A sale without a value tells Meta nothing to optimize for.
302
+ if (args.valueCents <= 0)
303
+ return false;
304
+ return await enqueue(ctx, {
305
+ siteId: args.siteId,
306
+ leadId: args.leadId,
307
+ eventName: "Purchase",
308
+ eventTime: args.eventTime,
309
+ meta: args.meta,
310
+ value: { valueCents: args.valueCents, currency: args.currency },
311
+ });
312
+ }
313
+ /** Forgets a removed lead's conversions, hashes and history alike. */
314
+ export async function removeLeadConversions(ctx, lead) {
315
+ const rows = await ctx.db
316
+ .query("metaConversionOutbox")
317
+ .withIndex("by_leadId", (range) => range.eq("leadId", lead._id))
318
+ .take(MAX_LEAD_CONVERSIONS);
319
+ for (const row of rows) {
320
+ await ctx.db.delete("metaConversionOutbox", row._id);
321
+ await bumpCounts(ctx, row, { [row.status]: -1 });
322
+ if (row.status === "sent" && row.sentAt !== undefined) {
323
+ await bumpDaily(ctx, row, row.sentAt, -1);
324
+ }
325
+ }
326
+ }
327
+ // ---------------------------------------------------------------------------
328
+ // Reads.
329
+ /**
330
+ * The site's conversions, newest first, with the lead each one reports. The
331
+ * cursor is the last row read: its `createdAt` (`before`) and, since one call
332
+ * can queue several events in the same millisecond, its `eventId`
333
+ * (`beforeEventId`): the page goes on right after that row. With `before`
334
+ * alone, the page starts strictly before that time.
335
+ */
336
+ export async function listConversions(ctx, args) {
337
+ const { before, beforeEventId } = args;
338
+ const query = ctx.db
339
+ .query("metaConversionOutbox")
340
+ .withIndex("by_siteId_and_createdAt", (range) => before === undefined
341
+ ? range.eq("siteId", args.siteId)
342
+ : beforeEventId === undefined
343
+ ? range.eq("siteId", args.siteId).lt("createdAt", before)
344
+ : range.eq("siteId", args.siteId).lte("createdAt", before))
345
+ .order("desc");
346
+ const rows = [];
347
+ // Rows tied with the cursor's time come in a fixed order (the index ends
348
+ // with the creation time): skip them up to the cursor row itself.
349
+ let skipping = before !== undefined && beforeEventId !== undefined;
350
+ for await (const row of query) {
351
+ if (skipping) {
352
+ if (row.createdAt === before) {
353
+ if (row.eventId === beforeEventId)
354
+ skipping = false;
355
+ continue;
356
+ }
357
+ skipping = false;
358
+ }
359
+ rows.push(row);
360
+ if (rows.length >= args.limit)
361
+ break;
362
+ }
363
+ const out = [];
364
+ for (const row of rows) {
365
+ const lead = await ctx.db.get("leads", row.leadId);
366
+ const adId = lead?.ad?.adId ?? null;
367
+ const ad = adId === null
368
+ ? null
369
+ : await ctx.db
370
+ .query("adCatalog")
371
+ .withIndex("by_siteId_and_adId", (range) => range.eq("siteId", row.siteId).eq("adId", adId))
372
+ .unique();
373
+ out.push({
374
+ eventId: row.eventId,
375
+ eventName: row.eventName,
376
+ leadId: row.leadId,
377
+ leadLabel: lead?.label ?? null,
378
+ adId,
379
+ adName: ad?.adName ?? null,
380
+ valueCents: row.valueCents ?? null,
381
+ status: row.status,
382
+ createdAt: row.createdAt,
383
+ sentAt: row.sentAt ?? null,
384
+ });
385
+ }
386
+ return out;
387
+ }
388
+ // ---------------------------------------------------------------------------
389
+ // Sending.
390
+ /** The Conversions API event for one outbox row. */
391
+ export async function conversionEvent(row, userData) {
392
+ return {
393
+ event_name: row.eventName,
394
+ event_time: Math.floor(row.eventTime / 1000),
395
+ event_id: row.eventId,
396
+ action_source: "chat",
397
+ user_data: {
398
+ ...(userData.ph.length > 0 ? { ph: userData.ph } : {}),
399
+ ...(userData.externalId ? { external_id: [userData.externalId] } : {}),
400
+ // Every site Rastro serves today sells in Brazil.
401
+ country: [await sha256Hex("br")],
402
+ },
403
+ ...(row.eventName === "Purchase" &&
404
+ row.valueCents !== undefined &&
405
+ row.currency !== undefined
406
+ ? {
407
+ custom_data: {
408
+ value: row.valueCents / 100,
409
+ currency: row.currency,
410
+ },
411
+ }
412
+ : {}),
413
+ };
414
+ }
415
+ /**
416
+ * Takes one attempt at a pending row: stamps its lease (`attemptStartedAt`,
417
+ * so the sweep leaves a send that may still be in flight alone) and returns
418
+ * what to send, or why the row is dropped.
419
+ */
420
+ export const claimSend = internalMutation({
421
+ args: { outboxId: v.id("metaConversionOutbox"), now: v.number() },
422
+ returns: v.union(v.null(), v.object({ kind: v.literal("drop"), error: v.string() }), v.object({
423
+ kind: v.literal("send"),
424
+ siteId: v.id("sites"),
425
+ attempts: v.number(),
426
+ testEventCode: v.union(v.string(), v.null()),
427
+ event: v.object({
428
+ eventId: v.string(),
429
+ eventName: metaConversionEventNameValidator,
430
+ eventTime: v.number(),
431
+ valueCents: v.optional(v.number()),
432
+ currency: v.optional(v.string()),
433
+ }),
434
+ userData: metaUserDataValidator,
435
+ })),
436
+ handler: async (ctx, args) => {
437
+ const row = await ctx.db.get("metaConversionOutbox", args.outboxId);
438
+ if (row?.status !== "pending")
439
+ return null;
440
+ await ctx.db.patch("metaConversionOutbox", row._id, {
441
+ attemptStartedAt: args.now,
442
+ });
443
+ if (!row.userData)
444
+ return { kind: "drop", error: "NO_USER_DATA" };
445
+ if (args.now - row.eventTime > MAX_EVENT_AGE_MS) {
446
+ return { kind: "drop", error: "EVENT_TOO_OLD" };
447
+ }
448
+ const pixel = await sendingPixel(ctx, row.siteId);
449
+ if (!pixel)
450
+ return { kind: "drop", error: "PIXEL_UNAVAILABLE" };
451
+ return {
452
+ kind: "send",
453
+ siteId: row.siteId,
454
+ attempts: row.attempts,
455
+ testEventCode: pixel.testEventCode ?? null,
456
+ event: {
457
+ eventId: row.eventId,
458
+ eventName: row.eventName,
459
+ eventTime: row.eventTime,
460
+ ...(row.valueCents !== undefined ? { valueCents: row.valueCents } : {}),
461
+ ...(row.currency !== undefined ? { currency: row.currency } : {}),
462
+ },
463
+ userData: row.userData,
464
+ };
465
+ },
466
+ });
467
+ /** Ends a row for good, sent or failed; either way its hashes go. */
468
+ async function closeRow(ctx, row, status, fields = {}) {
469
+ const now = Date.now();
470
+ await ctx.db.patch("metaConversionOutbox", row._id, {
471
+ status,
472
+ attempts: row.attempts + 1,
473
+ userData: undefined,
474
+ lastError: fields.error?.slice(0, 300),
475
+ ...(fields.fbtraceId ? { fbtraceId: fields.fbtraceId.slice(0, 100) } : {}),
476
+ ...(status === "sent" ? { sentAt: now } : {}),
477
+ });
478
+ await bumpCounts(ctx, row, { pending: -1, [status]: 1 });
479
+ if (status === "sent")
480
+ await bumpDaily(ctx, row, now, 1);
481
+ }
482
+ /**
483
+ * Counts a failed attempt: the next one after the backoff, or, when it was
484
+ * the last, the row fails for good.
485
+ */
486
+ async function failAttempt(ctx, row, error) {
487
+ if (row.attempts + 1 >= MAX_SEND_ATTEMPTS) {
488
+ await closeRow(ctx, row, "failed", { error });
489
+ return;
490
+ }
491
+ const attempts = row.attempts + 1;
492
+ const delay = RETRY_DELAYS_MS[Math.min(attempts, RETRY_DELAYS_MS.length) - 1];
493
+ await ctx.db.patch("metaConversionOutbox", row._id, {
494
+ attempts,
495
+ nextAttemptAt: Date.now() + delay,
496
+ lastError: error.slice(0, 300),
497
+ });
498
+ await ctx.scheduler.runAfter(delay, internal.metaCapi.send, {
499
+ outboxId: row._id,
500
+ });
501
+ }
502
+ /**
503
+ * Closes a row: sent, or failed for good. When Meta refused the token, the
504
+ * pixel that sent stops too.
505
+ */
506
+ export const finishSend = internalMutation({
507
+ args: {
508
+ outboxId: v.id("metaConversionOutbox"),
509
+ status: v.union(v.literal("sent"), v.literal("failed")),
510
+ error: v.optional(v.string()),
511
+ fbtraceId: v.optional(v.string()),
512
+ /** The pixel whose own token Meta refused, and the token's IV. */
513
+ refusedPixel: v.optional(v.object({ pixelId: v.string(), tokenIv: v.string() })),
514
+ },
515
+ returns: v.null(),
516
+ handler: async (ctx, args) => {
517
+ const row = await ctx.db.get("metaConversionOutbox", args.outboxId);
518
+ if (row?.status !== "pending")
519
+ return null;
520
+ await closeRow(ctx, row, args.status, {
521
+ ...(args.error !== undefined ? { error: args.error } : {}),
522
+ ...(args.fbtraceId !== undefined ? { fbtraceId: args.fbtraceId } : {}),
523
+ });
524
+ if (args.refusedPixel) {
525
+ const pixel = await sendingPixel(ctx, row.siteId);
526
+ // Only the token that was refused: one stored meanwhile keeps sending.
527
+ if (pixel?.pixelId === args.refusedPixel.pixelId &&
528
+ pixel.source !== "connection" &&
529
+ pixel.tokenIv === args.refusedPixel.tokenIv) {
530
+ await refusePixelToken(ctx, pixel, args.error ?? "token refused");
531
+ }
532
+ }
533
+ return null;
534
+ },
535
+ });
536
+ /** Records a failed attempt: retried after the backoff, or failed for good. */
537
+ export const retrySend = internalMutation({
538
+ args: { outboxId: v.id("metaConversionOutbox"), error: v.string() },
539
+ returns: v.null(),
540
+ handler: async (ctx, args) => {
541
+ const row = await ctx.db.get("metaConversionOutbox", args.outboxId);
542
+ if (row?.status !== "pending")
543
+ return null;
544
+ await failAttempt(ctx, row, args.error);
545
+ return null;
546
+ },
547
+ });
548
+ const graph = () => metaGraph({ fake: env.RASTRO_META_FAKE === "true" });
549
+ /**
550
+ * One attempt at one outbox row. Whatever goes wrong counts as an attempt:
551
+ * Meta's answers by their kind, anything else as `UNEXPECTED`.
552
+ */
553
+ export const send = internalAction({
554
+ args: { outboxId: v.id("metaConversionOutbox") },
555
+ returns: v.null(),
556
+ handler: async (ctx, args) => {
557
+ try {
558
+ await attempt(ctx, args.outboxId);
559
+ }
560
+ catch (error) {
561
+ const message = error instanceof Error ? error.message : String(error);
562
+ await ctx.runMutation(internal.metaCapi.retrySend, {
563
+ outboxId: args.outboxId,
564
+ error: `UNEXPECTED: ${message}`,
565
+ });
566
+ }
567
+ return null;
568
+ },
569
+ });
570
+ async function attempt(ctx, outboxId) {
571
+ const job = await ctx.runMutation(internal.metaCapi.claimSend, {
572
+ outboxId,
573
+ now: Date.now(),
574
+ });
575
+ if (!job)
576
+ return;
577
+ const close = (status, extra) => ctx.runMutation(internal.metaCapi.finishSend, {
578
+ outboxId,
579
+ status,
580
+ ...extra,
581
+ });
582
+ if (job.kind === "drop") {
583
+ await close("failed", { error: job.error });
584
+ return;
585
+ }
586
+ const sealed = await ctx.runQuery(internal.meta.loadPixelToken, {
587
+ siteId: job.siteId,
588
+ });
589
+ if (!sealed) {
590
+ await close("failed", { error: "PIXEL_UNAVAILABLE" });
591
+ return;
592
+ }
593
+ let token;
594
+ try {
595
+ token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
596
+ cipher: sealed.tokenCipher,
597
+ iv: sealed.tokenIv,
598
+ });
599
+ }
600
+ catch {
601
+ await close("failed", { error: "SECRETS_KEY_MISSING" });
602
+ return;
603
+ }
604
+ let result;
605
+ try {
606
+ result = await graph().conversions(token, sealed.pixelId, {
607
+ data: [await conversionEvent(job.event, job.userData)],
608
+ ...(job.testEventCode ? { test_event_code: job.testEventCode } : {}),
609
+ });
610
+ }
611
+ catch (error) {
612
+ // Anything but Meta's own answer is the caller's UNEXPECTED.
613
+ if (!(error instanceof MetaGraphError))
614
+ throw error;
615
+ if (error.kind === "token") {
616
+ // Whoever owns the refused token stops: the pixel, or its connection
617
+ // (and with it every pixel and ad account it fed).
618
+ switch (sealed.source) {
619
+ case "token":
620
+ await close("failed", {
621
+ error: error.message,
622
+ refusedPixel: {
623
+ pixelId: sealed.pixelId,
624
+ tokenIv: sealed.tokenIv,
625
+ },
626
+ });
627
+ break;
628
+ case "connection":
629
+ await close("failed", { error: error.message });
630
+ if (sealed.connectionId) {
631
+ // Only if it is still the stored token (a reconnect wins).
632
+ await ctx.runMutation(internal.meta.markConnectionInvalid, {
633
+ connectionId: sealed.connectionId,
634
+ tokenIv: sealed.tokenIv,
635
+ message: error.message,
636
+ });
637
+ }
638
+ break;
639
+ }
640
+ }
641
+ else if (error.kind === "permission") {
642
+ // A permission error does not heal by waiting.
643
+ await close("failed", { error: error.message });
644
+ }
645
+ else {
646
+ await ctx.runMutation(internal.metaCapi.retrySend, {
647
+ outboxId,
648
+ error: error.message,
649
+ });
650
+ }
651
+ return;
652
+ }
653
+ await close("sent", {
654
+ ...(result.fbtraceId ? { fbtraceId: result.fbtraceId } : {}),
655
+ });
656
+ }
657
+ // ---------------------------------------------------------------------------
658
+ // The sweep: a send lost on the way (a crashed action, a failed write) leaves
659
+ // its row pending. A chain re-sends such rows while any row is pending.
660
+ /** Makes sure the sweep runs by `at`, starting the chain if it stopped. */
661
+ async function kickSweep(ctx, at) {
662
+ const state = await ctx.db
663
+ .query("metaCapiSweep")
664
+ .withIndex("by_key", (range) => range.eq("key", "sweep"))
665
+ .unique();
666
+ if (state) {
667
+ const job = await ctx.db.system.get("_scheduled_functions", state.jobId);
668
+ const alive = job?.state.kind === "pending" || job?.state.kind === "inProgress";
669
+ if (alive && state.scheduledFor <= at)
670
+ return;
671
+ if (job?.state.kind === "pending")
672
+ await ctx.scheduler.cancel(state.jobId);
673
+ }
674
+ const jobId = await ctx.scheduler.runAt(at, internal.metaCapi.sweep, {});
675
+ if (state) {
676
+ await ctx.db.patch("metaCapiSweep", state._id, { scheduledFor: at, jobId });
677
+ }
678
+ else {
679
+ await ctx.db.insert("metaCapiSweep", {
680
+ key: "sweep",
681
+ scheduledFor: at,
682
+ jobId,
683
+ });
684
+ }
685
+ }
686
+ /**
687
+ * Re-sends the rows whose attempt was due more than STALL_MS ago, the lost
688
+ * attempt counted: past the last one, or past MAX_EVENT_AGE_MS, the row
689
+ * fails for good. Then runs again when the oldest pending row could stall,
690
+ * or stops.
691
+ */
692
+ export const sweep = internalMutation({
693
+ args: {},
694
+ returns: v.null(),
695
+ handler: async (ctx) => {
696
+ const now = Date.now();
697
+ const stalled = await ctx.db
698
+ .query("metaConversionOutbox")
699
+ .withIndex("by_status_and_nextAttemptAt", (range) => range.eq("status", "pending").lt("nextAttemptAt", now - STALL_MS))
700
+ .take(SWEEP_BATCH);
701
+ let handled = 0;
702
+ for (const row of stalled) {
703
+ // A send that took its attempt less than STALL_MS ago may still be in
704
+ // flight: its lease holds.
705
+ if ((row.attemptStartedAt ?? 0) >= now - STALL_MS)
706
+ continue;
707
+ handled += 1;
708
+ if (now - row.eventTime > MAX_EVENT_AGE_MS) {
709
+ await closeRow(ctx, row, "failed", { error: "EVENT_TOO_OLD" });
710
+ continue;
711
+ }
712
+ await failAttempt(ctx, row, "UNEXPECTED: the send stalled");
713
+ }
714
+ const state = await ctx.db
715
+ .query("metaCapiSweep")
716
+ .withIndex("by_key", (range) => range.eq("key", "sweep"))
717
+ .unique();
718
+ let at = now;
719
+ // A full batch it acted on may have more behind it; otherwise wait for
720
+ // the oldest pending row to be able to stall.
721
+ if (stalled.length < SWEEP_BATCH || handled === 0) {
722
+ const oldest = await ctx.db
723
+ .query("metaConversionOutbox")
724
+ .withIndex("by_status_and_nextAttemptAt", (range) => range.eq("status", "pending"))
725
+ .first();
726
+ at = oldest
727
+ ? Math.max(oldest.nextAttemptAt, oldest.attemptStartedAt ?? 0) +
728
+ STALL_MS +
729
+ 1_000
730
+ : null;
731
+ }
732
+ if (at === null) {
733
+ if (state)
734
+ await ctx.db.delete("metaCapiSweep", state._id);
735
+ return null;
736
+ }
737
+ const jobId = await ctx.scheduler.runAt(at, internal.metaCapi.sweep, {});
738
+ if (state) {
739
+ await ctx.db.patch("metaCapiSweep", state._id, {
740
+ scheduledFor: at,
741
+ jobId,
742
+ });
743
+ }
744
+ else {
745
+ await ctx.db.insert("metaCapiSweep", {
746
+ key: "sweep",
747
+ scheduledFor: at,
748
+ jobId,
749
+ });
750
+ }
751
+ return null;
752
+ },
753
+ });
754
+ //# sourceMappingURL=metaCapi.js.map