@agent-native/core 0.147.0 → 0.148.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +115 -68
  3. package/corpus/templates/analytics/DEVELOPING.md +50 -3
  4. package/corpus/templates/analytics/actions/purge-first-party-analytics-postgres.ts +190 -0
  5. package/corpus/templates/analytics/app/i18n/index.ts +14 -29
  6. package/corpus/templates/analytics/app/i18n-data.ts +10 -10
  7. package/corpus/templates/analytics/app/pages/Settings.tsx +5 -21
  8. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +1 -3
  9. package/corpus/templates/analytics/scripts/first-party-analytics-bigquery-backfill.ts +197 -18
  10. package/corpus/templates/analytics/server/db/schema.ts +23 -0
  11. package/corpus/templates/analytics/server/handlers/first-party-analytics.ts +3 -1
  12. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +1 -1
  13. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +113 -13
  14. package/corpus/templates/analytics/server/lib/first-party-analytics-purge.ts +140 -0
  15. package/corpus/templates/analytics/server/lib/first-party-analytics-volume.ts +207 -0
  16. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +40 -9
  17. package/corpus/templates/analytics/server/lib/gcloud.ts +148 -20
  18. package/corpus/templates/analytics/server/plugins/db.ts +42 -0
  19. package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +115 -68
  20. package/corpus/templates/assets/app/i18n/index.ts +14 -29
  21. package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +115 -68
  22. package/corpus/templates/brain/app/i18n/index.ts +14 -29
  23. package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +115 -68
  24. package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +115 -68
  25. package/corpus/templates/chat/app/i18n/index.ts +14 -29
  26. package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +115 -68
  27. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +2 -7
  28. package/corpus/templates/clips/app/components/recorder/storage-setup-card.tsx +18 -13
  29. package/corpus/templates/clips/app/components/settings/ai-setup-section.tsx +87 -370
  30. package/corpus/templates/clips/app/components/settings/slack-section.tsx +59 -101
  31. package/corpus/templates/clips/app/components/settings/video-storage-section.tsx +136 -213
  32. package/corpus/templates/clips/app/i18n/en-US.ts +11 -8
  33. package/corpus/templates/clips/desktop/src/app.tsx +1 -1
  34. package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +115 -68
  35. package/corpus/templates/content/app/i18n/index.ts +14 -29
  36. package/corpus/templates/crm/app/i18n/index.ts +14 -29
  37. package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +115 -68
  38. package/corpus/templates/design/actions/analyze-brand-assets.ts +15 -15
  39. package/corpus/templates/design/actions/import-code.ts +2 -43
  40. package/corpus/templates/design/app/i18n/index.ts +14 -29
  41. package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +115 -68
  42. package/corpus/templates/dispatch/app/i18n-data.ts +1 -1
  43. package/corpus/templates/factory/.agents/skills/frontend-design/SKILL.md +115 -68
  44. package/corpus/templates/factory/AGENTS.md +40 -49
  45. package/corpus/templates/factory/actions/babysit-agent-native-pull-request.ts +66 -0
  46. package/corpus/templates/factory/actions/evaluate-triage-item.ts +37 -0
  47. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +20 -0
  48. package/corpus/templates/factory/actions/get-triage-item.ts +25 -4
  49. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +55 -0
  50. package/corpus/templates/factory/actions/ingest-github-observation.ts +21 -0
  51. package/corpus/templates/factory/actions/list-factory-audit.ts +118 -0
  52. package/corpus/templates/factory/actions/list-factory-automations.ts +2 -1
  53. package/corpus/templates/factory/actions/list-triage-items.ts +24 -2
  54. package/corpus/templates/factory/actions/poll-github-sources.ts +60 -0
  55. package/corpus/templates/factory/actions/poll-sentry-errors.ts +39 -0
  56. package/corpus/templates/factory/actions/poll-slack-channel.ts +45 -1
  57. package/corpus/templates/factory/actions/run-factory-automation.ts +1 -1
  58. package/corpus/templates/factory/actions/save-factory-automation.ts +2 -2
  59. package/corpus/templates/factory/actions/start-builder-for-item.ts +51 -0
  60. package/corpus/templates/factory/app/components/factory/FactoryAuditView.tsx +685 -0
  61. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +6 -0
  62. package/corpus/templates/factory/app/i18n/en-US.ts +25 -0
  63. package/corpus/templates/factory/app/routes/factory.tsx +95 -42
  64. package/corpus/templates/factory/server/db/schema.ts +37 -0
  65. package/corpus/templates/factory/server/lib/require-factory-automation.ts +5 -1
  66. package/corpus/templates/factory/server/plugins/factory-migrations.ts +29 -0
  67. package/corpus/templates/factory/server/triage/audit.ts +79 -0
  68. package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +115 -68
  69. package/corpus/templates/forms/app/i18n/index.ts +14 -29
  70. package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +115 -68
  71. package/corpus/templates/macros/app/i18n/index.ts +14 -29
  72. package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +115 -68
  73. package/corpus/templates/mail/app/i18n/index.ts +14 -29
  74. package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +115 -68
  75. package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +7 -7
  76. package/corpus/templates/plan/app/i18n/index.ts +14 -29
  77. package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +115 -68
  78. package/corpus/templates/slides/actions/analyze-brand-assets.ts +15 -15
  79. package/corpus/templates/slides/actions/import-code.ts +2 -43
  80. package/corpus/templates/slides/app/i18n/index.ts +14 -29
  81. package/corpus/templates/slides/server/routes/api/preview-images.get.ts +4 -1
  82. package/corpus/templates/tasks/.agents/skills/frontend-design/SKILL.md +115 -68
  83. package/corpus/templates/tasks/app/components/layout/HeaderActions.tsx +1 -84
  84. package/corpus/templates/tasks/app/hooks/use-mobile.tsx +1 -21
  85. package/corpus/templates/tasks/app/i18n/index.ts +14 -29
  86. package/dist/agent/engine/ai-sdk-engine.js +2 -1
  87. package/dist/agent/engine/openai-compatible-endpoint.d.ts +2 -0
  88. package/dist/agent/engine/openai-compatible-endpoint.js +5 -1
  89. package/dist/agent/engine/provider-endpoint-validation.d.ts +9 -0
  90. package/dist/agent/engine/provider-endpoint-validation.js +26 -0
  91. package/dist/agent/engine/provider-env-vars.d.ts +1 -1
  92. package/dist/agent/engine/provider-env-vars.js +1 -1
  93. package/dist/agent/engine/registry.d.ts +8 -6
  94. package/dist/agent/engine/registry.js +37 -24
  95. package/dist/agent/tool-search.d.ts +1 -0
  96. package/dist/agent/tool-search.js +17 -1
  97. package/dist/automations/service.js +4 -10
  98. package/dist/brand-kit/brand-signals.d.ts +13 -0
  99. package/dist/brand-kit/brand-signals.js +15 -0
  100. package/dist/brand-kit/index.d.ts +2 -2
  101. package/dist/brand-kit/index.js +1 -1
  102. package/dist/cli/plan-blocks.d.ts +3 -18
  103. package/dist/cli/plan-blocks.js +2 -95
  104. package/dist/cli/plan-publish-store.d.ts +2 -61
  105. package/dist/cli/plan-publish-store.js +2 -127
  106. package/dist/cli/skills-content/connection.d.ts +1 -1
  107. package/dist/cli/skills-content/connection.js +1 -52
  108. package/dist/cli/skills-content/visual-recap-skill.d.ts +1 -1
  109. package/dist/cli/skills-content/visual-recap-skill.js +1 -553
  110. package/dist/client/agent-engine-key.d.ts +14 -4
  111. package/dist/client/agent-engine-key.js +51 -5
  112. package/dist/client/agent-provider-catalog.d.ts +20 -0
  113. package/dist/client/agent-provider-catalog.js +100 -0
  114. package/dist/client/blocks/index.d.ts +1 -0
  115. package/dist/client/blocks/index.js +1 -0
  116. package/dist/client/chat/run-recovery.js +4 -48
  117. package/dist/client/chat-first.js +4 -3
  118. package/dist/client/i18n.d.ts +14 -0
  119. package/dist/client/i18n.js +18 -0
  120. package/dist/client/settings/ProviderSetupForm.d.ts +21 -0
  121. package/dist/client/settings/ProviderSetupForm.js +145 -0
  122. package/dist/client/settings/SettingsPanel.js +52 -49
  123. package/dist/client/settings/index.d.ts +2 -0
  124. package/dist/client/settings/index.js +2 -0
  125. package/dist/coding-tools/run-code.d.ts +5 -0
  126. package/dist/coding-tools/run-code.js +196 -36
  127. package/dist/coding-tools/tool-orchestration.d.ts +10 -0
  128. package/dist/coding-tools/tool-orchestration.js +107 -0
  129. package/dist/collab/awareness.d.ts +2 -2
  130. package/dist/jobs/frontmatter.d.ts +9 -0
  131. package/dist/jobs/frontmatter.js +16 -0
  132. package/dist/jobs/scheduler.js +8 -8
  133. package/dist/jobs/tools.d.ts +1 -1
  134. package/dist/jobs/tools.js +2 -7
  135. package/dist/localization/default-messages.d.ts +20 -0
  136. package/dist/localization/default-messages.js +25 -5
  137. package/dist/observability/routes.d.ts +3 -3
  138. package/dist/onboarding/app-profile.js +1 -1
  139. package/dist/progress/routes.d.ts +1 -1
  140. package/dist/provider-api/actions/custom-provider-registration.d.ts +8 -8
  141. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  142. package/dist/resources/handlers.d.ts +1 -1
  143. package/dist/scripts/agent-engines/test-agent-engine.js +34 -10
  144. package/dist/secrets/routes.d.ts +9 -9
  145. package/dist/server/agent-chat/run-code-tools.d.ts +1 -1
  146. package/dist/server/agent-chat/run-code-tools.js +3 -1
  147. package/dist/server/agent-chat-plugin.js +6 -6
  148. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  149. package/dist/server/agent-engine-api-key-route.js +43 -12
  150. package/dist/server/agents-bundle.js +2 -1
  151. package/dist/server/auth.js +21 -5
  152. package/dist/server/credential-provider.js +1 -0
  153. package/dist/server/design-token-utils.d.ts +18 -0
  154. package/dist/server/design-token-utils.js +31 -0
  155. package/dist/server/onboarding-html.js +22 -2
  156. package/dist/server/realtime-token.d.ts +1 -1
  157. package/dist/templates/chat/.agents/skills/frontend-design/SKILL.md +115 -68
  158. package/dist/templates/chat/app/i18n/index.ts +14 -29
  159. package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +115 -68
  160. package/dist/templates/default/app/i18n/index.ts +14 -29
  161. package/dist/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +22 -9
  162. package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +115 -68
  163. package/dist/triggers/dispatcher.js +1 -8
  164. package/docs/content/environment-variables.mdx +39 -38
  165. package/package.json +4 -4
  166. package/src/templates/chat/.agents/skills/frontend-design/SKILL.md +115 -68
  167. package/src/templates/chat/app/i18n/index.ts +14 -29
  168. package/src/templates/default/.agents/skills/frontend-design/SKILL.md +115 -68
  169. package/src/templates/default/app/i18n/index.ts +14 -29
  170. package/src/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +22 -9
  171. package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +115 -68
  172. package/corpus/templates/plan/app/components/plan/wireframe/sanitize-html.ts +0 -343
