@agent-native/core 0.141.5 → 0.141.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +3 -14
  3. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +20 -8
  4. package/corpus/templates/analytics/.agents/skills/prometheus/SKILL.md +5 -5
  5. package/corpus/templates/analytics/AGENTS.md +2 -2
  6. package/corpus/templates/analytics/actions/compose-dashboard.ts +1 -1
  7. package/corpus/templates/analytics/actions/get-first-party-analytics-health.ts +15 -1
  8. package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +135 -0
  9. package/corpus/templates/analytics/actions/navigate.ts +1 -1
  10. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -2
  11. package/corpus/templates/analytics/actions/update-dashboard.ts +0 -1
  12. package/corpus/templates/analytics/actions/view-screen.ts +0 -20
  13. package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
  14. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +5 -5
  15. package/corpus/templates/analytics/app/i18n-data.ts +330 -90
  16. package/corpus/templates/analytics/app/pages/Agents.tsx +0 -3
  17. package/corpus/templates/analytics/app/pages/DataSources.tsx +66 -30
  18. package/corpus/templates/analytics/app/pages/Settings.tsx +22 -43
  19. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +23 -20
  20. package/corpus/templates/analytics/app/routes/extensions._index.tsx +1 -1
  21. package/corpus/templates/analytics/app/routes/settings.$.tsx +1 -0
  22. package/corpus/templates/analytics/app/routes/team.tsx +1 -1
  23. package/corpus/templates/analytics/changelog/2026-08-05-historical-analytics-rollups-now-repair-automatically-after-.md +6 -0
  24. package/corpus/templates/analytics/changelog/2026-08-05-settings-no-longer-includes-an-unnecessary-about-section.md +6 -0
  25. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +1 -1
  26. package/corpus/templates/analytics/netlify.toml +4 -0
  27. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
  28. package/corpus/templates/analytics/scripts/first-party-analytics-bigquery.sql +104 -0
  29. package/corpus/templates/analytics/server/jobs/analytics-rollup-backfill.ts +342 -0
  30. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +0 -2
  31. package/corpus/templates/analytics/server/lib/analytics-rollup-lock.ts +8 -0
  32. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +11 -29
  33. package/corpus/templates/analytics/server/lib/dashboards-store.ts +55 -0
  34. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +1058 -0
  35. package/corpus/templates/analytics/server/lib/first-party-analytics-health.ts +151 -46
  36. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +15 -9
  37. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +59 -17
  38. package/corpus/templates/analytics/server/lib/real-data-actions.ts +1 -3
  39. package/corpus/templates/analytics/server/plugins/agent-chat.ts +2 -4
  40. package/corpus/templates/analytics/server/plugins/analytics-rollup-backfill-jobs.ts +61 -0
  41. package/corpus/templates/analytics/server/plugins/db.ts +58 -187
  42. package/corpus/templates/analytics/server/routes/api/analytics-rollups/backfill.post.ts +53 -0
  43. package/corpus/templates/assets/.agents/skills/asset-generation/SKILL.md +8 -6
  44. package/corpus/templates/assets/.agents/skills/library-management/SKILL.md +16 -1
  45. package/corpus/templates/assets/AGENTS.md +1 -0
  46. package/corpus/templates/assets/actions/_helpers.ts +12 -0
  47. package/corpus/templates/assets/actions/generate-image.ts +9 -3
  48. package/corpus/templates/assets/actions/import-style-from-url.ts +115 -0
  49. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +3 -1
  50. package/corpus/templates/assets/app/routes/extensions._index.tsx +1 -1
  51. package/corpus/templates/assets/app/routes/settings.$.tsx +1 -0
  52. package/corpus/templates/assets/app/routes/settings.tsx +5 -0
  53. package/corpus/templates/assets/app/routes/team.tsx +1 -1
  54. package/corpus/templates/assets/changelog/2026-08-05-brand-kits-can-import-a-website-s-hydrated-visual-language-a.md +6 -0
  55. package/corpus/templates/assets/changelog/2026-08-05-generated-images-now-hand-off-embeddable-previews-to-other-w.md +6 -0
  56. package/corpus/templates/assets/server/lib/generation.ts +58 -0
  57. package/corpus/templates/assets/shared/api.ts +12 -0
  58. package/corpus/templates/brain/app/routes/extensions._index.tsx +1 -1
  59. package/corpus/templates/brain/app/routes/team.tsx +1 -1
  60. package/corpus/templates/brain/app/routes.ts +1 -1
  61. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +3 -1
  62. package/corpus/templates/calendar/app/routes/_app.extensions._index.tsx +1 -1
  63. package/corpus/templates/calendar/app/routes/_app.settings.$.tsx +1 -0
  64. package/corpus/templates/calendar/app/routes/_app.team.tsx +1 -1
  65. package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -1
  66. package/corpus/templates/chat/app/hooks/use-navigation-state.ts +1 -1
  67. package/corpus/templates/chat/app/root.tsx +1 -1
  68. package/corpus/templates/chat/app/routes/agent.tsx +7 -7
  69. package/corpus/templates/chat/app/routes/extensions._index.tsx +1 -1
  70. package/corpus/templates/chat/app/routes/settings.$.tsx +1 -0
  71. package/corpus/templates/chat/app/routes/team.tsx +1 -1
  72. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  73. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +1 -1
  74. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +1 -1
  75. package/corpus/templates/clips/app/routes/_app.extensions._index.tsx +1 -1
  76. package/corpus/templates/clips/app/routes/_app.settings.$.tsx +1 -0
  77. package/corpus/templates/clips/changelog/2026-08-05-clips-overlays-now-use-clean-surfaces-without-clipped-shadow.md +6 -0
  78. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -2
  79. package/corpus/templates/clips/desktop/src/styles.css +3 -54
  80. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +3 -3
  81. package/corpus/templates/content/app/routes/_app.extensions._index.tsx +1 -1
  82. package/corpus/templates/content/app/routes/_app.settings.$.tsx +1 -0
  83. package/corpus/templates/content/app/routes/_app.team.tsx +1 -1
  84. package/corpus/templates/content/e2e/global-setup.ts +1 -1
  85. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +6 -1
  86. package/corpus/templates/design/actions/analyze-brand-assets.ts +7 -7
  87. package/corpus/templates/design/actions/get-design-system.ts +3 -1
  88. package/corpus/templates/design/actions/import-from-url.ts +6 -5
  89. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +2 -2
  90. package/corpus/templates/design/app/routes/extensions._index.tsx +1 -1
  91. package/corpus/templates/design/app/routes/settings.$.tsx +1 -0
  92. package/corpus/templates/design/app/routes/team.tsx +1 -1
  93. package/corpus/templates/design/changelog/2026-08-05-missing-design-systems-now-return-a-clear-not-found-response.md +6 -0
  94. package/corpus/templates/design/changelog/2026-08-05-website-url-imports-now-capture-hydrated-browser-computed-de.md +6 -0
  95. package/corpus/templates/design/e2e/global-setup.ts +1 -1
  96. package/corpus/templates/dispatch/app/hooks/use-navigation-state.ts +1 -1
  97. package/corpus/templates/dispatch/app/routes/settings.$.tsx +1 -0
  98. package/corpus/templates/dispatch/app/routes/team.tsx +1 -1
  99. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +1 -1
  100. package/corpus/templates/factory/app/routes/extensions._index.tsx +1 -1
  101. package/corpus/templates/factory/app/routes/settings.$.tsx +1 -0
  102. package/corpus/templates/factory/app/routes/team.tsx +1 -1
  103. package/corpus/templates/forms/app/routes/_app.extensions._index.tsx +1 -1
  104. package/corpus/templates/forms/app/routes/_app.settings.$.tsx +1 -0
  105. package/corpus/templates/forms/app/routes/_app.team.tsx +1 -1
  106. package/corpus/templates/forms/shared/navigation.ts +1 -1
  107. package/corpus/templates/macros/app/routes/extensions._index.tsx +1 -1
  108. package/corpus/templates/macros/app/routes/settings.$.tsx +1 -0
  109. package/corpus/templates/macros/app/routes/team.tsx +1 -1
  110. package/corpus/templates/mail/app/routes/extensions._index.tsx +1 -1
  111. package/corpus/templates/mail/app/routes/settings.$.tsx +1 -0
  112. package/corpus/templates/plan/app/hooks/use-navigation-state.ts +1 -1
  113. package/corpus/templates/plan/app/routes/extensions._index.tsx +1 -1
  114. package/corpus/templates/plan/app/routes/settings.$.tsx +1 -0
  115. package/corpus/templates/plan/app/routes/team.tsx +1 -1
  116. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +3 -1
  117. package/corpus/templates/slides/actions/analyze-brand-assets.ts +7 -7
  118. package/corpus/templates/slides/actions/import-from-url.ts +6 -6
  119. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +2 -2
  120. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +1 -1
  121. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +3 -0
  122. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -1
  123. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +9 -3
  124. package/corpus/templates/slides/app/context/DeckContext.tsx +71 -20
  125. package/corpus/templates/slides/app/global.css +6 -0
  126. package/corpus/templates/slides/app/i18n/en-US.ts +5 -5
  127. package/corpus/templates/slides/app/lib/create-deck-generation.ts +9 -5
  128. package/corpus/templates/slides/app/pages/DeckEditor.tsx +17 -0
  129. package/corpus/templates/slides/app/pages/Index.tsx +16 -5
  130. package/corpus/templates/slides/app/routes/extensions._index.tsx +1 -1
  131. package/corpus/templates/slides/app/routes/settings.$.tsx +1 -0
  132. package/corpus/templates/slides/app/routes/team.tsx +1 -1
  133. package/corpus/templates/slides/changelog/2026-08-05-failed-deck-creation-and-reference-uploads-now-recover-clean.md +6 -0
  134. package/corpus/templates/slides/changelog/2026-08-05-new-presentation-prompts-now-clearly-describe-presentation-g.md +6 -0
  135. package/corpus/templates/slides/changelog/2026-08-05-slides-recover-completed-generation-results-after-a-sync-hic.md +6 -0
  136. package/corpus/templates/slides/changelog/2026-08-05-website-url-imports-now-capture-hydrated-browser-computed-st.md +6 -0
  137. package/corpus/templates/tasks/app/components/layout/Header.tsx +3 -1
  138. package/corpus/templates/tasks/app/routes/extensions._index.tsx +1 -1
  139. package/corpus/templates/tasks/app/routes/settings.$.tsx +1 -0
  140. package/dist/agent/engine/ai-sdk-engine.js +49 -3
  141. package/dist/agent/production-agent.d.ts +1 -0
  142. package/dist/agent/production-agent.js +8 -0
  143. package/dist/agent/types.d.ts +7 -0
  144. package/dist/client/AssistantChat.js +1 -1
  145. package/dist/client/agent-chat-adapter.d.ts +1 -1
  146. package/dist/client/agent-chat-adapter.js +30 -3
  147. package/dist/client/agent-page/AgentWorkspaceContent.d.ts +1 -1
  148. package/dist/client/agent-page/AgentWorkspaceContent.js +13 -13
  149. package/dist/client/chat/run-recovery.d.ts +2 -1
  150. package/dist/client/chat/run-recovery.js +14 -3
  151. package/dist/client/embed-auth.js +6 -2
  152. package/dist/client/error-format.d.ts +1 -0
  153. package/dist/client/error-format.js +16 -2
  154. package/dist/client/integrations/IntegrationsPanel.js +40 -6
  155. package/dist/client/org/OrgSwitcher.js +2 -2
  156. package/dist/client/org/TeamPage.js +92 -72
  157. package/dist/client/require-session.d.ts +1 -1
  158. package/dist/client/require-session.js +3 -3
  159. package/dist/client/resources/use-mcp-servers.d.ts +5 -0
  160. package/dist/client/resources/use-mcp-servers.js +36 -0
  161. package/dist/client/settings/AccountSettingsCard.js +40 -35
  162. package/dist/client/settings/AutomationsSection.js +1 -1
  163. package/dist/client/settings/DemoModeSection.d.ts +3 -1
  164. package/dist/client/settings/DemoModeSection.js +4 -1
  165. package/dist/client/settings/ExtensionsSettingsContent.js +1 -1
  166. package/dist/client/settings/SchedulingTimezoneField.d.ts +3 -1
  167. package/dist/client/settings/SchedulingTimezoneField.js +9 -5
  168. package/dist/client/settings/SettingsPanel.d.ts +4 -1
  169. package/dist/client/settings/SettingsPanel.js +21 -21
  170. package/dist/client/settings/SettingsTabsPage.d.ts +2 -2
  171. package/dist/client/settings/SettingsTabsPage.js +98 -45
  172. package/dist/client/settings/VoiceTranscriptionSection.js +6 -1
  173. package/dist/client/settings/useSettingsPanelController.js +47 -8
  174. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  175. package/dist/ingestion/website.d.ts +30 -0
  176. package/dist/localization/default-messages.d.ts +5 -0
  177. package/dist/localization/default-messages.js +5 -0
  178. package/dist/mcp-client/oauth-routes.js +1 -1
  179. package/dist/mcp-client/routes.d.ts +1 -0
  180. package/dist/mcp-client/routes.js +49 -0
  181. package/dist/navigation/index.js +16 -5
  182. package/dist/observability/routes.d.ts +3 -3
  183. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  184. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  185. package/dist/scripts/call-agent.js +11 -0
  186. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  187. package/dist/server/auth.js +9 -7
  188. package/dist/server/identity-sso.js +2 -1
  189. package/dist/server/onboarding-html.js +57 -34
  190. package/dist/server/realtime-token.d.ts +1 -1
  191. package/dist/server/transcribe-voice.d.ts +1 -1
  192. package/dist/server/workspace-provider-oauth.js +1 -1
  193. package/dist/setup-connections/onboarding.js +1 -1
  194. package/dist/shared/index.d.ts +1 -1
  195. package/dist/shared/index.js +1 -1
  196. package/dist/shared/sign-in-journey.d.ts +5 -2
  197. package/dist/shared/sign-in-journey.js +10 -4
  198. package/dist/templates/chat/app/components/layout/Layout.tsx +1 -1
  199. package/dist/templates/chat/app/hooks/use-navigation-state.ts +1 -1
  200. package/dist/templates/chat/app/root.tsx +1 -1
  201. package/dist/templates/chat/app/routes/agent.tsx +7 -7
  202. package/dist/templates/chat/app/routes/extensions._index.tsx +1 -1
  203. package/dist/templates/chat/app/routes/settings.$.tsx +1 -0
  204. package/dist/templates/chat/app/routes/team.tsx +1 -1
  205. package/dist/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +37 -6
  206. package/dist/templates/workspace-core/.agents/skills/authentication/SKILL.md +4 -2
  207. package/docs/content/authentication.mdx +4 -5
  208. package/docs/content/template-analytics-developers.mdx +1 -1
  209. package/docs/content/template-calendar-developers.mdx +1 -1
  210. package/docs/content/template-chat-developers.mdx +2 -2
  211. package/docs/content/template-content-developers.mdx +1 -1
  212. package/docs/content/template-mail-developers.mdx +1 -1
  213. package/docs/content/template-plan-developers.mdx +1 -1
  214. package/docs/content/toolkit-command-navigation.mdx +6 -6
  215. package/docs/content/toolkit-org-team.mdx +1 -1
  216. package/docs/content/toolkit-settings.mdx +8 -8
  217. package/docs/content/toolkit-setup-connections.mdx +1 -1
  218. package/package.json +1 -1
  219. package/src/templates/chat/app/components/layout/Layout.tsx +1 -1
  220. package/src/templates/chat/app/hooks/use-navigation-state.ts +1 -1
  221. package/src/templates/chat/app/root.tsx +1 -1
  222. package/src/templates/chat/app/routes/agent.tsx +7 -7
  223. package/src/templates/chat/app/routes/extensions._index.tsx +1 -1
  224. package/src/templates/chat/app/routes/settings.$.tsx +1 -0
  225. package/src/templates/chat/app/routes/team.tsx +1 -1
  226. package/src/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +37 -6
  227. package/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +4 -2
  228. package/corpus/templates/analytics/.agents/skills/dashboard-management/references/template-catalog-and-demo.md +0 -62
  229. package/corpus/templates/analytics/actions/install-dashboard-template.ts +0 -369
  230. package/corpus/templates/analytics/actions/list-dashboard-templates.ts +0 -47
  231. package/corpus/templates/analytics/actions/list-native-v2-dashboard-templates.ts +0 -34
  232. package/corpus/templates/analytics/app/routes/catalog.tsx +0 -240
  233. package/corpus/templates/analytics/changelog/2026-08-05-historical-analytics-rollups-stay-complete-and-atomic.md +0 -6
  234. package/corpus/templates/analytics/changelog/2026-08-05-historical-rollups-stay-complete-while-live-events-arrive.md +0 -6
  235. package/src/templates/default/actions/navigate.spec.ts +0 -53
