@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,1164 @@
|
|
|
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
|
+
internalQuery,
|
|
9
|
+
type MutationCtx,
|
|
10
|
+
} from "./_generated/server.js";
|
|
11
|
+
import { DAY_MS } from "./constants.js";
|
|
12
|
+
import { localDayStart, localOffsetMs } from "./localTime.js";
|
|
13
|
+
import {
|
|
14
|
+
metaGraph,
|
|
15
|
+
MetaGraphError,
|
|
16
|
+
type GraphAd,
|
|
17
|
+
type GraphSpendRow,
|
|
18
|
+
type GraphUsage,
|
|
19
|
+
type MetaGraph,
|
|
20
|
+
} from "./metaGraph.js";
|
|
21
|
+
import { decryptSecret } from "./secrets.js";
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* The Meta sync. Components have no crons, so one scheduler chain (`loop`)
|
|
25
|
+
* takes the linked ad accounts whose `nextSyncAt` has come, a few at a time,
|
|
26
|
+
* and hands each to `syncAccount` under a fresh `runId`. The action reads
|
|
27
|
+
* Meta and writes back through small mutations, each of which first checks
|
|
28
|
+
* that the link still exists and that the run still holds it: an account
|
|
29
|
+
* unlinked, or taken over by a newer run, takes no write from an old one.
|
|
30
|
+
* The chain stops when no linked account is waiting; a link, a new token or
|
|
31
|
+
* `syncNow` starts it again.
|
|
32
|
+
*
|
|
33
|
+
* Cadence per account, in the account's own timezone:
|
|
34
|
+
* - after linking, a 90-day backfill, one 30-day block per run, resumable;
|
|
35
|
+
* - then today and yesterday every 45 minutes;
|
|
36
|
+
* - the trailing 7 days and the full catalog once a day, from 04:00;
|
|
37
|
+
* - the trailing 28 days once a week (Meta settles spend within 28 days);
|
|
38
|
+
* - back after a pause of more than 27 days, the gap is backfilled first.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export const SYNC_INTERVAL_MS = 45 * 60_000;
|
|
42
|
+
/** `nextSyncAt` of an account the chain must not take: its token failed. */
|
|
43
|
+
export const PAUSED_AT = Number.MAX_SAFE_INTEGER;
|
|
44
|
+
/** A link to one connection at most; the chain pauses and resumes them all. */
|
|
45
|
+
export const MAX_LINKS_PER_CONNECTION = 500;
|
|
46
|
+
const LEASE_MS = 15 * 60_000;
|
|
47
|
+
const RETRY_MS = 15 * 60_000;
|
|
48
|
+
const MAX_RETRY_MS = 6 * 60 * 60_000;
|
|
49
|
+
const PERMISSION_RETRY_MS = 6 * 60 * 60_000;
|
|
50
|
+
const THROTTLE_BACKOFF_MS = 60 * 60_000;
|
|
51
|
+
const BACKFILL_NEXT_BLOCK_MS = 1_000;
|
|
52
|
+
/** Above this share of any Meta rate limit, the next sync waits longer. */
|
|
53
|
+
export const USAGE_BACKOFF_PCT = 75;
|
|
54
|
+
const LOOP_BATCH = 4;
|
|
55
|
+
const LOOP_STAGGER_MS = 30_000;
|
|
56
|
+
const BACKFILL_DAYS = 90;
|
|
57
|
+
const BACKFILL_BLOCK_DAYS = 30;
|
|
58
|
+
const GAP_DAYS = 27;
|
|
59
|
+
const DAILY_RESYNC_DAYS = 7;
|
|
60
|
+
const WEEKLY_RESYNC_DAYS = 28;
|
|
61
|
+
const DAILY_RESYNC_HOUR = 4;
|
|
62
|
+
const WEEK_MS = 7 * DAY_MS;
|
|
63
|
+
const SPEND_CHUNK = 400;
|
|
64
|
+
const CATALOG_CHUNK = 100;
|
|
65
|
+
const PAGE = 500;
|
|
66
|
+
const MISSING_NAMES_LOOKBACK_DAYS = 7;
|
|
67
|
+
const MAX_MISSING_NAMES = 50;
|
|
68
|
+
const MAX_THUMBNAILS_PER_RUN = 300;
|
|
69
|
+
const THUMBNAIL_CONCURRENCY = 8;
|
|
70
|
+
const MAX_ERROR = 300;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Dates. Meta files spend under the account-local date; Rastro stores that
|
|
74
|
+
// date's UTC midnight as `dayStart`.
|
|
75
|
+
|
|
76
|
+
/** The account-local calendar date of an instant, `YYYY-MM-DD`. */
|
|
77
|
+
export function localDate(timestamp: number, timeZone: string): string {
|
|
78
|
+
return new Date(timestamp + localOffsetMs(timestamp, timeZone))
|
|
79
|
+
.toISOString()
|
|
80
|
+
.slice(0, 10);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function dateDayStart(date: string): number {
|
|
84
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
85
|
+
return Date.UTC(year!, month! - 1, day!);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const shiftDate = (date: string, days: number) =>
|
|
89
|
+
new Date(dateDayStart(date) + days * DAY_MS).toISOString().slice(0, 10);
|
|
90
|
+
const minDate = (left: string, right: string) => (left < right ? left : right);
|
|
91
|
+
const maxDate = (left: string, right: string) => (left > right ? left : right);
|
|
92
|
+
|
|
93
|
+
export type SyncRange = { since: string; until: string };
|
|
94
|
+
export type SyncPlan =
|
|
95
|
+
| {
|
|
96
|
+
kind: "backfill";
|
|
97
|
+
range: SyncRange;
|
|
98
|
+
/** The date the next block starts from, or null when this is the last. */
|
|
99
|
+
nextFrom: string | null;
|
|
100
|
+
fullCatalog: boolean;
|
|
101
|
+
}
|
|
102
|
+
| {
|
|
103
|
+
kind: "live";
|
|
104
|
+
range: SyncRange;
|
|
105
|
+
fullCatalog: boolean;
|
|
106
|
+
daily: boolean;
|
|
107
|
+
weekly: boolean;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** What one run of an account reads from Meta, by its state and the time. */
|
|
111
|
+
export function planSync(
|
|
112
|
+
account: Pick<
|
|
113
|
+
Doc<"siteAdAccounts">,
|
|
114
|
+
| "timezone"
|
|
115
|
+
| "lastSyncAt"
|
|
116
|
+
| "lastDailyResyncAt"
|
|
117
|
+
| "lastFullResyncAt"
|
|
118
|
+
| "backfillFrom"
|
|
119
|
+
>,
|
|
120
|
+
now: number,
|
|
121
|
+
): SyncPlan {
|
|
122
|
+
const today = localDate(now, account.timezone);
|
|
123
|
+
const earliest = shiftDate(today, -(BACKFILL_DAYS - 1));
|
|
124
|
+
let from = account.backfillFrom;
|
|
125
|
+
// Never synced (whatever failed before): the backfill is still owed.
|
|
126
|
+
if (from === undefined && account.lastSyncAt === undefined) from = earliest;
|
|
127
|
+
// Back after a long pause: fill the gap before the live cadence resumes.
|
|
128
|
+
if (
|
|
129
|
+
from === undefined &&
|
|
130
|
+
account.lastSyncAt !== undefined &&
|
|
131
|
+
now - account.lastSyncAt > GAP_DAYS * DAY_MS
|
|
132
|
+
) {
|
|
133
|
+
from = maxDate(
|
|
134
|
+
shiftDate(localDate(account.lastSyncAt, account.timezone), -1),
|
|
135
|
+
earliest,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (from !== undefined) {
|
|
139
|
+
const since = minDate(maxDate(from, earliest), today);
|
|
140
|
+
const until = minDate(shiftDate(since, BACKFILL_BLOCK_DAYS - 1), today);
|
|
141
|
+
const last = until === today;
|
|
142
|
+
return {
|
|
143
|
+
kind: "backfill",
|
|
144
|
+
range: { since, until },
|
|
145
|
+
nextFrom: last ? null : shiftDate(until, 1),
|
|
146
|
+
fullCatalog: last,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const dailySlot =
|
|
150
|
+
localDayStart(now, account.timezone) + DAILY_RESYNC_HOUR * 3_600_000;
|
|
151
|
+
const daily =
|
|
152
|
+
now >= dailySlot && (account.lastDailyResyncAt ?? 0) < dailySlot;
|
|
153
|
+
const weekly =
|
|
154
|
+
daily && now - (account.lastFullResyncAt ?? 0) >= WEEK_MS - DAY_MS / 2;
|
|
155
|
+
const days = weekly ? WEEKLY_RESYNC_DAYS : daily ? DAILY_RESYNC_DAYS : 2;
|
|
156
|
+
return {
|
|
157
|
+
kind: "live",
|
|
158
|
+
range: { since: shiftDate(today, -(days - 1)), until: today },
|
|
159
|
+
fullCatalog: daily,
|
|
160
|
+
daily,
|
|
161
|
+
weekly,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The wait after `failures` failed runs in a row: 15 min doubling to 6 h. */
|
|
166
|
+
export function retryDelay(failures: number): number {
|
|
167
|
+
return Math.min(RETRY_MS * 2 ** Math.max(failures - 1, 0), MAX_RETRY_MS);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// The chain.
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Makes sure the chain runs by `at`: starts it, or pulls a later run earlier.
|
|
175
|
+
* Called by every write that gives the chain work.
|
|
176
|
+
*/
|
|
177
|
+
export async function kickSyncLoop(ctx: MutationCtx, at: number) {
|
|
178
|
+
const state = await ctx.db
|
|
179
|
+
.query("metaSyncLoop")
|
|
180
|
+
.withIndex("by_key", (range) => range.eq("key", "loop"))
|
|
181
|
+
.unique();
|
|
182
|
+
if (state) {
|
|
183
|
+
const job = await ctx.db.system.get("_scheduled_functions", state.jobId);
|
|
184
|
+
const alive =
|
|
185
|
+
job?.state.kind === "pending" || job?.state.kind === "inProgress";
|
|
186
|
+
if (alive && state.scheduledFor <= at) return;
|
|
187
|
+
if (job?.state.kind === "pending") await ctx.scheduler.cancel(state.jobId);
|
|
188
|
+
}
|
|
189
|
+
const jobId = await ctx.scheduler.runAt(at, internal.metaSync.loop, {});
|
|
190
|
+
if (state) {
|
|
191
|
+
await ctx.db.patch("metaSyncLoop", state._id, { scheduledFor: at, jobId });
|
|
192
|
+
} else {
|
|
193
|
+
await ctx.db.insert("metaSyncLoop", {
|
|
194
|
+
key: "loop",
|
|
195
|
+
scheduledFor: at,
|
|
196
|
+
jobId,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One link of the chain: hands the due accounts to fresh runs, moves on. */
|
|
202
|
+
export const loop = internalMutation({
|
|
203
|
+
args: {},
|
|
204
|
+
returns: v.null(),
|
|
205
|
+
handler: async (ctx) => {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const due = await ctx.db
|
|
208
|
+
.query("siteAdAccounts")
|
|
209
|
+
.withIndex("by_nextSyncAt", (range) => range.lte("nextSyncAt", now))
|
|
210
|
+
.take(LOOP_BATCH);
|
|
211
|
+
for (const account of due) {
|
|
212
|
+
const runId = crypto.randomUUID();
|
|
213
|
+
await ctx.db.patch("siteAdAccounts", account._id, {
|
|
214
|
+
runId,
|
|
215
|
+
nextSyncAt: now + LEASE_MS,
|
|
216
|
+
// A run that still held the account never finished: say so. Its
|
|
217
|
+
// writes are refused from here on, since the account has a new run.
|
|
218
|
+
...(account.runId !== undefined
|
|
219
|
+
? { lastError: "RUN_TIMEOUT: the previous sync did not finish" }
|
|
220
|
+
: {}),
|
|
221
|
+
});
|
|
222
|
+
await ctx.scheduler.runAfter(0, internal.metaSync.syncAccount, {
|
|
223
|
+
siteAdAccountId: account._id,
|
|
224
|
+
runId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const state = await ctx.db
|
|
228
|
+
.query("metaSyncLoop")
|
|
229
|
+
.withIndex("by_key", (range) => range.eq("key", "loop"))
|
|
230
|
+
.unique();
|
|
231
|
+
const next = await ctx.db
|
|
232
|
+
.query("siteAdAccounts")
|
|
233
|
+
.withIndex("by_nextSyncAt", (range) => range.lt("nextSyncAt", PAUSED_AT))
|
|
234
|
+
.first();
|
|
235
|
+
if (!next) {
|
|
236
|
+
if (state) await ctx.db.delete("metaSyncLoop", state._id);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
// More accounts due than one batch: spread them out a little.
|
|
240
|
+
const at = Math.max(
|
|
241
|
+
next.nextSyncAt,
|
|
242
|
+
now + (due.length ? LOOP_STAGGER_MS : 0),
|
|
243
|
+
);
|
|
244
|
+
const jobId = await ctx.scheduler.runAt(at, internal.metaSync.loop, {});
|
|
245
|
+
if (state) {
|
|
246
|
+
await ctx.db.patch("metaSyncLoop", state._id, {
|
|
247
|
+
scheduledFor: at,
|
|
248
|
+
jobId,
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
await ctx.db.insert("metaSyncLoop", {
|
|
252
|
+
key: "loop",
|
|
253
|
+
scheduledFor: at,
|
|
254
|
+
jobId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// One account.
|
|
263
|
+
|
|
264
|
+
const runArgs = {
|
|
265
|
+
siteAdAccountId: v.id("siteAdAccounts"),
|
|
266
|
+
runId: v.string(),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** The link, if it still exists and this run still holds it. */
|
|
270
|
+
async function heldLink(
|
|
271
|
+
ctx: MutationCtx,
|
|
272
|
+
args: { siteAdAccountId: Id<"siteAdAccounts">; runId: string },
|
|
273
|
+
) {
|
|
274
|
+
const link = await ctx.db.get("siteAdAccounts", args.siteAdAccountId);
|
|
275
|
+
return link && link.runId === args.runId ? link : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const loadJob = internalQuery({
|
|
279
|
+
args: runArgs,
|
|
280
|
+
returns: v.union(
|
|
281
|
+
v.null(),
|
|
282
|
+
v.object({
|
|
283
|
+
siteId: v.id("sites"),
|
|
284
|
+
adAccountId: v.string(),
|
|
285
|
+
timezone: v.string(),
|
|
286
|
+
lastSyncAt: v.optional(v.number()),
|
|
287
|
+
lastDailyResyncAt: v.optional(v.number()),
|
|
288
|
+
lastFullResyncAt: v.optional(v.number()),
|
|
289
|
+
backfillFrom: v.optional(v.string()),
|
|
290
|
+
connectionActive: v.boolean(),
|
|
291
|
+
tokenCipher: v.string(),
|
|
292
|
+
tokenIv: v.string(),
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
handler: async (ctx, args) => {
|
|
296
|
+
const account = await ctx.db.get("siteAdAccounts", args.siteAdAccountId);
|
|
297
|
+
if (!account || account.runId !== args.runId) return null;
|
|
298
|
+
const connection = await ctx.db.get(
|
|
299
|
+
"metaConnections",
|
|
300
|
+
account.connectionId,
|
|
301
|
+
);
|
|
302
|
+
if (!connection) return null;
|
|
303
|
+
return {
|
|
304
|
+
siteId: account.siteId,
|
|
305
|
+
adAccountId: account.adAccountId,
|
|
306
|
+
timezone: account.timezone,
|
|
307
|
+
lastSyncAt: account.lastSyncAt,
|
|
308
|
+
lastDailyResyncAt: account.lastDailyResyncAt,
|
|
309
|
+
lastFullResyncAt: account.lastFullResyncAt,
|
|
310
|
+
backfillFrom: account.backfillFrom,
|
|
311
|
+
connectionActive: connection.status === "active",
|
|
312
|
+
tokenCipher: connection.tokenCipher,
|
|
313
|
+
tokenIv: connection.tokenIv,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
type SpendFigures = { spendCents: number; impressions: number; clicks: number };
|
|
319
|
+
type SpendKey = { siteId: Id<"sites">; adAccountId: string; dayStart: number };
|
|
320
|
+
|
|
321
|
+
/** Adds `delta` to the site-day aggregate, dropping a row that reaches 0. */
|
|
322
|
+
async function bumpSiteSpend(
|
|
323
|
+
ctx: MutationCtx,
|
|
324
|
+
key: SpendKey,
|
|
325
|
+
delta: SpendFigures,
|
|
326
|
+
) {
|
|
327
|
+
if (!delta.spendCents && !delta.impressions && !delta.clicks) return;
|
|
328
|
+
const row = await ctx.db
|
|
329
|
+
.query("siteSpendDaily")
|
|
330
|
+
.withIndex("by_siteId_and_adAccountId_and_dayStart", (range) =>
|
|
331
|
+
range
|
|
332
|
+
.eq("siteId", key.siteId)
|
|
333
|
+
.eq("adAccountId", key.adAccountId)
|
|
334
|
+
.eq("dayStart", key.dayStart),
|
|
335
|
+
)
|
|
336
|
+
.unique();
|
|
337
|
+
const next = {
|
|
338
|
+
spendCents: (row?.spendCents ?? 0) + delta.spendCents,
|
|
339
|
+
impressions: (row?.impressions ?? 0) + delta.impressions,
|
|
340
|
+
clicks: (row?.clicks ?? 0) + delta.clicks,
|
|
341
|
+
};
|
|
342
|
+
if (row && !next.spendCents && !next.impressions && !next.clicks) {
|
|
343
|
+
await ctx.db.delete("siteSpendDaily", row._id);
|
|
344
|
+
} else if (row) {
|
|
345
|
+
await ctx.db.patch("siteSpendDaily", row._id, next);
|
|
346
|
+
} else {
|
|
347
|
+
await ctx.db.insert("siteSpendDaily", { ...key, ...next });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const figures = (row: SpendFigures): SpendFigures => ({
|
|
352
|
+
spendCents: row.spendCents,
|
|
353
|
+
impressions: row.impressions,
|
|
354
|
+
clicks: row.clicks,
|
|
355
|
+
});
|
|
356
|
+
const negate = (row: SpendFigures): SpendFigures => ({
|
|
357
|
+
spendCents: -row.spendCents,
|
|
358
|
+
impressions: -row.impressions,
|
|
359
|
+
clicks: -row.clicks,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const spendRowValidator = v.object({
|
|
363
|
+
adId: v.string(),
|
|
364
|
+
dayStart: v.number(),
|
|
365
|
+
spendCents: v.number(),
|
|
366
|
+
impressions: v.number(),
|
|
367
|
+
clicks: v.number(),
|
|
368
|
+
conversations: v.number(),
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Upserts one chunk of spend rows, stamping them with the run's start, and
|
|
373
|
+
* keeps the site-day aggregate in step. `stop` when the run lost the link.
|
|
374
|
+
*/
|
|
375
|
+
export const saveSpend = internalMutation({
|
|
376
|
+
args: {
|
|
377
|
+
...runArgs,
|
|
378
|
+
syncedAt: v.number(),
|
|
379
|
+
rows: v.array(spendRowValidator),
|
|
380
|
+
},
|
|
381
|
+
returns: v.object({ stop: v.boolean() }),
|
|
382
|
+
handler: async (ctx, args) => {
|
|
383
|
+
const link = await heldLink(ctx, args);
|
|
384
|
+
if (!link) return { stop: true };
|
|
385
|
+
for (const row of args.rows) {
|
|
386
|
+
const key = {
|
|
387
|
+
siteId: link.siteId,
|
|
388
|
+
adAccountId: link.adAccountId,
|
|
389
|
+
dayStart: row.dayStart,
|
|
390
|
+
};
|
|
391
|
+
const existing = await ctx.db
|
|
392
|
+
.query("adSpendDaily")
|
|
393
|
+
.withIndex("by_siteId_and_adId_and_dayStart", (range) =>
|
|
394
|
+
range
|
|
395
|
+
.eq("siteId", link.siteId)
|
|
396
|
+
.eq("adId", row.adId)
|
|
397
|
+
.eq("dayStart", row.dayStart),
|
|
398
|
+
)
|
|
399
|
+
.unique();
|
|
400
|
+
const fields = {
|
|
401
|
+
adAccountId: link.adAccountId,
|
|
402
|
+
spendCents: row.spendCents,
|
|
403
|
+
impressions: row.impressions,
|
|
404
|
+
clicks: row.clicks,
|
|
405
|
+
metaConversations: row.conversations,
|
|
406
|
+
// Never older than what a later run already wrote.
|
|
407
|
+
updatedAt: Math.max(existing?.updatedAt ?? 0, args.syncedAt),
|
|
408
|
+
};
|
|
409
|
+
if (existing) {
|
|
410
|
+
await bumpSiteSpend(
|
|
411
|
+
ctx,
|
|
412
|
+
{ ...key, adAccountId: existing.adAccountId },
|
|
413
|
+
negate(existing),
|
|
414
|
+
);
|
|
415
|
+
await ctx.db.patch("adSpendDaily", existing._id, fields);
|
|
416
|
+
} else {
|
|
417
|
+
await ctx.db.insert("adSpendDaily", {
|
|
418
|
+
siteId: link.siteId,
|
|
419
|
+
adId: row.adId,
|
|
420
|
+
dayStart: row.dayStart,
|
|
421
|
+
...fields,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
await bumpSiteSpend(ctx, key, figures(row));
|
|
425
|
+
}
|
|
426
|
+
return { stop: false };
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* After a complete range is saved, drops the account's rows in it that the
|
|
432
|
+
* run did not touch: Meta leaves out an ad-day with nothing, so a day
|
|
433
|
+
* corrected to zero disappears from its answer. Pages until done.
|
|
434
|
+
*/
|
|
435
|
+
export const dropStaleSpend = internalMutation({
|
|
436
|
+
args: {
|
|
437
|
+
...runArgs,
|
|
438
|
+
firstDay: v.number(),
|
|
439
|
+
lastDay: v.number(),
|
|
440
|
+
syncedAt: v.number(),
|
|
441
|
+
cursor: v.union(v.string(), v.null()),
|
|
442
|
+
},
|
|
443
|
+
returns: v.object({
|
|
444
|
+
stop: v.boolean(),
|
|
445
|
+
done: v.boolean(),
|
|
446
|
+
cursor: v.union(v.string(), v.null()),
|
|
447
|
+
}),
|
|
448
|
+
handler: async (ctx, args) => {
|
|
449
|
+
const link = await heldLink(ctx, args);
|
|
450
|
+
if (!link) return { stop: true, done: true, cursor: null };
|
|
451
|
+
const page = await ctx.db
|
|
452
|
+
.query("adSpendDaily")
|
|
453
|
+
.withIndex("by_siteId_and_adAccountId_and_dayStart", (range) =>
|
|
454
|
+
range
|
|
455
|
+
.eq("siteId", link.siteId)
|
|
456
|
+
.eq("adAccountId", link.adAccountId)
|
|
457
|
+
.gte("dayStart", args.firstDay)
|
|
458
|
+
.lte("dayStart", args.lastDay),
|
|
459
|
+
)
|
|
460
|
+
.paginate({ cursor: args.cursor, numItems: PAGE });
|
|
461
|
+
for (const row of page.page) {
|
|
462
|
+
if (row.updatedAt < args.syncedAt) {
|
|
463
|
+
await ctx.db.delete("adSpendDaily", row._id);
|
|
464
|
+
await bumpSiteSpend(
|
|
465
|
+
ctx,
|
|
466
|
+
{
|
|
467
|
+
siteId: row.siteId,
|
|
468
|
+
adAccountId: row.adAccountId,
|
|
469
|
+
dayStart: row.dayStart,
|
|
470
|
+
},
|
|
471
|
+
negate(row),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return { stop: false, done: page.isDone, cursor: page.continueCursor };
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const catalogAdValidator = v.object({
|
|
480
|
+
adId: v.string(),
|
|
481
|
+
adName: v.optional(v.string()),
|
|
482
|
+
adsetId: v.optional(v.string()),
|
|
483
|
+
adsetName: v.optional(v.string()),
|
|
484
|
+
campaignId: v.optional(v.string()),
|
|
485
|
+
campaignName: v.optional(v.string()),
|
|
486
|
+
adStatus: v.optional(v.string()),
|
|
487
|
+
creativeType: v.optional(
|
|
488
|
+
v.union(
|
|
489
|
+
v.literal("video"),
|
|
490
|
+
v.literal("image"),
|
|
491
|
+
v.literal("carousel"),
|
|
492
|
+
v.literal("other"),
|
|
493
|
+
),
|
|
494
|
+
),
|
|
495
|
+
/** Meta's thumbnail, only to decide whether one must be copied. */
|
|
496
|
+
hasThumbnail: v.optional(v.boolean()),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Names, status and creative for the account's ads. A missing field keeps the
|
|
501
|
+
* stored value. Returns the ads that have a thumbnail at Meta but none in
|
|
502
|
+
* storage yet; `stop` when the run lost the link.
|
|
503
|
+
*/
|
|
504
|
+
export const upsertCatalog = internalMutation({
|
|
505
|
+
args: { ...runArgs, ads: v.array(catalogAdValidator) },
|
|
506
|
+
returns: v.object({
|
|
507
|
+
stop: v.boolean(),
|
|
508
|
+
needThumbnail: v.array(v.string()),
|
|
509
|
+
}),
|
|
510
|
+
handler: async (ctx, args) => {
|
|
511
|
+
const link = await heldLink(ctx, args);
|
|
512
|
+
if (!link) return { stop: true, needThumbnail: [] };
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
const needThumbnail: string[] = [];
|
|
515
|
+
for (const { hasThumbnail, ...ad } of args.ads) {
|
|
516
|
+
const defined = Object.fromEntries(
|
|
517
|
+
Object.entries(ad).filter(([, value]) => value !== undefined),
|
|
518
|
+
);
|
|
519
|
+
const row = await ctx.db
|
|
520
|
+
.query("adCatalog")
|
|
521
|
+
.withIndex("by_siteId_and_adId", (range) =>
|
|
522
|
+
range.eq("siteId", link.siteId).eq("adId", ad.adId),
|
|
523
|
+
)
|
|
524
|
+
.unique();
|
|
525
|
+
if (row) {
|
|
526
|
+
await ctx.db.patch("adCatalog", row._id, {
|
|
527
|
+
...defined,
|
|
528
|
+
adAccountId: link.adAccountId,
|
|
529
|
+
updatedAt: now,
|
|
530
|
+
});
|
|
531
|
+
if (hasThumbnail && !row.thumbnailStorageId) {
|
|
532
|
+
needThumbnail.push(ad.adId);
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
await ctx.db.insert("adCatalog", {
|
|
536
|
+
siteId: link.siteId,
|
|
537
|
+
...defined,
|
|
538
|
+
adId: ad.adId,
|
|
539
|
+
adAccountId: link.adAccountId,
|
|
540
|
+
updatedAt: now,
|
|
541
|
+
});
|
|
542
|
+
if (hasThumbnail) needThumbnail.push(ad.adId);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { stop: false, needThumbnail };
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
export const setThumbnail = internalMutation({
|
|
550
|
+
args: {
|
|
551
|
+
siteId: v.id("sites"),
|
|
552
|
+
adId: v.string(),
|
|
553
|
+
storageId: v.id("_storage"),
|
|
554
|
+
},
|
|
555
|
+
returns: v.null(),
|
|
556
|
+
handler: async (ctx, args) => {
|
|
557
|
+
const row = await ctx.db
|
|
558
|
+
.query("adCatalog")
|
|
559
|
+
.withIndex("by_siteId_and_adId", (range) =>
|
|
560
|
+
range.eq("siteId", args.siteId).eq("adId", args.adId),
|
|
561
|
+
)
|
|
562
|
+
.unique();
|
|
563
|
+
if (!row) {
|
|
564
|
+
await ctx.storage.delete(args.storageId);
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
if (row.thumbnailStorageId) {
|
|
568
|
+
await ctx.storage.delete(row.thumbnailStorageId);
|
|
569
|
+
}
|
|
570
|
+
await ctx.db.patch("adCatalog", row._id, {
|
|
571
|
+
thumbnailStorageId: args.storageId,
|
|
572
|
+
updatedAt: Date.now(),
|
|
573
|
+
});
|
|
574
|
+
return null;
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Ads that brought the site leads lately but have no name yet, to ask the
|
|
580
|
+
* account for. An ad the catalog already files under another account is not
|
|
581
|
+
* this account's to name.
|
|
582
|
+
*/
|
|
583
|
+
export const missingAdNames = internalQuery({
|
|
584
|
+
args: {
|
|
585
|
+
siteId: v.id("sites"),
|
|
586
|
+
adAccountId: v.string(),
|
|
587
|
+
since: v.number(),
|
|
588
|
+
},
|
|
589
|
+
returns: v.array(v.string()),
|
|
590
|
+
handler: async (ctx, args) => {
|
|
591
|
+
const rows = await ctx.db
|
|
592
|
+
.query("leadDailyRollups")
|
|
593
|
+
.withIndex("by_siteId_and_dimension_and_dayStart_and_key", (range) =>
|
|
594
|
+
range
|
|
595
|
+
.eq("siteId", args.siteId)
|
|
596
|
+
.eq("dimension", "ad")
|
|
597
|
+
.gte("dayStart", args.since),
|
|
598
|
+
)
|
|
599
|
+
.take(2_000);
|
|
600
|
+
const missing: string[] = [];
|
|
601
|
+
for (const adId of new Set(rows.map((row) => row.key))) {
|
|
602
|
+
const catalog = await ctx.db
|
|
603
|
+
.query("adCatalog")
|
|
604
|
+
.withIndex("by_siteId_and_adId", (range) =>
|
|
605
|
+
range.eq("siteId", args.siteId).eq("adId", adId),
|
|
606
|
+
)
|
|
607
|
+
.unique();
|
|
608
|
+
if (catalog?.adName) continue;
|
|
609
|
+
if (catalog?.adAccountId && catalog.adAccountId !== args.adAccountId) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
missing.push(adId);
|
|
613
|
+
if (missing.length >= MAX_MISSING_NAMES) break;
|
|
614
|
+
}
|
|
615
|
+
return missing;
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const outcomeValidator = v.union(
|
|
620
|
+
v.object({
|
|
621
|
+
kind: v.literal("ok"),
|
|
622
|
+
usagePct: v.number(),
|
|
623
|
+
regainMs: v.union(v.number(), v.null()),
|
|
624
|
+
plan: v.union(
|
|
625
|
+
v.object({
|
|
626
|
+
kind: v.literal("backfill"),
|
|
627
|
+
nextFrom: v.union(v.string(), v.null()),
|
|
628
|
+
}),
|
|
629
|
+
v.object({
|
|
630
|
+
kind: v.literal("live"),
|
|
631
|
+
daily: v.boolean(),
|
|
632
|
+
weekly: v.boolean(),
|
|
633
|
+
}),
|
|
634
|
+
),
|
|
635
|
+
/** Meta had more rows than the page cap for some day of the range. */
|
|
636
|
+
truncated: v.boolean(),
|
|
637
|
+
}),
|
|
638
|
+
v.object({
|
|
639
|
+
kind: v.union(
|
|
640
|
+
v.literal("token"),
|
|
641
|
+
v.literal("throttled"),
|
|
642
|
+
v.literal("permission"),
|
|
643
|
+
v.literal("other"),
|
|
644
|
+
v.literal("key"),
|
|
645
|
+
),
|
|
646
|
+
message: v.string(),
|
|
647
|
+
regainMs: v.union(v.number(), v.null()),
|
|
648
|
+
}),
|
|
649
|
+
);
|
|
650
|
+
type Outcome = Infer<typeof outcomeValidator>;
|
|
651
|
+
|
|
652
|
+
/** Records how a run ended and when the account syncs next. */
|
|
653
|
+
export const finishSync = internalMutation({
|
|
654
|
+
args: {
|
|
655
|
+
...runArgs,
|
|
656
|
+
/** Where the run wrote, to purge it if the link is gone. */
|
|
657
|
+
siteId: v.id("sites"),
|
|
658
|
+
adAccountId: v.string(),
|
|
659
|
+
startedAt: v.number(),
|
|
660
|
+
/** The token the run used: a failure only condemns that token. */
|
|
661
|
+
tokenIv: v.string(),
|
|
662
|
+
outcome: outcomeValidator,
|
|
663
|
+
},
|
|
664
|
+
returns: v.null(),
|
|
665
|
+
handler: async (ctx, args) => {
|
|
666
|
+
const account = await ctx.db.get("siteAdAccounts", args.siteAdAccountId);
|
|
667
|
+
if (!account) {
|
|
668
|
+
// Unlinked while the run wrote: take back whatever it wrote after the
|
|
669
|
+
// unlink's own purge ran.
|
|
670
|
+
await ctx.scheduler.runAfter(0, internal.metaSync.purgeSpend, {
|
|
671
|
+
siteId: args.siteId,
|
|
672
|
+
adAccountId: args.adAccountId,
|
|
673
|
+
cursor: null,
|
|
674
|
+
});
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
// A newer run holds the account: this one's ending is not the account's.
|
|
678
|
+
if (account.runId !== args.runId) return null;
|
|
679
|
+
const now = Date.now();
|
|
680
|
+
const { outcome } = args;
|
|
681
|
+
const requested = account.syncRequested === true;
|
|
682
|
+
const base = { runId: undefined, syncRequested: undefined };
|
|
683
|
+
if (outcome.kind === "ok") {
|
|
684
|
+
const busy = outcome.usagePct > USAGE_BACKOFF_PCT;
|
|
685
|
+
const wait = busy
|
|
686
|
+
? Math.max(outcome.regainMs ?? 0, THROTTLE_BACKOFF_MS)
|
|
687
|
+
: 0;
|
|
688
|
+
const lastError = outcome.truncated
|
|
689
|
+
? "META_TRUNCATED: Meta listed more rows than one call reads for a day; that day may be short"
|
|
690
|
+
: busy
|
|
691
|
+
? "RATE_LIMITED: backing off"
|
|
692
|
+
: undefined;
|
|
693
|
+
let nextSyncAt: number;
|
|
694
|
+
if (outcome.plan.kind === "backfill" && outcome.plan.nextFrom !== null) {
|
|
695
|
+
// More blocks to read: the next one right away (unless Meta is busy).
|
|
696
|
+
nextSyncAt = now + Math.max(wait, BACKFILL_NEXT_BLOCK_MS);
|
|
697
|
+
await ctx.db.patch("siteAdAccounts", account._id, {
|
|
698
|
+
...base,
|
|
699
|
+
backfillFrom: outcome.plan.nextFrom,
|
|
700
|
+
failures: 0,
|
|
701
|
+
lastError,
|
|
702
|
+
nextSyncAt,
|
|
703
|
+
});
|
|
704
|
+
} else {
|
|
705
|
+
nextSyncAt = requested ? now : now + (wait || SYNC_INTERVAL_MS);
|
|
706
|
+
const backfilled = outcome.plan.kind === "backfill";
|
|
707
|
+
const daily =
|
|
708
|
+
backfilled || (outcome.plan.kind === "live" && outcome.plan.daily);
|
|
709
|
+
const weekly =
|
|
710
|
+
backfilled || (outcome.plan.kind === "live" && outcome.plan.weekly);
|
|
711
|
+
await ctx.db.patch("siteAdAccounts", account._id, {
|
|
712
|
+
...base,
|
|
713
|
+
syncState: "live",
|
|
714
|
+
backfillFrom: undefined,
|
|
715
|
+
failures: 0,
|
|
716
|
+
lastSyncAt: args.startedAt,
|
|
717
|
+
...(daily ? { lastDailyResyncAt: args.startedAt } : {}),
|
|
718
|
+
...(weekly ? { lastFullResyncAt: args.startedAt } : {}),
|
|
719
|
+
lastError,
|
|
720
|
+
nextSyncAt,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
await kickSyncLoop(ctx, nextSyncAt);
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
const message = outcome.message.slice(0, MAX_ERROR);
|
|
727
|
+
if (outcome.kind === "token") {
|
|
728
|
+
const connection = await ctx.db.get(
|
|
729
|
+
"metaConnections",
|
|
730
|
+
account.connectionId,
|
|
731
|
+
);
|
|
732
|
+
if (connection && connection.tokenIv === args.tokenIv) {
|
|
733
|
+
await pauseConnection(ctx, connection._id, message, now);
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
// The host pasted a new token while this run used the old one: the
|
|
737
|
+
// new one gets its chance at once.
|
|
738
|
+
await ctx.db.patch("siteAdAccounts", account._id, {
|
|
739
|
+
...base,
|
|
740
|
+
nextSyncAt: now,
|
|
741
|
+
});
|
|
742
|
+
await kickSyncLoop(ctx, now);
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
const failures = (account.failures ?? 0) + 1;
|
|
746
|
+
const delay =
|
|
747
|
+
outcome.kind === "throttled"
|
|
748
|
+
? Math.max(outcome.regainMs ?? 0, THROTTLE_BACKOFF_MS)
|
|
749
|
+
: outcome.kind === "permission"
|
|
750
|
+
? PERMISSION_RETRY_MS
|
|
751
|
+
: retryDelay(failures);
|
|
752
|
+
const code =
|
|
753
|
+
outcome.kind === "throttled"
|
|
754
|
+
? "RATE_LIMITED"
|
|
755
|
+
: outcome.kind === "key"
|
|
756
|
+
? "SECRETS_KEY_MISSING"
|
|
757
|
+
: "META_UNAVAILABLE";
|
|
758
|
+
const nextSyncAt = requested ? now : now + delay;
|
|
759
|
+
await ctx.db.patch("siteAdAccounts", account._id, {
|
|
760
|
+
...base,
|
|
761
|
+
// A backfill stays owed (lastSyncAt unset, or backfillFrom kept), so a
|
|
762
|
+
// retry resumes it; a permission error is shown as an error.
|
|
763
|
+
syncState: outcome.kind === "permission" ? "error" : account.syncState,
|
|
764
|
+
failures,
|
|
765
|
+
lastError: `${code}: ${message}`.slice(0, MAX_ERROR),
|
|
766
|
+
nextSyncAt,
|
|
767
|
+
});
|
|
768
|
+
await kickSyncLoop(ctx, nextSyncAt);
|
|
769
|
+
return null;
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
/** A dead token stops every account it serves until a new one is pasted. */
|
|
774
|
+
async function pauseConnection(
|
|
775
|
+
ctx: MutationCtx,
|
|
776
|
+
connectionId: Id<"metaConnections">,
|
|
777
|
+
message: string,
|
|
778
|
+
now: number,
|
|
779
|
+
) {
|
|
780
|
+
const connection = await ctx.db.get("metaConnections", connectionId);
|
|
781
|
+
await ctx.db.patch("metaConnections", connectionId, {
|
|
782
|
+
status: "invalid",
|
|
783
|
+
invalidSince: connection?.invalidSince ?? now,
|
|
784
|
+
lastError: message,
|
|
785
|
+
lastCheckedAt: now,
|
|
786
|
+
updatedAt: now,
|
|
787
|
+
});
|
|
788
|
+
// Bounded: a connection links at most MAX_LINKS_PER_CONNECTION accounts.
|
|
789
|
+
const links = await ctx.db
|
|
790
|
+
.query("siteAdAccounts")
|
|
791
|
+
.withIndex("by_connectionId", (range) =>
|
|
792
|
+
range.eq("connectionId", connectionId),
|
|
793
|
+
)
|
|
794
|
+
.take(MAX_LINKS_PER_CONNECTION);
|
|
795
|
+
for (const link of links) {
|
|
796
|
+
await ctx.db.patch("siteAdAccounts", link._id, {
|
|
797
|
+
syncState: "error",
|
|
798
|
+
runId: undefined,
|
|
799
|
+
syncRequested: undefined,
|
|
800
|
+
lastError: `META_TOKEN_INVALID: ${message}`.slice(0, MAX_ERROR),
|
|
801
|
+
nextSyncAt: PAUSED_AT,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function catalogFromSpend(rows: GraphSpendRow[]) {
|
|
807
|
+
const byAd = new Map<string, GraphSpendRow>();
|
|
808
|
+
for (const row of rows) byAd.set(row.adId, row);
|
|
809
|
+
return [...byAd.values()].map((row) => ({
|
|
810
|
+
adId: row.adId,
|
|
811
|
+
...(row.adName ? { adName: row.adName } : {}),
|
|
812
|
+
...(row.adsetId ? { adsetId: row.adsetId } : {}),
|
|
813
|
+
...(row.adsetName ? { adsetName: row.adsetName } : {}),
|
|
814
|
+
...(row.campaignId ? { campaignId: row.campaignId } : {}),
|
|
815
|
+
...(row.campaignName ? { campaignName: row.campaignName } : {}),
|
|
816
|
+
}));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function catalogFromAds(ads: GraphAd[]) {
|
|
820
|
+
return ads.map((ad) => ({
|
|
821
|
+
adId: ad.adId,
|
|
822
|
+
...(ad.name ? { adName: ad.name } : {}),
|
|
823
|
+
...(ad.adsetId ? { adsetId: ad.adsetId } : {}),
|
|
824
|
+
...(ad.adsetName ? { adsetName: ad.adsetName } : {}),
|
|
825
|
+
...(ad.campaignId ? { campaignId: ad.campaignId } : {}),
|
|
826
|
+
...(ad.campaignName ? { campaignName: ad.campaignName } : {}),
|
|
827
|
+
...(ad.effectiveStatus ? { adStatus: ad.effectiveStatus } : {}),
|
|
828
|
+
...(ad.creativeType ? { creativeType: ad.creativeType } : {}),
|
|
829
|
+
hasThumbnail: ad.thumbnailUrl !== null,
|
|
830
|
+
}));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function chunks<T>(items: T[], size: number): T[][] {
|
|
834
|
+
const out: T[][] = [];
|
|
835
|
+
for (let index = 0; index < items.length; index += size) {
|
|
836
|
+
out.push(items.slice(index, index + size));
|
|
837
|
+
}
|
|
838
|
+
return out;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const dayCount = (range: SyncRange) =>
|
|
842
|
+
(dateDayStart(range.until) - dateDayStart(range.since)) / DAY_MS + 1;
|
|
843
|
+
|
|
844
|
+
type RangePart = { range: SyncRange; rows: GraphSpendRow[]; complete: boolean };
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* A range's spend, split in halves while Meta has more rows than one call
|
|
848
|
+
* reads, down to single days. A day still over the cap comes back partial
|
|
849
|
+
* (`complete: false`): its rows are kept, but nothing is inferred from what
|
|
850
|
+
* it lacks.
|
|
851
|
+
*/
|
|
852
|
+
async function readRange(
|
|
853
|
+
graph: MetaGraph,
|
|
854
|
+
token: string,
|
|
855
|
+
adAccountId: string,
|
|
856
|
+
range: SyncRange,
|
|
857
|
+
track: (usage: GraphUsage) => void,
|
|
858
|
+
): Promise<RangePart[]> {
|
|
859
|
+
const result = await graph.insights(
|
|
860
|
+
token,
|
|
861
|
+
adAccountId,
|
|
862
|
+
range.since,
|
|
863
|
+
range.until,
|
|
864
|
+
);
|
|
865
|
+
track(result.usage);
|
|
866
|
+
if (result.complete || dayCount(range) === 1) {
|
|
867
|
+
return [{ range, rows: result.rows, complete: result.complete }];
|
|
868
|
+
}
|
|
869
|
+
const firstUntil = shiftDate(
|
|
870
|
+
range.since,
|
|
871
|
+
Math.floor(dayCount(range) / 2) - 1,
|
|
872
|
+
);
|
|
873
|
+
return [
|
|
874
|
+
...(await readRange(
|
|
875
|
+
graph,
|
|
876
|
+
token,
|
|
877
|
+
adAccountId,
|
|
878
|
+
{ since: range.since, until: firstUntil },
|
|
879
|
+
track,
|
|
880
|
+
)),
|
|
881
|
+
...(await readRange(
|
|
882
|
+
graph,
|
|
883
|
+
token,
|
|
884
|
+
adAccountId,
|
|
885
|
+
{ since: shiftDate(firstUntil, 1), until: range.until },
|
|
886
|
+
track,
|
|
887
|
+
)),
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Ends the run quietly: the link is gone or a newer run holds it. */
|
|
892
|
+
class LostLink extends Error {}
|
|
893
|
+
|
|
894
|
+
/** Reads one account from Meta and writes what it found, as run `runId`. */
|
|
895
|
+
export const syncAccount = internalAction({
|
|
896
|
+
args: runArgs,
|
|
897
|
+
returns: v.null(),
|
|
898
|
+
handler: async (ctx, args) => {
|
|
899
|
+
const startedAt = Date.now();
|
|
900
|
+
const job = await ctx.runQuery(internal.metaSync.loadJob, args);
|
|
901
|
+
if (!job) return null;
|
|
902
|
+
const finish = (outcome: Outcome) =>
|
|
903
|
+
ctx.runMutation(internal.metaSync.finishSync, {
|
|
904
|
+
...args,
|
|
905
|
+
siteId: job.siteId,
|
|
906
|
+
adAccountId: job.adAccountId,
|
|
907
|
+
startedAt,
|
|
908
|
+
tokenIv: job.tokenIv,
|
|
909
|
+
outcome,
|
|
910
|
+
});
|
|
911
|
+
let usage: GraphUsage = { pct: 0, regainMs: null };
|
|
912
|
+
const track = (next: GraphUsage) => {
|
|
913
|
+
usage = {
|
|
914
|
+
pct: Math.max(usage.pct, next.pct),
|
|
915
|
+
regainMs: Math.max(usage.regainMs ?? 0, next.regainMs ?? 0) || null,
|
|
916
|
+
};
|
|
917
|
+
};
|
|
918
|
+
try {
|
|
919
|
+
if (!job.connectionActive) {
|
|
920
|
+
await finish({
|
|
921
|
+
kind: "token",
|
|
922
|
+
message: "the connection's token was marked invalid",
|
|
923
|
+
regainMs: null,
|
|
924
|
+
});
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
let token: string;
|
|
928
|
+
try {
|
|
929
|
+
token = await decryptSecret(env.RASTRO_SECRETS_KEY, {
|
|
930
|
+
cipher: job.tokenCipher,
|
|
931
|
+
iv: job.tokenIv,
|
|
932
|
+
});
|
|
933
|
+
} catch {
|
|
934
|
+
await finish({
|
|
935
|
+
kind: "key",
|
|
936
|
+
message:
|
|
937
|
+
"RASTRO_SECRETS_KEY is missing or not the one the token was sealed with",
|
|
938
|
+
regainMs: null,
|
|
939
|
+
});
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
const graph = metaGraph({ fake: env.RASTRO_META_FAKE === "true" });
|
|
943
|
+
const plan = planSync(job, startedAt);
|
|
944
|
+
|
|
945
|
+
const parts = await readRange(
|
|
946
|
+
graph,
|
|
947
|
+
token,
|
|
948
|
+
job.adAccountId,
|
|
949
|
+
plan.range,
|
|
950
|
+
track,
|
|
951
|
+
);
|
|
952
|
+
const catalog = new Map<
|
|
953
|
+
string,
|
|
954
|
+
ReturnType<typeof catalogFromSpend>[number]
|
|
955
|
+
>();
|
|
956
|
+
for (const part of parts) {
|
|
957
|
+
const rows = part.rows.map((row) => ({
|
|
958
|
+
adId: row.adId,
|
|
959
|
+
dayStart: dateDayStart(row.date),
|
|
960
|
+
spendCents: row.spendCents,
|
|
961
|
+
impressions: row.impressions,
|
|
962
|
+
clicks: row.clicks,
|
|
963
|
+
conversations: row.conversations,
|
|
964
|
+
}));
|
|
965
|
+
for (const chunk of chunks(rows, SPEND_CHUNK)) {
|
|
966
|
+
const saved = await ctx.runMutation(internal.metaSync.saveSpend, {
|
|
967
|
+
...args,
|
|
968
|
+
syncedAt: startedAt,
|
|
969
|
+
rows: chunk,
|
|
970
|
+
});
|
|
971
|
+
if (saved.stop) throw new LostLink();
|
|
972
|
+
}
|
|
973
|
+
// A partial day proves nothing about the rows it lacks.
|
|
974
|
+
if (part.complete) {
|
|
975
|
+
let cursor: string | null = null;
|
|
976
|
+
for (;;) {
|
|
977
|
+
const page: {
|
|
978
|
+
stop: boolean;
|
|
979
|
+
done: boolean;
|
|
980
|
+
cursor: string | null;
|
|
981
|
+
} = await ctx.runMutation(internal.metaSync.dropStaleSpend, {
|
|
982
|
+
...args,
|
|
983
|
+
firstDay: dateDayStart(part.range.since),
|
|
984
|
+
lastDay: dateDayStart(part.range.until),
|
|
985
|
+
syncedAt: startedAt,
|
|
986
|
+
cursor,
|
|
987
|
+
});
|
|
988
|
+
if (page.stop) throw new LostLink();
|
|
989
|
+
if (page.done) break;
|
|
990
|
+
cursor = page.cursor;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
for (const entry of catalogFromSpend(part.rows)) {
|
|
994
|
+
catalog.set(entry.adId, { ...catalog.get(entry.adId), ...entry });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
let ads: GraphAd[] = [];
|
|
999
|
+
if (plan.fullCatalog) {
|
|
1000
|
+
const listed = await graph.ads(token, job.adAccountId);
|
|
1001
|
+
track(listed.usage);
|
|
1002
|
+
ads = listed.ads;
|
|
1003
|
+
} else {
|
|
1004
|
+
const missing = (
|
|
1005
|
+
await ctx.runQuery(internal.metaSync.missingAdNames, {
|
|
1006
|
+
siteId: job.siteId,
|
|
1007
|
+
adAccountId: job.adAccountId,
|
|
1008
|
+
since:
|
|
1009
|
+
dateDayStart(localDate(startedAt, job.timezone)) -
|
|
1010
|
+
MISSING_NAMES_LOOKBACK_DAYS * DAY_MS,
|
|
1011
|
+
})
|
|
1012
|
+
).filter((adId) => !catalog.get(adId)?.adName);
|
|
1013
|
+
if (missing.length > 0) {
|
|
1014
|
+
const listed = await graph.ads(token, job.adAccountId, missing);
|
|
1015
|
+
track(listed.usage);
|
|
1016
|
+
ads = listed.ads;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const listedIds = new Set(ads.map((ad) => ad.adId));
|
|
1020
|
+
const entries = [
|
|
1021
|
+
...[...catalog.values()].filter((entry) => !listedIds.has(entry.adId)),
|
|
1022
|
+
...catalogFromAds(ads),
|
|
1023
|
+
];
|
|
1024
|
+
const needThumbnail: string[] = [];
|
|
1025
|
+
for (const chunk of chunks(entries, CATALOG_CHUNK)) {
|
|
1026
|
+
const upserted = await ctx.runMutation(
|
|
1027
|
+
internal.metaSync.upsertCatalog,
|
|
1028
|
+
{ ...args, ads: chunk },
|
|
1029
|
+
);
|
|
1030
|
+
if (upserted.stop) throw new LostLink();
|
|
1031
|
+
needThumbnail.push(...upserted.needThumbnail);
|
|
1032
|
+
}
|
|
1033
|
+
const thumbnails = new Map(
|
|
1034
|
+
ads.flatMap((ad) =>
|
|
1035
|
+
ad.thumbnailUrl ? [[ad.adId, ad.thumbnailUrl] as const] : [],
|
|
1036
|
+
),
|
|
1037
|
+
);
|
|
1038
|
+
const queue = needThumbnail.slice(0, MAX_THUMBNAILS_PER_RUN);
|
|
1039
|
+
for (const batch of chunks(queue, THUMBNAIL_CONCURRENCY)) {
|
|
1040
|
+
await Promise.all(
|
|
1041
|
+
batch.map(async (adId) => {
|
|
1042
|
+
const url = thumbnails.get(adId);
|
|
1043
|
+
const blob = url ? await graph.thumbnail(url) : null;
|
|
1044
|
+
if (!blob) return;
|
|
1045
|
+
const storageId: Id<"_storage"> = await ctx.storage.store(blob);
|
|
1046
|
+
await ctx.runMutation(internal.metaSync.setThumbnail, {
|
|
1047
|
+
siteId: job.siteId,
|
|
1048
|
+
adId,
|
|
1049
|
+
storageId,
|
|
1050
|
+
});
|
|
1051
|
+
}),
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
await finish({
|
|
1055
|
+
kind: "ok",
|
|
1056
|
+
usagePct: usage.pct,
|
|
1057
|
+
regainMs: usage.regainMs,
|
|
1058
|
+
plan:
|
|
1059
|
+
plan.kind === "backfill"
|
|
1060
|
+
? { kind: "backfill", nextFrom: plan.nextFrom }
|
|
1061
|
+
: { kind: "live", daily: plan.daily, weekly: plan.weekly },
|
|
1062
|
+
truncated: parts.some((part) => !part.complete),
|
|
1063
|
+
});
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
if (error instanceof LostLink) {
|
|
1066
|
+
// finishSync sees the link gone (and purges) or a newer run holding
|
|
1067
|
+
// it (and leaves the account to that run).
|
|
1068
|
+
await finish({ kind: "other", message: "link lost", regainMs: null });
|
|
1069
|
+
} else if (error instanceof MetaGraphError) {
|
|
1070
|
+
await finish({
|
|
1071
|
+
// `app` comes from the login exchanges only, never a sync call.
|
|
1072
|
+
kind: error.kind === "app" ? "other" : error.kind,
|
|
1073
|
+
message: error.message,
|
|
1074
|
+
regainMs: error.regainMs ?? usage.regainMs,
|
|
1075
|
+
});
|
|
1076
|
+
} else {
|
|
1077
|
+
await finish({
|
|
1078
|
+
kind: "other",
|
|
1079
|
+
message: error instanceof Error ? error.message : "sync failed",
|
|
1080
|
+
regainMs: null,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return null;
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Deletes a site's spend from one ad account after it is unlinked, a batch
|
|
1090
|
+
* per run, then its site-day aggregate. Stops if the account is linked to the
|
|
1091
|
+
* site again meanwhile (its backfill rewrites the rows).
|
|
1092
|
+
*/
|
|
1093
|
+
export const purgeSpend = internalMutation({
|
|
1094
|
+
args: {
|
|
1095
|
+
siteId: v.id("sites"),
|
|
1096
|
+
adAccountId: v.string(),
|
|
1097
|
+
cursor: v.union(v.string(), v.null()),
|
|
1098
|
+
},
|
|
1099
|
+
returns: v.null(),
|
|
1100
|
+
handler: async (ctx, args) => {
|
|
1101
|
+
const relinked = await ctx.db
|
|
1102
|
+
.query("siteAdAccounts")
|
|
1103
|
+
.withIndex("by_siteId_and_adAccountId", (range) =>
|
|
1104
|
+
range.eq("siteId", args.siteId).eq("adAccountId", args.adAccountId),
|
|
1105
|
+
)
|
|
1106
|
+
.unique();
|
|
1107
|
+
if (relinked) return null;
|
|
1108
|
+
const rows = await ctx.db
|
|
1109
|
+
.query("adSpendDaily")
|
|
1110
|
+
.withIndex("by_siteId_and_adAccountId_and_dayStart", (range) =>
|
|
1111
|
+
range.eq("siteId", args.siteId).eq("adAccountId", args.adAccountId),
|
|
1112
|
+
)
|
|
1113
|
+
.take(PAGE);
|
|
1114
|
+
for (const row of rows) await ctx.db.delete("adSpendDaily", row._id);
|
|
1115
|
+
const sums =
|
|
1116
|
+
rows.length < PAGE
|
|
1117
|
+
? await ctx.db
|
|
1118
|
+
.query("siteSpendDaily")
|
|
1119
|
+
.withIndex("by_siteId_and_adAccountId_and_dayStart", (range) =>
|
|
1120
|
+
range
|
|
1121
|
+
.eq("siteId", args.siteId)
|
|
1122
|
+
.eq("adAccountId", args.adAccountId),
|
|
1123
|
+
)
|
|
1124
|
+
.take(PAGE)
|
|
1125
|
+
: [];
|
|
1126
|
+
for (const row of sums) await ctx.db.delete("siteSpendDaily", row._id);
|
|
1127
|
+
if (rows.length === PAGE || sums.length === PAGE) {
|
|
1128
|
+
await ctx.scheduler.runAfter(0, internal.metaSync.purgeSpend, {
|
|
1129
|
+
...args,
|
|
1130
|
+
cursor: null,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* How far the first backfill has read, from the cursor the chain keeps:
|
|
1139
|
+
* whole 30-day blocks done of the 90 days' total. Null once the account has
|
|
1140
|
+
* synced (a later gap fill runs under `live`). Reads no clock: the range is
|
|
1141
|
+
* counted from the account-local date it was linked on.
|
|
1142
|
+
*/
|
|
1143
|
+
export function backfillProgress(
|
|
1144
|
+
link: Pick<
|
|
1145
|
+
Doc<"siteAdAccounts">,
|
|
1146
|
+
"lastSyncAt" | "backfillFrom" | "createdAt" | "timezone"
|
|
1147
|
+
>,
|
|
1148
|
+
): { done: number; total: number } | null {
|
|
1149
|
+
if (link.lastSyncAt !== undefined) return null;
|
|
1150
|
+
const total = Math.ceil(BACKFILL_DAYS / BACKFILL_BLOCK_DAYS);
|
|
1151
|
+
if (link.backfillFrom === undefined) return { done: 0, total };
|
|
1152
|
+
const start = shiftDate(
|
|
1153
|
+
localDate(link.createdAt, link.timezone),
|
|
1154
|
+
-(BACKFILL_DAYS - 1),
|
|
1155
|
+
);
|
|
1156
|
+
const days = Math.round(
|
|
1157
|
+
(dateDayStart(link.backfillFrom) - dateDayStart(start)) / DAY_MS,
|
|
1158
|
+
);
|
|
1159
|
+
const done = Math.min(
|
|
1160
|
+
Math.max(Math.floor(days / BACKFILL_BLOCK_DAYS), 0),
|
|
1161
|
+
total - 1,
|
|
1162
|
+
);
|
|
1163
|
+
return { done, total };
|
|
1164
|
+
}
|