@@ -6,7 +6,7 @@ import {
6
6
  type BigQueryTableRef,
7
7
  } from "./bigquery.js";
8
8
  import { requireRequestCredentialContext } from "./credentials-context.js";
9
- import { getAccessToken } from "./gcloud.js";
9
+ import { fetchGoogleWithRetry, getAccessToken } from "./gcloud.js";
10
10
  import {
11
11
  getScopedSettingRecord,
12
12
  putScopedSettingRecord,
@@ -78,6 +78,19 @@ const MAX_INSERT_BATCH_SIZE = 200;
78
78
  const MAX_DEDICATED_INSERT_BATCH_SIZE = 500;
79
79
  const MAX_DEDICATED_INSERT_CONCURRENCY = 4;
80
80
  const MAX_INSERT_REQUEST_BYTES = 8 * 1024 * 1024;
81
+ const DEFAULT_BACKFILL_LOOKBACK_DAYS = 60;
82
+ const MIN_BACKFILL_LOOKBACK_DAYS = 30;
83
+ const MAX_BACKFILL_LOOKBACK_DAYS = 60;
84
+ const DEFAULT_BACKFILL_SKIP_EVENTS = ["http.response"];
85
+ const MAX_BACKFILL_SKIP_EVENTS = 32;
86
+ const BACKFILL_LOOKBACK_DAYS_ENV = "ANALYTICS_BIGQUERY_BACKFILL_LOOKBACK_DAYS";
87
+ const BACKFILL_SKIP_EVENTS_ENV = "ANALYTICS_BIGQUERY_BACKFILL_SKIP_EVENTS";
88
+
89
+ export interface FirstPartyAnalyticsBackfillOptions {
90
+ lookbackDays?: number;
91
+ skipEventNames?: string[];
92
+ now?: () => string;
93
+ }
81
94
 
82
95
  export interface FirstPartyAnalyticsInsertOptions {
83
96
  /** Maximum rows in one BigQuery insertAll request. */
@@ -319,7 +332,7 @@ async function insertBatch(
319
332
  token: string,
320
333
  rows: Record<string, unknown>[],
321
334
  ): Promise<void> {
322
- const response = await fetch(
335
+ const response = await fetchGoogleWithRetry(
323
336
  `https://bigquery.googleapis.com/bigquery/v2/projects/${table.projectId}/datasets/${table.datasetId}/tables/${table.tableId}/insertAll`,
324
337
  {
325
338
  method: "POST",
@@ -336,6 +349,7 @@ async function insertBatch(
336
349
  })),
337
350
  }),
338
351
  },
352
+ "BigQuery insertAll",
339
353
  );
