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