@@ -0,0 +1,1058 @@
1
+ import { getDbExec } from "@agent-native/core/db";
2
+
3
+ import {
4
+ getBigQueryProjectId,
5
+ runQuery,
6
+ type BigQueryTableRef,
7
+ } from "./bigquery.js";
8
+ import { requireRequestCredentialContext } from "./credentials-context.js";
9
+ import { getAccessToken } from "./gcloud.js";
10
+ import {
11
+ getScopedSettingRecord,
12
+ putScopedSettingRecord,
13
+ } from "./scoped-settings.js";
14
+
15
+ export const FIRST_PARTY_ANALYTICS_BACKEND_SETTING =
16
+ "first-party-analytics-backend";
17
+
18
+ export type FirstPartyAnalyticsSink = "postgres" | "dual" | "bigquery";
19
+
20
+ export interface FirstPartyAnalyticsBackendConfig {
21
+ sink: FirstPartyAnalyticsSink;
22
+ table: string | null;
23
+ backfillCursor?: string | null;
24
+ backfillCompleted?: boolean;
25
+ }
26
+
27
+ interface FirstPartyAnalyticsBackfillCursor {
28
+ receivedAt: string;
29
+ id: string;
30
+ }
31
+
32
+ export interface FirstPartyAnalyticsScope {
33
+ userEmail: string;
34
+ orgId: string | null;
35
+ }
36
+
37
+ interface FirstPartyAnalyticsBackendSetting {
38
+ sink?: unknown;
39
+ table?: unknown;
40
+ backfillCursor?: unknown;
41
+ backfillCompleted?: unknown;
42
+ }
43
+
44
+ interface FirstPartyAnalyticsEventRow {
45
+ id: string;
46
+ publicKeyId: string;
47
+ eventName: string;
48
+ userId: string | null;
49
+ anonymousId: string | null;
50
+ userKey: string | null;
51
+ sessionId: string | null;
52
+ timestamp: string;
53
+ eventDate: string | null;
54
+ receivedAt: string;
55
+ url: string | null;
56
+ path: string | null;
57
+ hostname: string | null;
58
+ referrer: string | null;
59
+ app: string | null;
60
+ template: string | null;
61
+ signedIn: string | null;
62
+ properties: string;
63
+ context: string;
64
+ ownerEmail: string;
65
+ orgId: string | null;
66
+ }
67
+
68
+ const TABLE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
69
+ const PROJECT_ID_PATTERN = /^[A-Za-z][A-Za-z0-9-]{4,61}[A-Za-z0-9]$/;
70
+ const FIRST_PARTY_QUERY_TABLES = [
71
+ "analytics_events",
72
+ "analytics_event_daily_rollups",
73
+ "analytics_user_days",
74
+ ] as const;
75
+ const BACKEND_CONFIG_CACHE_TTL_MS = 30_000;
76
+
77
+ const backendConfigCache = new Map<
78
+ string,
79
+ { config: FirstPartyAnalyticsBackendConfig; expiresAt: number }
80
+ >();
81
+
82
+ function backendScopeKey(scope: FirstPartyAnalyticsScope): string {
83
+ return `${scope.orgId ? `o:${scope.orgId}` : "u:"}${scope.userEmail}`;
84
+ }
85
+
86
+ function parseTableRef(
87
+ raw: string | null | undefined,
88
+ fallbackProjectId: string,
89
+ ): BigQueryTableRef {
90
+ const value = raw?.trim().replace(/^`|`$/g, "");
91
+ const parts = value ? value.split(".") : [];
92
+ const [projectId, datasetId, tableId] =
93
+ parts.length === 3
94
+ ? parts
95
+ : parts.length === 2
96
+ ? [fallbackProjectId, parts[0], parts[1]]
97
+ : [fallbackProjectId, "analytics", "first_party_analytics_events_raw"];
98
+
99
+ if (
100
+ !PROJECT_ID_PATTERN.test(projectId) ||
101
+ !TABLE_ID_PATTERN.test(datasetId) ||
102
+ !TABLE_ID_PATTERN.test(tableId)
103
+ ) {
104
+ throw new Error(
105
+ "The first-party BigQuery table must be dataset.table or project.dataset.table",
106
+ );
107
+ }
108
+
109
+ return {
110
+ projectId,
111
+ datasetId,
112
+ tableId,
113
+ fullyQualified: `${projectId}.${datasetId}.${tableId}`,
114
+ };
115
+ }
116
+
117
+ function normalizeSink(value: unknown): FirstPartyAnalyticsSink {
118
+ return value === "dual" || value === "bigquery" ? value : "postgres";
119
+ }
120
+
121
+ export async function getFirstPartyAnalyticsBackend(
122
+ scope: FirstPartyAnalyticsScope,
123
+ ): Promise<FirstPartyAnalyticsBackendConfig> {
124
+ const cacheKey = backendScopeKey(scope);
125
+ const cached = backendConfigCache.get(cacheKey);
126
+ if (cached && cached.expiresAt > Date.now()) return cached.config;
127
+
128
+ const setting = (await getScopedSettingRecord(
129
+ { email: scope.userEmail, orgId: scope.orgId },
130
+ FIRST_PARTY_ANALYTICS_BACKEND_SETTING,
131
+ )) as FirstPartyAnalyticsBackendSetting | null;
132
+ const config = {
133
+ sink: normalizeSink(setting?.sink),
134
+ table: typeof setting?.table === "string" ? setting.table : null,
135
+ backfillCursor:
136
+ typeof setting?.backfillCursor === "string"
137
+ ? setting.backfillCursor
138
+ : null,
139
+ backfillCompleted: setting?.backfillCompleted === true,
140
+ };
141
+ backendConfigCache.set(cacheKey, {
142
+ config,
143
+ expiresAt: Date.now() + BACKEND_CONFIG_CACHE_TTL_MS,
144
+ });
145
+ return config;
146
+ }
147
+
148
+ export async function saveFirstPartyAnalyticsBackend(
149
+ scope: FirstPartyAnalyticsScope,
150
+ config: FirstPartyAnalyticsBackendConfig,
151
+ ): Promise<void> {
152
+ await putScopedSettingRecord(
153
+ { email: scope.userEmail, orgId: scope.orgId },
154
+ FIRST_PARTY_ANALYTICS_BACKEND_SETTING,
155
+ {
156
+ sink: config.sink,
157
+ ...(config.table ? { table: config.table } : {}),
158
+ ...(config.backfillCursor !== undefined
159
+ ? { backfillCursor: config.backfillCursor }
160
+ : {}),
161
+ ...(config.backfillCompleted !== undefined
162
+ ? { backfillCompleted: config.backfillCompleted }
163
+ : {}),
164
+ updatedAt: new Date().toISOString(),
165
+ },
166
+ );
167
+ backendConfigCache.delete(backendScopeKey(scope));
168
+ }
169
+
170
+ export function resetFirstPartyAnalyticsBackendCacheForTests(): void {
171
+ backendConfigCache.clear();
172
+ }
173
+
174
+ export async function getFirstPartyAnalyticsTable(
175
+ configuredTable?: string | null,
176
+ ): Promise<BigQueryTableRef> {
177
+ const projectId = await getBigQueryProjectId();
178
+ return parseTableRef(configuredTable, projectId);
179
+ }
180
+
181
+ function tableName(table: BigQueryTableRef, suffix: string): string {
182
+ return `${table.projectId}.${table.datasetId}.${table.tableId}${suffix}`;
183
+ }
184
+
185
+ function firstPartyAnalyticsRawTable(table: BigQueryTableRef): string {
186
+ return table.fullyQualified;
187
+ }
188
+
189
+ export function firstPartyAnalyticsPhysicalTables(table: BigQueryTableRef): {
190
+ events: string;
191
+ dailyRollups: string;
192
+ userDays: string;
193
+ } {
194
+ return {
195
+ events: tableName(table, "_query"),
196
+ dailyRollups: tableName(table, "_daily_rollups"),
197
+ userDays: tableName(table, "_user_days"),
198
+ };
199
+ }
200
+
201
+ const FIRST_PARTY_ANALYTICS_RAW_SCHEMA = [
202
+ ["id", "STRING"],
203
+ ["public_key_id", "STRING"],
204
+ ["event_name", "STRING"],
205
+ ["user_id", "STRING"],
206
+ ["anonymous_id", "STRING"],
207
+ ["user_key", "STRING"],
208
+ ["session_id", "STRING"],
209
+ ["timestamp", "TIMESTAMP"],
210
+ ["event_date", "DATE"],
211
+ ["received_at", "TIMESTAMP"],
212
+ ["url", "STRING"],
213
+ ["path", "STRING"],
214
+ ["hostname", "STRING"],
215
+ ["referrer", "STRING"],
216
+ ["app", "STRING"],
217
+ ["template", "STRING"],
218
+ ["signed_in", "STRING"],
219
+ ["properties", "STRING"],
220
+ ["context", "STRING"],
221
+ ["owner_email", "STRING"],
222
+ ["org_id", "STRING"],
223
+ ] as const;
224
+
225
+ const FIRST_PARTY_ANALYTICS_QUERY_SCHEMA = FIRST_PARTY_ANALYTICS_RAW_SCHEMA;
226
+ const FIRST_PARTY_ANALYTICS_DAILY_ROLLUP_SCHEMA = [
227
+ ["id", "STRING"],
228
+ ["tenant_key", "STRING"],
229
+ ["owner_email", "STRING"],
230
+ ["org_id", "STRING"],
231
+ ["event_date", "DATE"],
232
+ ["event_name", "STRING"],
233
+ ["app", "STRING"],
234
+ ["template", "STRING"],
235
+ ["event_count", "INT64"],
236
+ ] as const;
237
+ const FIRST_PARTY_ANALYTICS_USER_DAYS_SCHEMA = [
238
+ ["id", "STRING"],
239
+ ["tenant_key", "STRING"],
240
+ ["owner_email", "STRING"],
241
+ ["org_id", "STRING"],
242
+ ["event_date", "DATE"],
243
+ ["user_key", "STRING"],
244
+ ] as const;
245
+
246
+ function sqlList(values: readonly string[]): string {
247
+ return values.map((value) => sqlLiteral(value)).join(", ");
248
+ }
249
+
250
+ function firstPartyEventRowToBigQuery(
251
+ row: FirstPartyAnalyticsEventRow | Record<string, unknown>,
252
+ ): Record<string, unknown> {
253
+ const record = row as Record<string, unknown>;
254
+ const value = (camel: string, snake: string): unknown =>
255
+ record[camel] ?? record[snake];
256
+ return {
257
+ id: value("id", "id"),
258
+ public_key_id: value("publicKeyId", "public_key_id"),
259
+ event_name: value("eventName", "event_name"),
260
+ user_id: value("userId", "user_id"),
261
+ anonymous_id: value("anonymousId", "anonymous_id"),
262
+ user_key: value("userKey", "user_key"),
263
+ session_id: value("sessionId", "session_id"),
264
+ timestamp: value("timestamp", "timestamp"),
265
+ event_date: value("eventDate", "event_date"),
266
+ received_at: value("receivedAt", "received_at"),
267
+ url: value("url", "url"),
268
+ path: value("path", "path"),
269
+ hostname: value("hostname", "hostname"),
270
+ referrer: value("referrer", "referrer"),
271
+ app: value("app", "app"),
272
+ template: value("template", "template"),
273
+ signed_in: value("signedIn", "signed_in"),
274
+ properties: value("properties", "properties") ?? "{}",
275
+ context: value("context", "context") ?? "{}",
276
+ owner_email: value("ownerEmail", "owner_email"),
277
+ org_id: value("orgId", "org_id"),
278
+ };
279
+ }
280
+
281
+ async function insertBatch(
282
+ table: BigQueryTableRef,
283
+ token: string,
284
+ rows: Record<string, unknown>[],
285
+ ): Promise<void> {
286
+ const response = await fetch(
287
+ `https://bigquery.googleapis.com/bigquery/v2/projects/${table.projectId}/datasets/${table.datasetId}/tables/${table.tableId}/insertAll`,
288
+ {
289
+ method: "POST",
290
+ headers: {
291
+ Authorization: `Bearer ${token}`,
292
+ "Content-Type": "application/json",
293
+ },
294
+ body: JSON.stringify({
295
+ skipInvalidRows: false,
296
+ ignoreUnknownValues: false,
297
+ rows: rows.map((row) => ({
298
+ insertId: typeof row.id === "string" ? row.id : undefined,
299
+ json: row,
300
+ })),
301
+ }),
302
+ },
303
+ );
304
+ if (!response.ok) {
305
+ const text = await response.text();
306
+ throw new Error(
307
+ `BigQuery event insert failed (${response.status}): ${text}`,
308
+ );
309
+ }
310
+ const result = (await response.json()) as {
311
+ insertErrors?: Array<{
312
+ index?: number;
313
+ errors?: Array<{ message?: string }>;
314
+ }>;
315
+ };
316
+ if (result.insertErrors?.length) {
317
+ const detail = result.insertErrors
318
+ .flatMap((entry) => entry.errors ?? [])
319
+ .map((entry) => entry.message)
320
+ .filter((message): message is string => Boolean(message))
321
+ .slice(0, 3)
322
+ .join("; ");
323
+ throw new Error(
324
+ `BigQuery rejected ${result.insertErrors.length} event row(s)${detail ? `: ${detail}` : ""}`,
325
+ );
326
+ }
327
+ }
328
+
329
+ export async function insertFirstPartyAnalyticsRows(
330
+ rows: Array<FirstPartyAnalyticsEventRow | Record<string, unknown>>,
331
+ configuredTable?: string | null,
332
+ ): Promise<number> {
333
+ if (!rows.length) return 0;
334
+ requireRequestCredentialContext("GOOGLE_APPLICATION_CREDENTIALS_JSON");
335
+ const [table, token] = await Promise.all([
336
+ getFirstPartyAnalyticsTable(configuredTable),
337
+ getAccessToken(),
338
+ ]);
339
+ const payloadRows = rows.map(firstPartyEventRowToBigQuery);
340
+ for (let offset = 0; offset < payloadRows.length; offset += 50) {
341
+ await insertBatch(table, token, payloadRows.slice(offset, offset + 50));
342
+ }
343
+ return payloadRows.length;
344
+ }
345
+
346
+ function sqlLiteral(value: string | null): string {
347
+ if (value === null) return "NULL";
348
+ return `'${value.replace(/'/g, "''")}'`;
349
+ }
350
+
351
+ function bindSqlArguments(sql: string, args: Array<string | null>): string {
352
+ let index = 0;
353
+ return sql.replace(/\?/g, () => {
354
+ const value = args[index++];
355
+ if (value === undefined) {
356
+ throw new Error("First-party BigQuery query has too few bind arguments");
357
+ }
358
+ return sqlLiteral(value);
359
+ });
360
+ }
361
+
362
+ function maskSqlLiterals(sql: string): string {
363
+ const chars = [...sql];
364
+ let inLiteral = false;
365
+ for (let index = 0; index < chars.length; index++) {
366
+ if (chars[index] !== "'") continue;
367
+ if (!inLiteral) {
368
+ inLiteral = true;
369
+ chars[index] = " ";
370
+ continue;
371
+ }
372
+ if (chars[index + 1] === "'") {
373
+ chars[index] = " ";
374
+ chars[index + 1] = " ";
375
+ index++;
376
+ continue;
377
+ }
378
+ chars[index] = " ";
379
+ inLiteral = false;
380
+ }
381
+ for (let index = 0; index < chars.length; index++) {
382
+ if (inLiteral) chars[index] = " ";
383
+ }
384
+ return chars.join("");
385
+ }
386
+
387
+ function rewriteOutsideSqlLiterals(
388
+ sql: string,
389
+ rewrite: (code: string) => string,
390
+ ): string {
391
+ let result = "";
392
+ let cursor = 0;
393
+ while (cursor < sql.length) {
394
+ const literalStart = sql.indexOf("'", cursor);
395
+ if (literalStart === -1) {
396
+ result += rewrite(sql.slice(cursor));
397
+ break;
398
+ }
399
+ result += rewrite(sql.slice(cursor, literalStart));
400
+ let literalEnd = literalStart + 1;
401
+ while (literalEnd < sql.length) {
402
+ if (sql[literalEnd] !== "'") {
403
+ literalEnd++;
404
+ continue;
405
+ }
406
+ if (sql[literalEnd + 1] === "'") {
407
+ literalEnd += 2;
408
+ continue;
409
+ }
410
+ literalEnd++;
411
+ break;
412
+ }
413
+ result += sql.slice(literalStart, literalEnd);
414
+ cursor = literalEnd;
415
+ }
416
+ return result;
417
+ }
418
+
419
+ function findMatchingSqlParen(sql: string, openIndex: number): number {
420
+ let depth = 0;
421
+ let inLiteral = false;
422
+ for (let index = openIndex; index < sql.length; index++) {
423
+ const char = sql[index];
424
+ if (char === "'") {
425
+ if (inLiteral && sql[index + 1] === "'") {
426
+ index++;
427
+ continue;
428
+ }
429
+ inLiteral = !inLiteral;
430
+ continue;
431
+ }
432
+ if (inLiteral) continue;
433
+ if (char === "(") depth++;
434
+ if (char === ")") {
435
+ depth--;
436
+ if (depth === 0) return index;
437
+ }
438
+ }
439
+ return -1;
440
+ }
441
+
442
+ function splitTopLevelSqlArgs(value: string): string[] {
443
+ const args: string[] = [];
444
+ let start = 0;
445
+ let depth = 0;
446
+ let inLiteral = false;
447
+ for (let index = 0; index < value.length; index++) {
448
+ const char = value[index];
449
+ if (char === "'") {
450
+ if (inLiteral && value[index + 1] === "'") {
451
+ index++;
452
+ continue;
453
+ }
454
+ inLiteral = !inLiteral;
455
+ continue;
456
+ }
457
+ if (inLiteral) continue;
458
+ if (char === "(") depth++;
459
+ if (char === ")") depth--;
460
+ if (char === "," && depth === 0) {
461
+ args.push(value.slice(start, index).trim());
462
+ start = index + 1;
463
+ }
464
+ }
465
+ args.push(value.slice(start).trim());
466
+ return args;
467
+ }
468
+
469
+ function rewriteSqlFunctionCalls(
470
+ sql: string,
471
+ functionName: string,
472
+ rewrite: (args: string[]) => string,
473
+ ): string {
474
+ const functionRe = new RegExp(`\\b${functionName}\\s*\\(`, "gi");
475
+ const masked = maskSqlLiterals(sql);
476
+ let cursor = 0;
477
+ let result = "";
478
+ let match = functionRe.exec(masked);
479
+ while (match) {
480
+ const openIndex = match.index + match[0].lastIndexOf("(");
481
+ const closeIndex = findMatchingSqlParen(sql, openIndex);
482
+ if (closeIndex === -1) {
483
+ throw new Error(
484
+ `First-party BigQuery query has an unterminated ${functionName} call`,
485
+ );
486
+ }
487
+ result += sql.slice(cursor, match.index);
488
+ result += rewrite(
489
+ splitTopLevelSqlArgs(sql.slice(openIndex + 1, closeIndex)),
490
+ );
491
+ cursor = closeIndex + 1;
492
+ functionRe.lastIndex = cursor;
493
+ match = functionRe.exec(masked);
494
+ }
495
+ return match ? result : result + sql.slice(cursor);
496
+ }
497
+
498
+ function replacePostgresCastsInCode(code: string): string {
499
+ const castType = new RegExp(
500
+ "::\\s*(date|timestamp|timestamptz|int|int2|int4|int8|integer|float|float4|float8|double\\s+precision|numeric|text|varchar|boolean|bool|json|jsonb)\\b",
501
+ "gi",
502
+ );
503
+ let result = code;
504
+ let match = castType.exec(result);
505
+ while (match) {
506
+ const normalizedType = match[1].toLowerCase().replace(/\s+/g, " ");
507
+ const mappedType: Record<string, string> = {
508
+ bool: "BOOL",
509
+ boolean: "BOOL",
510
+ date: "DATE",
511
+ double: "FLOAT64",
512
+ "double precision": "FLOAT64",
513
+ float: "FLOAT64",
514
+ float4: "FLOAT64",
515
+ float8: "FLOAT64",
516
+ int: "INT64",
517
+ int2: "INT64",
518
+ int4: "INT64",
519
+ int8: "INT64",
520
+ integer: "INT64",
521
+ numeric: "NUMERIC",
522
+ text: "STRING",
523
+ timestamp: "TIMESTAMP",
524
+ timestamptz: "TIMESTAMP",
525
+ varchar: "STRING",
526
+ };
527
+ const targetType = mappedType[normalizedType];
528
+ if (!targetType) {
529
+ throw new Error(
530
+ `First-party BigQuery query does not support PostgreSQL ${normalizedType} casts`,
531
+ );
532
+ }
533
+
534
+ let operandEnd = match.index;
535
+ while (operandEnd > 0 && /\s/.test(result[operandEnd - 1] ?? "")) {
536
+ operandEnd--;
537
+ }
538
+ let operandStart = operandEnd;
539
+ if (result[operandEnd - 1] === ")") {
540
+ let depth = 0;
541
+ for (let index = operandEnd - 1; index >= 0; index--) {
542
+ if (result[index] === ")") depth++;
543
+ if (result[index] !== "(") continue;
544
+ depth--;
545
+ if (depth === 0) {
546
+ operandStart = index;
547
+ break;
548
+ }
549
+ }
550
+ } else {
551
+ while (
552
+ operandStart > 0 &&
553
+ /[A-Za-z0-9_.$]/.test(result[operandStart - 1] ?? "")
554
+ ) {
555
+ operandStart--;
556
+ }
557
+ }
558
+ if (operandStart === operandEnd) {
559
+ throw new Error(
560
+ "First-party BigQuery query has an invalid PostgreSQL cast",
561
+ );
562
+ }
563
+ const operand = result.slice(operandStart, operandEnd).trim();
564
+ const castEnd = match.index + match[0].length;
565
+ result = `${result.slice(0, operandStart)}CAST(${operand} AS ${targetType})${result.slice(castEnd)}`;
566
+ castType.lastIndex =
567
+ operandStart + `CAST(${operand} AS ${targetType})`.length;
568
+ match = castType.exec(result);
569
+ }
570
+ return result;
571
+ }
572
+
573
+ function translatePostgresDateExpression(value: string): string {
574
+ let translated = replacePostgresCastsInCode(value.trim());
575
+ translated = translated
576
+ .replace(/\bnow\s*\(\s*\)/gi, "CURRENT_TIMESTAMP()")
577
+ .replace(/\bCURRENT_DATE\b(?!\s*\()/gi, "CURRENT_DATE()")
578
+ .replace(/\bCURRENT_TIMESTAMP\b(?!\s*\()/gi, "CURRENT_TIMESTAMP()");
579
+ translated = replaceBigQueryDateArithmetic(translated);
580
+ return translated.trim();
581
+ }
582
+
583
+ function replaceBigQueryDateArithmetic(code: string): string {
584
+ const intervalExpression =
585
+ /((?:[A-Za-z_][A-Za-z0-9_.]*\(\)|[A-Za-z_][A-Za-z0-9_.]*|CAST\([^()]+\s+AS\s+[A-Z0-9]+\)))\s*([+-])\s*INTERVAL\s+(\d+)\s+(DAY|WEEK|MONTH)\b/gi;
586
+ let translated = code.replace(
587
+ intervalExpression,
588
+ (
589
+ _match,
590
+ operand: string,
591
+ operator: string,
592
+ amount: string,
593
+ unit: string,
594
+ ) => {
595
+ const functionName = /CURRENT_TIMESTAMP|TIMESTAMP/i.test(operand)
596
+ ? "TIMESTAMP"
597
+ : "DATE";
598
+ return `${functionName}_${operator === "+" ? "ADD" : "SUB"}(${operand}, INTERVAL ${amount} ${unit.toUpperCase()})`;
599
+ },
600
+ );
601
+ const dateIntegerExpression =
602
+ /((?:[A-Za-z_][A-Za-z0-9_]*\.)*(?:date|event_date|start_date|end_date|cohort_date)|CAST\([^()]+\s+AS\s+DATE\))\s*\+\s*([A-Za-z_][A-Za-z0-9_.]*)/gi;
603
+ translated = translated.replace(
604
+ dateIntegerExpression,
605
+ (_match, dateExpression: string, offsetExpression: string) =>
606
+ `DATE_ADD(${dateExpression}, INTERVAL ${offsetExpression} DAY)`,
607
+ );
608
+ return translated;
609
+ }
610
+
611
+ function translatePostgresJsonOperators(sql: string): string {
612
+ return sql.replace(
613
+ /\b([A-Za-z_][A-Za-z0-9_.]*)\s*::\s*jsonb\s*->>\s*'([^']+)'/gi,
614
+ (_match, expression: string, key: string) =>
615
+ `JSON_VALUE(${expression}, '$.${key}')`,
616
+ );
617
+ }
618
+
619
+ function translateFirstPartyAnalyticsBigQuerySql(sql: string): string {
620
+ let translated = translatePostgresJsonOperators(sql);
621
+ translated = translated.replace(
622
+ /\bINTERVAL\s*'(\d+)\s+(day|days|week|weeks|month|months)'/gi,
623
+ (_match, amount: string, unit: string) =>
624
+ `INTERVAL ${amount} ${unit.replace(/s$/i, "").toUpperCase()}`,
625
+ );
626
+ translated = rewriteSqlFunctionCalls(translated, "date_trunc", (args) => {
627
+ if (args.length !== 2 || !/^'week'$/i.test(args[0] ?? "")) {
628
+ throw new Error(
629
+ "First-party BigQuery query only supports PostgreSQL date_trunc('week', ...) expressions",
630
+ );
631
+ }
632
+ return `DATE_TRUNC(CAST(${translatePostgresDateExpression(args[1] ?? "")} AS DATE), WEEK(MONDAY))`;
633
+ });
634
+ translated = rewriteSqlFunctionCalls(translated, "to_char", (args) => {
635
+ if (args.length !== 2 || !/^'YYYY-MM-DD'$/i.test(args[1] ?? "")) {
636
+ throw new Error(
637
+ "First-party BigQuery query only supports PostgreSQL to_char(..., 'YYYY-MM-DD') expressions",
638
+ );
639
+ }
640
+ return `FORMAT_DATE('%Y-%m-%d', CAST(${translatePostgresDateExpression(args[0] ?? "")} AS DATE))`;
641
+ });
642
+ translated = rewriteSqlFunctionCalls(translated, "chr", (args) => {
643
+ if (args.length !== 1) {
644
+ throw new Error("First-party BigQuery query has an invalid chr call");
645
+ }
646
+ return `CHR(${args[0]})`;
647
+ });
648
+ let previous = "";
649
+ while (previous !== translated) {
650
+ previous = translated;
651
+ translated = rewriteSqlFunctionCalls(translated, "split_part", (args) => {
652
+ const index = Number(args[2]);
653
+ if (args.length !== 3 || !Number.isInteger(index) || index < 1) {
654
+ throw new Error(
655
+ "First-party BigQuery query only supports split_part with a positive integer index",
656
+ );
657
+ }
658
+ return `SPLIT(${args[0]}, ${args[1]})[SAFE_OFFSET(${index - 1})]`;
659
+ });
660
+ }
661
+ translated = rewriteOutsideSqlLiterals(translated, (code) => {
662
+ let rewritten = replacePostgresCastsInCode(code);
663
+ rewritten = rewritten
664
+ .replace(/\bnow\s*\(\s*\)/gi, "CURRENT_TIMESTAMP()")
665
+ .replace(/\bCURRENT_DATE\b(?!\s*\()/gi, "CURRENT_DATE()")
666
+ .replace(/\bCURRENT_TIMESTAMP\b(?!\s*\()/gi, "CURRENT_TIMESTAMP()");
667
+ return replaceBigQueryDateArithmetic(rewritten);
668
+ });
669
+
670
+ const code = maskSqlLiterals(translated);
671
+ const unsupported: Array<[RegExp, string]> = [
672
+ [/\bto_char\s*\(/i, "to_char"],
673
+ [/\bdate_trunc\s*\(\s*'/i, "date_trunc"],
674
+ [/\b(?:ILIKE|SIMILAR\s+TO)\b/i, "ILIKE/SIMILAR TO"],
675
+ [
676
+ /\b(?:LATERAL|generate_series|unnest|string_to_array)\b/i,
677
+ "set-returning PostgreSQL functions",
678
+ ],
679
+ [/::\s*[A-Za-z_]/i, "PostgreSQL casts"],
680
+ [/\bINTERVAL\s*'/i, "PostgreSQL interval literals"],
681
+ [/\bAT\s+TIME\s+ZONE\b/i, "AT TIME ZONE"],
682
+ [/\bFILTER\s*\(\s*WHERE\b/i, "FILTER (WHERE ...)"],
683
+ ];
684
+ const incompatible = unsupported.find(([pattern]) => pattern.test(code));
685
+ if (incompatible) {
686
+ throw new Error(
687
+ `First-party analytics query uses unsupported PostgreSQL syntax (${incompatible[1]}) after BigQuery translation`,
688
+ );
689
+ }
690
+ return translated;
691
+ }
692
+
693
+ function qualifyQuerySources(sql: string, table: BigQueryTableRef): string {
694
+ const physical = firstPartyAnalyticsPhysicalTables(table);
695
+ const sourceMap: Record<string, string> = {
696
+ // Event predicates are injected by scopedAnalyticsSql. Use the raw table
697
+ // here so those predicates run before the retry-deduplication window.
698
+ analytics_events: firstPartyAnalyticsRawTable(table),
699
+ analytics_event_daily_rollups: physical.dailyRollups,
700
+ analytics_user_days: physical.userDays,
701
+ };
702
+ const sourcePattern = FIRST_PARTY_QUERY_TABLES.join("|");
703
+ return sql.replace(
704
+ new RegExp(`\\b(from|join)\\s+(${sourcePattern})\\b`, "gi"),
705
+ (_match, keyword: string, logicalName: string) =>
706
+ `${keyword} \`${sourceMap[logicalName.toLowerCase()] ?? logicalName}\``,
707
+ );
708
+ }
709
+
710
+ function addPartitionPrunedEventDeduplication(
711
+ sql: string,
712
+ table: BigQueryTableRef,
713
+ ): string {
714
+ const quote = String.fromCharCode(96);
715
+ const source =
716
+ "SELECT * FROM " +
717
+ quote +
718
+ firstPartyAnalyticsRawTable(table) +
719
+ quote +
720
+ " WHERE";
721
+ let result = "";
722
+ let cursor = 0;
723
+ while (cursor < sql.length) {
724
+ const sourceIndex = sql.indexOf(source, cursor);
725
+ if (sourceIndex === -1) return result + sql.slice(cursor);
726
+ const predicateStart = sourceIndex + source.length;
727
+ let depth = 0;
728
+ let inLiteral = false;
729
+ let predicateEnd = sql.length;
730
+ for (let index = predicateStart; index < sql.length; index++) {
731
+ const char = sql[index];
732
+ if (char === "'") {
733
+ if (inLiteral && sql[index + 1] === "'") {
734
+ index++;
735
+ continue;
736
+ }
737
+ inLiteral = !inLiteral;
738
+ continue;
739
+ }
740
+ if (inLiteral) continue;
741
+ if (char === "(") {
742
+ depth++;
743
+ continue;
744
+ }
745
+ if (char === ")") {
746
+ if (depth === 0) {
747
+ predicateEnd = index;
748
+ break;
749
+ }
750
+ depth--;
751
+ continue;
752
+ }
753
+ if (depth === 0 && /^UNION\s+ALL\b/i.test(sql.slice(index))) {
754
+ predicateEnd = index;
755
+ break;
756
+ }
757
+ }
758
+ result +=
759
+ sql.slice(cursor, predicateEnd) +
760
+ " QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY received_at DESC) = 1";
761
+ cursor = predicateEnd;
762
+ }
763
+ return result;
764
+ }
765
+
766
+ export function renderFirstPartyAnalyticsBigQuerySql(
767
+ scopedSql: string,
768
+ args: Array<string | null>,
769
+ table: BigQueryTableRef,
770
+ ): string {
771
+ // The Postgres/SQLite scope builder uses a text fallback for nullable event
772
+ // dates. BigQuery's event_date is a DATE, and the fallback is unnecessary
773
+ // because the sink normalizes it before insert.
774
+ const normalizedScopeSql = scopedSql.replace(
775
+ /\(COALESCE\(NULLIF\(event_date, ''\), substr\(timestamp, 1, 10\)\) <= \?\)/g,
776
+ "(event_date <= ?)",
777
+ );
778
+ const translated =
779
+ translateFirstPartyAnalyticsBigQuerySql(normalizedScopeSql);
780
+ const bound = bindSqlArguments(translated, args);
781
+ return addPartitionPrunedEventDeduplication(
782
+ qualifyQuerySources(bound, table),
783
+ table,
784
+ );
785
+ }
786
+
787
+ export async function queryFirstPartyAnalyticsInBigQuery(
788
+ scopedSql: string,
789
+ args: Array<string | null>,
790
+ table: BigQueryTableRef,
791
+ ): Promise<{
792
+ rows: Record<string, unknown>[];
793
+ schema: { name: string; type: string }[];
794
+ }> {
795
+ const result = await runQuery(
796
+ `SELECT * FROM (${renderFirstPartyAnalyticsBigQuerySql(scopedSql, args, table)}) AS first_party_analytics_query LIMIT 5000`,
797
+ );
798
+ return { rows: result.rows, schema: result.schema };
799
+ }
800
+
801
+ export async function assertFirstPartyAnalyticsBigQueryReady(
802
+ configuredTable?: string | null,
803
+ ): Promise<{ table: BigQueryTableRef; rowCount: number }> {
804
+ const table = await getFirstPartyAnalyticsTable(configuredTable);
805
+ const expectedColumns = new Map<string, readonly string[]>([
806
+ [table.tableId, FIRST_PARTY_ANALYTICS_RAW_SCHEMA.map(([name]) => name)],
807
+ [
808
+ table.tableId + "_query",
809
+ FIRST_PARTY_ANALYTICS_QUERY_SCHEMA.map(([name]) => name),
810
+ ],
811
+ [
812
+ table.tableId + "_daily_rollups",
813
+ FIRST_PARTY_ANALYTICS_DAILY_ROLLUP_SCHEMA.map(([name]) => name),
814
+ ],
815
+ [
816
+ table.tableId + "_user_days",
817
+ FIRST_PARTY_ANALYTICS_USER_DAYS_SCHEMA.map(([name]) => name),
818
+ ],
819
+ ]);
820
+ const tableNames = [...expectedColumns.keys()];
821
+ const quote = String.fromCharCode(96);
822
+ const informationSchema =
823
+ quote +
824
+ table.projectId +
825
+ "." +
826
+ table.datasetId +
827
+ ".INFORMATION_SCHEMA" +
828
+ quote;
829
+ const tableMetadata = await runQuery(
830
+ "SELECT table_name FROM " +
831
+ informationSchema +
832
+ ".TABLES WHERE table_name IN (" +
833
+ sqlList(tableNames) +
834
+ ")",
835
+ );
836
+ const presentTables = new Set(
837
+ tableMetadata.rows
838
+ .map((row) => row.table_name)
839
+ .filter((value): value is string => typeof value === "string"),
840
+ );
841
+ const missingTables = tableNames.filter((name) => !presentTables.has(name));
842
+ if (missingTables.length) {
843
+ throw new Error(
844
+ "First-party Analytics BigQuery is missing required tables or views: " +
845
+ missingTables.join(", "),
846
+ );
847
+ }
848
+ const columnMetadata = await runQuery(
849
+ "SELECT table_name, column_name FROM " +
850
+ informationSchema +
851
+ ".COLUMNS WHERE table_name IN (" +
852
+ sqlList(tableNames) +
853
+ ")",
854
+ );
855
+ const presentColumns = new Map<string, Set<string>>();
856
+ for (const row of columnMetadata.rows) {
857
+ if (
858
+ typeof row.table_name !== "string" ||
859
+ typeof row.column_name !== "string"
860
+ ) {
861
+ continue;
862
+ }
863
+ const columns = presentColumns.get(row.table_name) ?? new Set<string>();
864
+ columns.add(row.column_name);
865
+ presentColumns.set(row.table_name, columns);
866
+ }
867
+ const missingColumns: string[] = [];
868
+ for (const [tableName, columns] of expectedColumns) {
869
+ for (const column of columns) {
870
+ if (!presentColumns.get(tableName)?.has(column)) {
871
+ missingColumns.push(tableName + "." + column);
872
+ }
873
+ }
874
+ }
875
+ if (missingColumns.length) {
876
+ throw new Error(
877
+ "First-party Analytics BigQuery is missing required columns: " +
878
+ missingColumns.join(", "),
879
+ );
880
+ }
881
+ const result = await runQuery(
882
+ "SELECT COUNT(*) AS row_count FROM " + quote + table.fullyQualified + quote,
883
+ );
884
+ const rowCount = Number(result.rows[0]?.row_count ?? 0);
885
+ return { table, rowCount: Number.isFinite(rowCount) ? rowCount : 0 };
886
+ }
887
+
888
+ export async function getFirstPartyAnalyticsBigQueryMetrics(
889
+ scope: FirstPartyAnalyticsScope,
890
+ configuredTable?: string | null,
891
+ ): Promise<{
892
+ eventCount: number;
893
+ dailyRollupRows: number;
894
+ firstEventDate: string | null;
895
+ lastEventDate: string | null;
896
+ }> {
897
+ const table = await getFirstPartyAnalyticsTable(configuredTable);
898
+ const physical = firstPartyAnalyticsPhysicalTables(table);
899
+ const ownerEmail = sqlLiteral(scope.userEmail);
900
+ const today = sqlLiteral(new Date().toISOString().slice(0, 10));
901
+ const tenantFilter = scope.orgId
902
+ ? `(org_id = ${sqlLiteral(scope.orgId)} OR (org_id IS NULL AND owner_email = ${ownerEmail}))`
903
+ : `(org_id IS NULL AND owner_email = ${ownerEmail})`;
904
+ const result = await runQuery(`
905
+ WITH scoped_events AS (
906
+ SELECT *
907
+ FROM \`${physical.events}\`
908
+ WHERE ${tenantFilter} AND event_date <= ${today}
909
+ )
910
+ SELECT
911
+ COUNT(*) AS event_count,
912
+ COUNT(DISTINCT CONCAT(
913
+ CAST(event_date AS STRING), '|', event_name, '|', COALESCE(app, ''),
914
+ '|', COALESCE(template, '')
915
+ )) AS daily_rollup_rows,
916
+ MIN(event_date) AS first_event_date,
917
+ MAX(event_date) AS last_event_date
918
+ FROM scoped_events
919
+ `);
920
+ const row = result.rows[0] ?? {};
921
+ const readValue = (...names: string[]): unknown => {
922
+ for (const name of names) {
923
+ if (Object.prototype.hasOwnProperty.call(row, name)) return row[name];
924
+ }
925
+ return undefined;
926
+ };
927
+ const numberValue = (value: unknown): number => {
928
+ const parsed = Number(value);
929
+ return Number.isFinite(parsed) ? parsed : 0;
930
+ };
931
+ const dateValue = (value: unknown): string | null => {
932
+ if (typeof value === "string" && value) return value.slice(0, 10);
933
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
934
+ return value.toISOString().slice(0, 10);
935
+ }
936
+ if (value && typeof value === "object" && "value" in value) {
937
+ return dateValue((value as { value?: unknown }).value);
938
+ }
939
+ return null;
940
+ };
941
+ return {
942
+ eventCount: numberValue(readValue("event_count", "eventCount")),
943
+ dailyRollupRows: numberValue(
944
+ readValue("daily_rollup_rows", "dailyRollupRows"),
945
+ ),
946
+ firstEventDate: dateValue(readValue("first_event_date", "firstEventDate")),
947
+ lastEventDate: dateValue(readValue("last_event_date", "lastEventDate")),
948
+ };
949
+ }
950
+
951
+ export interface FirstPartyAnalyticsBackfillBatch {
952
+ nextCursor: string | null;
953
+ copied: number;
954
+ complete: boolean;
955
+ }
956
+
957
+ function parseBackfillCursor(
958
+ cursor: string | null,
959
+ ): FirstPartyAnalyticsBackfillCursor {
960
+ if (!cursor) return { receivedAt: "", id: "" };
961
+ try {
962
+ const parsed = JSON.parse(
963
+ cursor,
964
+ ) as Partial<FirstPartyAnalyticsBackfillCursor>;
965
+ if (
966
+ typeof parsed.receivedAt === "string" &&
967
+ typeof parsed.id === "string"
968
+ ) {
969
+ return { receivedAt: parsed.receivedAt, id: parsed.id };
970
+ }
971
+ // coercion-ok: legacy cursors restart from the beginning to prevent skipped dual-write events.
972
+ } catch {
973
+ // Legacy cursors were only event ids. Restart from the beginning so a
974
+ // dual-write failure cannot leave an event below the new tuple cursor.
975
+ }
976
+ return { receivedAt: "", id: "" };
977
+ }
978
+
979
+ function serializeBackfillCursor(
980
+ cursor: FirstPartyAnalyticsBackfillCursor,
981
+ ): string {
982
+ return JSON.stringify(cursor);
983
+ }
984
+
985
+ function backfillScopeSql(
986
+ scope: FirstPartyAnalyticsScope,
987
+ cursor: FirstPartyAnalyticsBackfillCursor,
988
+ ): {
989
+ sql: string;
990
+ args: string[];
991
+ } {
992
+ const cursorSql =
993
+ "(received_at > ? OR (received_at = ? AND id > ?)) ORDER BY received_at ASC, id ASC LIMIT ?";
994
+ if (scope.orgId) {
995
+ return {
996
+ sql:
997
+ "WHERE (org_id = ? OR (org_id IS NULL AND owner_email = ?)) AND " +
998
+ cursorSql,
999
+ args: [
1000
+ scope.orgId,
1001
+ scope.userEmail,
1002
+ cursor.receivedAt,
1003
+ cursor.receivedAt,
1004
+ cursor.id,
1005
+ ],
1006
+ };
1007
+ }
1008
+ return {
1009
+ sql: "WHERE org_id IS NULL AND owner_email = ? AND " + cursorSql,
1010
+ args: [scope.userEmail, cursor.receivedAt, cursor.receivedAt, cursor.id],
1011
+ };
1012
+ }
1013
+
1014
+ export async function backfillFirstPartyAnalyticsBatch(
1015
+ scope: FirstPartyAnalyticsScope,
1016
+ cursor: string | null,
1017
+ limit: number,
1018
+ configuredTable?: string | null,
1019
+ ): Promise<FirstPartyAnalyticsBackfillBatch> {
1020
+ const boundedLimit = Math.min(Math.max(Math.floor(limit), 1), 500);
1021
+ const db = getDbExec();
1022
+ const parsedCursor = parseBackfillCursor(cursor);
1023
+ const scoped = backfillScopeSql(scope, parsedCursor);
1024
+ const result = await db.execute({
1025
+ sql: `SELECT * FROM analytics_events ${scoped.sql}`,
1026
+ args: [...scoped.args, boundedLimit],
1027
+ timeoutMs: 20_000,
1028
+ maxAttempts: 1,
1029
+ });
1030
+ const rows = result.rows as Record<string, unknown>[];
1031
+ if (!rows.length) {
1032
+ return {
1033
+ nextCursor: serializeBackfillCursor(parsedCursor),
1034
+ copied: 0,
1035
+ complete: true,
1036
+ };
1037
+ }
1038
+ await insertFirstPartyAnalyticsRows(rows, configuredTable);
1039
+ const lastId = rows[rows.length - 1]?.id;
1040
+ if (typeof lastId !== "string" || !lastId) {
1041
+ throw new Error("First-party analytics backfill row is missing its id");
1042
+ }
1043
+ const lastReceivedAt =
1044
+ rows[rows.length - 1]?.received_at ?? rows[rows.length - 1]?.receivedAt;
1045
+ if (typeof lastReceivedAt !== "string" || !lastReceivedAt) {
1046
+ throw new Error(
1047
+ "First-party analytics backfill row is missing its received_at",
1048
+ );
1049
+ }
1050
+ return {
1051
+ nextCursor: serializeBackfillCursor({
1052
+ receivedAt: lastReceivedAt,
1053
+ id: lastId,
1054
+ }),
1055
+ copied: rows.length,
1056
+ complete: rows.length < boundedLimit,
1057
+ };
1058
+ }