340
354
  if (!response.ok) {
341
355
  const text = await response.text();
@@ -939,6 +953,7 @@ export async function queryFirstPartyAnalyticsInBigQuery(
939
953
 
940
954
  export async function assertFirstPartyAnalyticsBigQueryReady(
941
955
  configuredTable?: string | null,
956
+ options: { includeRowCount?: boolean } = {},
942
957
  ): Promise<{ table: BigQueryTableRef; rowCount: number }> {
943
958
  const table = await getFirstPartyAnalyticsTable(configuredTable);
944
959
  const expectedColumns = new Map<string, readonly string[]>([
@@ -1017,6 +1032,9 @@ export async function assertFirstPartyAnalyticsBigQueryReady(
1017
1032
  missingColumns.join(", "),
1018
1033
  );
1019
1034
  }
1035
+ if (options.includeRowCount === false) {
1036
+ return { table, rowCount: 0 };
1037
+ }
1020
1038
  const result = await runQuery(
1021
1039
  "SELECT COUNT(*) AS row_count FROM " + quote + table.fullyQualified + quote,
1022
1040
  );
@@ -1027,6 +1045,7 @@ export async function assertFirstPartyAnalyticsBigQueryReady(
1027
1045
  export async function getFirstPartyAnalyticsBigQueryMetrics(
1028
1046
  scope: FirstPartyAnalyticsScope,
1029
1047
  configuredTable?: string | null,
1048
+ options: { includeLegacyOwnerRows?: boolean; startDate?: string } = {},
1030
1049
  ): Promise<{
1031
1050
  eventCount: number;
1032
1051
  dailyRollupRows: number;
@@ -1037,14 +1056,20 @@ export async function getFirstPartyAnalyticsBigQueryMetrics(
1037
1056
  const physical = firstPartyAnalyticsPhysicalTables(table);
1038
1057
  const ownerEmail = sqlLiteral(scope.userEmail);
1039
1058
  const today = sqlLiteral(new Date().toISOString().slice(0, 10));
1059
+ const includeLegacyOwnerRows = options.includeLegacyOwnerRows !== false;
1040
1060
  const tenantFilter = scope.orgId
1041
- ? `(org_id = ${sqlLiteral(scope.orgId)} OR (org_id IS NULL AND owner_email = ${ownerEmail}))`
1061
+ ? includeLegacyOwnerRows
1062
+ ? `(org_id = ${sqlLiteral(scope.orgId)} OR (org_id IS NULL AND owner_email = ${ownerEmail}))`
1063
+ : `(org_id = ${sqlLiteral(scope.orgId)})`
1042
1064
  : `(org_id IS NULL AND owner_email = ${ownerEmail})`;
1065
+ const startDateFilter = options.startDate
1066
+ ? ` AND event_date >= ${sqlLiteral(options.startDate)}`
1067
+ : "";
1043
1068
  const result = await runQuery(`
1044
1069
  WITH scoped_events AS (
1045
1070
  SELECT *
1046
1071
  FROM \`${physical.events}\`
1047
- WHERE ${tenantFilter} AND event_date <= ${today}
1072
+ WHERE ${tenantFilter}${startDateFilter} AND event_date <= ${today}
1048
1073
  )
1049
1074
  SELECT
1050
1075
  COUNT(*) AS event_count,
@@ -1121,26 +1146,92 @@ function serializeBackfillCursor(
1121
1146
  return JSON.stringify(cursor);
1122
1147
  }
1123
1148
 
1149
+ function boundedLookbackDays(value: number): number {
1150
+ if (
1151
+ !Number.isInteger(value) ||
1152
+ value < MIN_BACKFILL_LOOKBACK_DAYS ||
1153
+ value > MAX_BACKFILL_LOOKBACK_DAYS
1154
+ ) {
1155
+ throw new Error(
1156
+ `BigQuery backfill lookback must be an integer between ${MIN_BACKFILL_LOOKBACK_DAYS} and ${MAX_BACKFILL_LOOKBACK_DAYS} days`,
1157
+ );
1158
+ }
1159
+ return value;
1160
+ }
1161
+
1162
+ function configuredLookbackDays(requested: number | undefined): number {
1163
+ if (requested !== undefined) return boundedLookbackDays(requested);
1164
+ const raw = process.env[BACKFILL_LOOKBACK_DAYS_ENV]?.trim();
1165
+ if (!raw) return DEFAULT_BACKFILL_LOOKBACK_DAYS;
1166
+ const parsed = Number(raw);
1167
+ return Number.isInteger(parsed) &&
1168
+ parsed >= MIN_BACKFILL_LOOKBACK_DAYS &&
1169
+ parsed <= MAX_BACKFILL_LOOKBACK_DAYS
1170
+ ? parsed
1171
+ : DEFAULT_BACKFILL_LOOKBACK_DAYS;
1172
+ }
1173
+
1174
+ function configuredSkipEventNames(requested: string[] | undefined): string[] {
1175
+ const raw = requested
1176
+ ? requested
1177
+ : (
1178
+ process.env[BACKFILL_SKIP_EVENTS_ENV] ??
1179
+ DEFAULT_BACKFILL_SKIP_EVENTS.join(",")
1180
+ )
1181
+ .split(",")
1182
+ .map((name) => name.trim());
1183
+ const names = [...new Set(raw)].filter(Boolean);
1184
+ if (names.length > MAX_BACKFILL_SKIP_EVENTS) {
1185
+ throw new Error(
1186
+ `BigQuery backfill skips at most ${MAX_BACKFILL_SKIP_EVENTS} event names`,
1187
+ );
1188
+ }
1189
+ return names;
1190
+ }
1191
+
1192
+ function backfillLookbackStart(now: string, lookbackDays: number): string {
1193
+ const date = new Date(now);
1194
+ if (Number.isNaN(date.getTime())) {
1195
+ throw new Error(`Invalid BigQuery backfill clock value: ${now}`);
1196
+ }
1197
+ date.setUTCDate(date.getUTCDate() - lookbackDays);
1198
+ return date.toISOString();
1199
+ }
1200
+
1124
1201
  function backfillBranchSql(
1125
1202
  predicate: string,
1126
1203
  predicateArgs: string[],
1127
1204
  cursor: FirstPartyAnalyticsBackfillCursor,
1205
+ lookbackStart: string,
1206
+ skipEventNames: string[],
1128
1207
  ): {
1129
1208
  sql: string;
1130
- args: string[];
1209
+ args: unknown[];
1131
1210
  } {
1132
- const cursorSql = cursor.receivedAt
1133
- ? "(received_at > ? OR (received_at = ? AND id > ?))"
1134
- : "";
1135
- const cursorArgs = cursor.receivedAt
1136
- ? [cursor.receivedAt, cursor.receivedAt, cursor.id]
1137
- : [];
1211
+ const cursorSql = cursor.receivedAt ? "(received_at, id) > (?, ?)" : "";
1212
+ const cursorArgs = cursor.receivedAt ? [cursor.receivedAt, cursor.id] : [];
1213
+ const skipSql =
1214
+ skipEventNames.length === DEFAULT_BACKFILL_SKIP_EVENTS.length &&
1215
+ skipEventNames[0] === DEFAULT_BACKFILL_SKIP_EVENTS[0]
1216
+ ? "event_name IS DISTINCT FROM 'http.response'"
1217
+ : skipEventNames.length
1218
+ ? `(event_name IS NULL OR event_name NOT IN (${skipEventNames.map(() => "?").join(", ")}))`
1219
+ : "";
1220
+ const filters = ["received_at >= ?", skipSql].filter(Boolean);
1138
1221
  return {
1139
1222
  sql: `SELECT id, received_at
1140
1223
  FROM analytics_events
1141
- WHERE ${predicate}${cursorSql ? ` AND ${cursorSql}` : ""}
1224
+ WHERE ${predicate}
1225
+ AND ${filters.join("\n AND ")}${cursorSql ? `\n AND ${cursorSql}` : ""}
1142
1226
  ORDER BY received_at ASC, id ASC LIMIT ?`,
1143
- args: [...predicateArgs, ...cursorArgs],
1227
+ args: [
1228
+ ...predicateArgs,
1229
+ lookbackStart,
1230
+ ...(skipSql === "event_name IS DISTINCT FROM 'http.response'"
1231
+ ? []
1232
+ : skipEventNames),
1233
+ ...cursorArgs,
1234
+ ],
1144
1235
  };
1145
1236
  }
1146
1237
 
@@ -1191,6 +1282,7 @@ export async function backfillFirstPartyAnalyticsBatch(
1191
1282
  cursor: string | null,
1192
1283
  limit: number,
1193
1284
  configuredTable?: string | null,
1285
+ options?: FirstPartyAnalyticsBackfillOptions,
1194
1286
  ): Promise<FirstPartyAnalyticsBackfillBatch> {
1195
1287
  const boundedLimit = Math.min(
1196
1288
  Math.max(Math.floor(limit), 1),
@@ -1198,6 +1290,12 @@ export async function backfillFirstPartyAnalyticsBatch(
1198
1290
  );
1199
1291
  const db = getDbExec();
1200
1292
  const parsedCursor = parseBackfillCursor(cursor);
1293
+ const lookbackDays = configuredLookbackDays(options?.lookbackDays);
1294
+ const lookbackStart = backfillLookbackStart(
1295
+ options?.now?.() ?? new Date().toISOString(),
1296
+ lookbackDays,
1297
+ );
1298
+ const skipEventNames = configuredSkipEventNames(options?.skipEventNames);
1201
1299
  const branches = scope.orgId
1202
1300
  ? [
1203
1301
  { predicate: "org_id = ?", args: [scope.orgId] },
@@ -1218,6 +1316,8 @@ export async function backfillFirstPartyAnalyticsBatch(
1218
1316
  branch.predicate,
1219
1317
  branch.args,
1220
1318
  parsedCursor,
1319
+ lookbackStart,
1320
+ skipEventNames,
1221
1321
  );
1222
1322
  const result = await db.execute({
1223
1323
  sql: scoped.sql,
@@ -0,0 +1,140 @@
1
+ import { and, count, eq, gte, isNull, or } from "drizzle-orm";
2
+
3
+ import { getDb, schema } from "../db/index.js";
4
+
5
+ export interface FirstPartyAnalyticsPurgeScope {
6
+ userEmail: string;
7
+ orgId: string;
8
+ }
9
+
10
+ export interface FirstPartyAnalyticsPostgresPurgeCounts {
11
+ eventRows: number;
12
+ dailyRollupRows: number;
13
+ userDayRows: number;
14
+ }
15
+
16
+ export interface FirstPartyAnalyticsPurgeWindow {
17
+ startReceivedAt: string;
18
+ startEventDate: string;
19
+ }
20
+
21
+ function scopePredicate(
22
+ table: {
23
+ orgId: any;
24
+ ownerEmail: any;
25
+ },
26
+ scope: FirstPartyAnalyticsPurgeScope,
27
+ includeLegacyOwnerRows: boolean,
28
+ ) {
29
+ if (!includeLegacyOwnerRows) return eq(table.orgId, scope.orgId);
30
+ return or(
31
+ eq(table.orgId, scope.orgId),
32
+ and(isNull(table.orgId), eq(table.ownerEmail, scope.userEmail)),
33
+ );
34
+ }
35
+
36
+ function windowPredicate(
37
+ table: {
38
+ eventDate: any;
39
+ receivedAt?: any;
40
+ },
41
+ predicate: any,
42
+ window: FirstPartyAnalyticsPurgeWindow,
43
+ ) {
44
+ return table.receivedAt
45
+ ? and(predicate, gte(table.receivedAt, window.startReceivedAt))
46
+ : and(predicate, gte(table.eventDate, window.startEventDate));
47
+ }
48
+
49
+ async function countScopedRows(table: any, predicate: any): Promise<number> {
50
+ const [row] = await (getDb() as any)
51
+ .select({ count: count() })
52
+ .from(table)
53
+ .where(predicate);
54
+ const value = Number(row?.count ?? 0);
55
+ return Number.isFinite(value) ? value : 0;
56
+ }
57
+
58
+ export async function countFirstPartyAnalyticsPostgresRows(
59
+ scope: FirstPartyAnalyticsPurgeScope,
60
+ includeLegacyOwnerRows: boolean,
61
+ window: FirstPartyAnalyticsPurgeWindow,
62
+ ): Promise<FirstPartyAnalyticsPostgresPurgeCounts> {
63
+ const predicates = [
64
+ windowPredicate(
65
+ schema.analyticsEvents,
66
+ scopePredicate(schema.analyticsEvents, scope, includeLegacyOwnerRows),
67
+ window,
68
+ ),
69
+ windowPredicate(
70
+ schema.analyticsEventDailyRollups,
71
+ scopePredicate(
72
+ schema.analyticsEventDailyRollups,
73
+ scope,
74
+ includeLegacyOwnerRows,
75
+ ),
76
+ window,
77
+ ),
78
+ windowPredicate(
79
+ schema.analyticsUserDays,
80
+ scopePredicate(schema.analyticsUserDays, scope, includeLegacyOwnerRows),
81
+ window,
82
+ ),
83
+ ];
84
+ const [eventRows, dailyRollupRows, userDayRows] = await Promise.all([
85
+ countScopedRows(schema.analyticsEvents, predicates[0]),
86
+ countScopedRows(schema.analyticsEventDailyRollups, predicates[1]),
87
+ countScopedRows(schema.analyticsUserDays, predicates[2]),
88
+ ]);
89
+ return { eventRows, dailyRollupRows, userDayRows };
90
+ }
91
+
92
+ export async function purgeFirstPartyAnalyticsPostgresRows(
93
+ scope: FirstPartyAnalyticsPurgeScope,
94
+ includeLegacyOwnerRows: boolean,
95
+ window: FirstPartyAnalyticsPurgeWindow,
96
+ ): Promise<FirstPartyAnalyticsPostgresPurgeCounts> {
97
+ const counts = await countFirstPartyAnalyticsPostgresRows(
98
+ scope,
99
+ includeLegacyOwnerRows,
100
+ window,
101
+ );
102
+ await (getDb() as any).transaction(async (tx: any) => {
103
+ await tx
104
+ .delete(schema.analyticsEvents)
105
+ .where(
106
+ windowPredicate(
107
+ schema.analyticsEvents,
108
+ scopePredicate(schema.analyticsEvents, scope, includeLegacyOwnerRows),
109
+ window,
110
+ ),
111
+ );
112
+ await tx
113
+ .delete(schema.analyticsEventDailyRollups)
114
+ .where(
115
+ windowPredicate(
116
+ schema.analyticsEventDailyRollups,
117
+ scopePredicate(
118
+ schema.analyticsEventDailyRollups,
119
+ scope,
120
+ includeLegacyOwnerRows,
121
+ ),
122
+ window,
123
+ ),
124
+ );
125
+ await tx
126
+ .delete(schema.analyticsUserDays)
127
+ .where(
128
+ windowPredicate(
129
+ schema.analyticsUserDays,
130
+ scopePredicate(
131
+ schema.analyticsUserDays,
132
+ scope,
133
+ includeLegacyOwnerRows,
134
+ ),
135
+ window,
136
+ ),
137
+ );
138
+ });
139
+ return counts;
140
+ }
@@ -0,0 +1,207 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+
3
+ import { schema } from "../db/index.js";
4
+
5
+ export const FIRST_PARTY_POSTGRES_EVENT_VOLUME_LIMIT_ENV =
6
+ "ANALYTICS_FIRST_PARTY_POSTGRES_EVENT_VOLUME_LIMIT";
7
+ export const FIRST_PARTY_POSTGRES_EVENT_VOLUME_WINDOW_DAYS_ENV =
8
+ "ANALYTICS_FIRST_PARTY_POSTGRES_EVENT_VOLUME_WINDOW_DAYS";
9
+
10
+ export const DEFAULT_FIRST_PARTY_POSTGRES_EVENT_VOLUME_LIMIT = 1_000_000;
11
+ export const DEFAULT_FIRST_PARTY_POSTGRES_EVENT_VOLUME_WINDOW_DAYS = 30;
12
+
13
+ const MAX_EVENT_VOLUME_LIMIT = 100_000_000;
14
+ const MIN_VOLUME_WINDOW_DAYS = 1;
15
+ const MAX_VOLUME_WINDOW_DAYS = 90;
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ export interface FirstPartyPostgresEventVolumeConfig {
19
+ eventLimit: number;
20
+ windowDays: number;
21
+ }
22
+
23
+ export interface FirstPartyPostgresEventVolumeScope {
24
+ ownerEmail: string;
25
+ orgId: string | null;
26
+ receivedAt: string;
27
+ }
28
+
29
+ export interface FirstPartyPostgresEventVolumeReservation {
30
+ tenantKey: string;
31
+ windowStart: string;
32
+ eventCount: number;
33
+ eventLimit: number;
34
+ }
35
+
36
+ export class FirstPartyPostgresEventVolumeLimitError extends Error {
37
+ readonly statusCode = 429;
38
+
39
+ constructor(
40
+ readonly reservation: FirstPartyPostgresEventVolumeReservation,
41
+ requested: number,
42
+ ) {
43
+ super(
44
+ `Postgres first-party Analytics volume limit reached for this ${reservation.windowStart} window: ${reservation.eventCount}/${reservation.eventLimit} events reserved; connect your own analytics database or BigQuery before sending more events (requested ${requested}).`,
45
+ );
46
+ this.name = "FirstPartyPostgresEventVolumeLimitError";
47
+ }
48
+ }
49
+
50
+ function parseIntegerEnv(
51
+ environment: Record<string, string | undefined>,
52
+ key: string,
53
+ fallback: number,
54
+ minimum: number,
55
+ maximum: number,
56
+ ): number {
57
+ const raw = environment[key]?.trim();
58
+ if (!raw) return fallback;
59
+ if (!/^\d+$/.test(raw)) {
60
+ throw new Error(
61
+ `${key} must be an integer between ${minimum} and ${maximum}`,
62
+ );
63
+ }
64
+ const value = Number(raw);
65
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
66
+ throw new Error(
67
+ `${key} must be an integer between ${minimum} and ${maximum}`,
68
+ );
69
+ }
70
+ return value;
71
+ }
72
+
73
+ export function getFirstPartyPostgresEventVolumeConfig(
74
+ environment: Record<string, string | undefined> = process.env,
75
+ ): FirstPartyPostgresEventVolumeConfig {
76
+ return {
77
+ eventLimit: parseIntegerEnv(
78
+ environment,
79
+ FIRST_PARTY_POSTGRES_EVENT_VOLUME_LIMIT_ENV,
80
+ DEFAULT_FIRST_PARTY_POSTGRES_EVENT_VOLUME_LIMIT,
81
+ 1,
82
+ MAX_EVENT_VOLUME_LIMIT,
83
+ ),
84
+ windowDays: parseIntegerEnv(
85
+ environment,
86
+ FIRST_PARTY_POSTGRES_EVENT_VOLUME_WINDOW_DAYS_ENV,
87
+ DEFAULT_FIRST_PARTY_POSTGRES_EVENT_VOLUME_WINDOW_DAYS,
88
+ MIN_VOLUME_WINDOW_DAYS,
89
+ MAX_VOLUME_WINDOW_DAYS,
90
+ ),
91
+ };
92
+ }
93
+
94
+ export function firstPartyPostgresEventVolumeWindowStart(
95
+ receivedAt: string,
96
+ windowDays: number,
97
+ ): string {
98
+ if (!Number.isInteger(windowDays) || windowDays < 1) {
99
+ throw new Error(
100
+ "First-party Analytics volume window must be a positive integer",
101
+ );
102
+ }
103
+ const receivedAtMs = Date.parse(receivedAt);
104
+ if (!Number.isFinite(receivedAtMs)) {
105
+ throw new Error(
106
+ "First-party Analytics volume receivedAt must be an ISO timestamp",
107
+ );
108
+ }
109
+ const windowMs = windowDays * DAY_MS;
110
+ return new Date(Math.floor(receivedAtMs / windowMs) * windowMs).toISOString();
111
+ }
112
+
113
+ function tenantKey(
114
+ scope: Pick<FirstPartyPostgresEventVolumeScope, "ownerEmail" | "orgId">,
115
+ ): string {
116
+ return scope.orgId ? `org:${scope.orgId}` : `user:${scope.ownerEmail}`;
117
+ }
118
+
119
+ function reservationId(tenant: string, windowStart: string): string {
120
+ return `aevu_${encodeURIComponent(tenant)}_${encodeURIComponent(windowStart)}`;
121
+ }
122
+
123
+ export async function reserveFirstPartyPostgresEventVolume(
124
+ transaction: any,
125
+ scope: FirstPartyPostgresEventVolumeScope,
126
+ requested: number,
127
+ ): Promise<FirstPartyPostgresEventVolumeReservation> {
128
+ if (!Number.isInteger(requested) || requested < 1) {
129
+ throw new Error(
130
+ "First-party Analytics volume reservation must be positive",
131
+ );
132
+ }
133
+ const config = getFirstPartyPostgresEventVolumeConfig();
134
+ const scopedTenantKey = tenantKey(scope);
135
+ const windowStart = firstPartyPostgresEventVolumeWindowStart(
136
+ scope.receivedAt,
137
+ config.windowDays,
138
+ );
139
+
140
+ await transaction
141
+ .insert(schema.analyticsEventVolumeUsage)
142
+ .values({
143
+ id: reservationId(scopedTenantKey, windowStart),
144
+ tenantKey: scopedTenantKey,
145
+ ownerEmail: scope.ownerEmail,
146
+ orgId: scope.orgId,
147
+ windowStart,
148
+ eventCount: 0,
149
+ eventLimit: config.eventLimit,
150
+ updatedAt: scope.receivedAt,
151
+ })
152
+ .onConflictDoNothing({
153
+ target: [
154
+ schema.analyticsEventVolumeUsage.tenantKey,
155
+ schema.analyticsEventVolumeUsage.windowStart,
156
+ ],
157
+ });
158
+
159
+ const [updated] = await transaction
160
+ .update(schema.analyticsEventVolumeUsage)
161
+ .set({
162
+ eventCount: sql`${schema.analyticsEventVolumeUsage.eventCount} + ${requested}`,
163
+ eventLimit: config.eventLimit,
164
+ updatedAt: scope.receivedAt,
165
+ })
166
+ .where(
167
+ and(
168
+ eq(schema.analyticsEventVolumeUsage.tenantKey, scopedTenantKey),
169
+ eq(schema.analyticsEventVolumeUsage.windowStart, windowStart),
170
+ sql`${schema.analyticsEventVolumeUsage.eventCount} + ${requested} <= ${config.eventLimit}`,
171
+ ),
172
+ )
173
+ .returning({
174
+ eventCount: schema.analyticsEventVolumeUsage.eventCount,
175
+ eventLimit: schema.analyticsEventVolumeUsage.eventLimit,
176
+ });
177
+
178
+ if (!updated) {
179
+ const [current] = await transaction
180
+ .select({
181
+ eventCount: schema.analyticsEventVolumeUsage.eventCount,
182
+ eventLimit: schema.analyticsEventVolumeUsage.eventLimit,
183
+ })
184
+ .from(schema.analyticsEventVolumeUsage)
185
+ .where(
186
+ and(
187
+ eq(schema.analyticsEventVolumeUsage.tenantKey, scopedTenantKey),
188
+ eq(schema.analyticsEventVolumeUsage.windowStart, windowStart),
189
+ ),
190
+ )
191
+ .limit(1);
192
+ const reservation = {
193
+ tenantKey: scopedTenantKey,
194
+ windowStart,
195
+ eventCount: Number(current?.eventCount ?? 0),
196
+ eventLimit: Number(current?.eventLimit ?? config.eventLimit),
197
+ };
198
+ throw new FirstPartyPostgresEventVolumeLimitError(reservation, requested);
199
+ }
200
+
201
+ return {
202
+ tenantKey: scopedTenantKey,
203
+ windowStart,
204
+ eventCount: Number(updated.eventCount),
205
+ eventLimit: Number(updated.eventLimit),
206
+ };
207
+ }
@@ -25,6 +25,7 @@ import {
25
25
  recordFirstPartyAnalyticsQueryPressure,
26
26
  } from "./first-party-analytics-health.js";
27
27
  import { upsertFirstPartyAnalyticsRollups } from "./first-party-analytics-rollups.js";
28
+ import { reserveFirstPartyPostgresEventVolume } from "./first-party-analytics-volume.js";
28
29
 
29
30
  export interface AnalyticsScope {
30
31
  userEmail: string;
@@ -578,6 +579,7 @@ export async function recordAnalyticsEvents(
578
579
  orgId: key.orgId ?? null,
579
580
  });
580
581
 
582
+ let bigQueryInsertError: unknown = null;
581
583
  if (rows.length && (backend.sink === "dual" || backend.sink === "bigquery")) {
582
584
  try {
583
585
  await runWithRequestContext(
@@ -588,21 +590,47 @@ export async function recordAnalyticsEvents(
588
590
  () => insertFirstPartyAnalyticsRows(rows, backend.table),
589
591
  );
590
592
  } catch (error) {
591
- if (backend.sink === "bigquery") throw error;
593
+ if (backend.sink === "bigquery") {
594
+ // Keep SQL-only exception issues, public-key metadata, and session
595
+ // replay links durable even when the warehouse is temporarily down.
596
+ // The request still fails below so callers do not mistake a warehouse
597
+ // outage for a successful BigQuery write.
598
+ bigQueryInsertError = error;
599
+ }
592
600
  // Dual-write mode keeps Postgres as the recoverable source until the
593
601
  // backfill has completed. A BigQuery outage must not lose live events.
594
- console.error(
595
- "[first-party-analytics] BigQuery dual-write failed; retaining Postgres event:",
596
- error,
597
- );
602
+ if (backend.sink === "dual") {
603
+ console.error(
604
+ "[first-party-analytics] BigQuery dual-write failed; retaining Postgres event:",
605
+ error,
606
+ );
607
+ }
598
608
  }
599
609
  }
600
610
 
611
+ let postgresInsertError: unknown = null;
601
612
  if (rows.length && backend.sink !== "bigquery") {
602
- await db.transaction(async (tx: any) => {
603
- await tx.insert(schema.analyticsEvents).values(rows);
604
- await upsertFirstPartyAnalyticsRollups(rows, tx);
605
- });
613
+ try {
614
+ await db.transaction(async (tx: any) => {
615
+ if (backend.sink === "postgres" || backend.sink === "dual") {
616
+ await reserveFirstPartyPostgresEventVolume(
617
+ tx,
618
+ {
619
+ ownerEmail: key.ownerEmail,
620
+ orgId: key.orgId ?? null,
621
+ receivedAt,
622
+ },
623
+ rows.length,
624
+ );
625
+ }
626
+ await tx.insert(schema.analyticsEvents).values(rows);
627
+ await upsertFirstPartyAnalyticsRollups(rows, tx);
628
+ });
629
+ } catch (error) {
630
+ // Preserve SQL-only exception issues and public-key metadata below even
631
+ // when a Postgres volume reservation or insert rejects the batch.
632
+ postgresInsertError = error;
633
+ }
606
634
  }
607
635
  if (rows.length) {
608
636
  await touchPublicKeyLastUsedAt(key.id, receivedAt);
@@ -627,6 +655,9 @@ export async function recordAnalyticsEvents(
627
655
  }
628
656
  }
629
657
 
658
+ if (bigQueryInsertError) throw bigQueryInsertError;
659
+ if (postgresInsertError) throw postgresInsertError;
660
+
630
661
  return { accepted: rows.length, keyId: key.id };
631
662
  }
632
663