@agent-native/core 0.141.4 → 0.141.6

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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +13 -6
  3. package/corpus/templates/analytics/actions/get-first-party-analytics-health.ts +15 -1
  4. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +2 -2
  5. package/corpus/templates/analytics/app/i18n-data.ts +328 -0
  6. package/corpus/templates/analytics/app/pages/DataSources.tsx +66 -30
  7. package/corpus/templates/analytics/changelog/2026-08-05-historical-analytics-rollups-now-repair-automatically-after-.md +6 -0
  8. package/corpus/templates/analytics/netlify.toml +4 -0
  9. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
  10. package/corpus/templates/analytics/server/jobs/analytics-rollup-backfill.ts +342 -0
  11. package/corpus/templates/analytics/server/lib/analytics-rollup-lock.ts +8 -0
  12. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +11 -29
  13. package/corpus/templates/analytics/server/lib/dashboards-store.ts +55 -0
  14. package/corpus/templates/analytics/server/lib/first-party-analytics-health.ts +117 -31
  15. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +15 -9
  16. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +5 -16
  17. package/corpus/templates/analytics/server/plugins/analytics-rollup-backfill-jobs.ts +61 -0
  18. package/corpus/templates/analytics/server/plugins/db.ts +58 -187
  19. package/corpus/templates/analytics/server/routes/api/analytics-rollups/backfill.post.ts +53 -0
  20. package/corpus/templates/clips/changelog/2026-08-05-clips-overlays-now-use-clean-surfaces-without-clipped-shadow.md +6 -0
  21. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -2
  22. package/corpus/templates/clips/desktop/src/styles.css +3 -54
  23. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +3 -3
  24. package/corpus/templates/design/actions/get-design-system.ts +3 -1
  25. package/corpus/templates/design/changelog/2026-08-05-missing-design-systems-now-return-a-clear-not-found-response.md +6 -0
  26. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +2 -2
  27. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -1
  28. package/corpus/templates/slides/app/i18n/en-US.ts +5 -5
  29. package/corpus/templates/slides/changelog/2026-08-05-new-presentation-prompts-now-clearly-describe-presentation-g.md +6 -0
  30. package/dist/client/AssistantChat.js +1 -1
  31. package/dist/client/chat/run-recovery.d.ts +2 -1
  32. package/dist/client/chat/run-recovery.js +10 -3
  33. package/dist/client/error-format.d.ts +1 -0
  34. package/dist/client/error-format.js +4 -2
  35. package/dist/collab/routes.d.ts +1 -1
  36. package/dist/notifications/routes.d.ts +1 -1
  37. package/dist/resources/handlers.d.ts +1 -1
  38. package/dist/secrets/routes.d.ts +3 -3
  39. package/docs/AGENTS.md +1 -1
  40. package/docs/content/actions.mdx +1 -1
  41. package/docs/content/agent-surfaces.mdx +1 -1
  42. package/docs/content/agents.mdx +9 -0
  43. package/docs/content/database.mdx +2 -2
  44. package/docs/content/drop-in-agent.mdx +1 -1
  45. package/docs/content/notifications.mdx +1 -1
  46. package/docs/content/routing.mdx +1 -1
  47. package/docs/content/server-database.mdx +437 -0
  48. package/docs/content/server-middleware.mdx +151 -0
  49. package/docs/content/server-overview.mdx +234 -0
  50. package/docs/content/server-plugins.mdx +147 -0
  51. package/docs/content/server-routes.mdx +194 -0
  52. package/package.json +1 -1
  53. package/corpus/templates/analytics/changelog/2026-08-05-historical-analytics-rollups-stay-complete-and-atomic.md +0 -6
  54. package/corpus/templates/analytics/changelog/2026-08-05-historical-rollups-stay-complete-while-live-events-arrive.md +0 -6
  55. package/docs/content/server.mdx +0 -469
@@ -1,6 +1,10 @@
1
1
  import type { SqlDashboardConfig } from "../../app/pages/adhoc/sql-dashboard/types";
2
2
  import { loadDashboardSeed } from "./dashboard-seeds";
3
- import { listDashboards, type DashboardRecord } from "./dashboards-store";
3
+ import {
4
+ listDashboardSummaries,
5
+ type DashboardRecord,
6
+ type DashboardSummaryRecord,
7
+ } from "./dashboards-store";
4
8
  import {
5
9
  FIRST_PARTY_DASHBOARD_ID,
6
10
  repairFirstPartyObservedRetentionPanels,
@@ -1021,33 +1025,13 @@ export function cloneDashboardConfig(
1021
1025
  return JSON.parse(JSON.stringify(entry.buildConfig())) as SqlDashboardConfig;
1022
1026
  }
1023
1027
 
1024
- function templateIdFromConfig(config: Record<string, unknown>): string | null {
1025
- const catalog = config.catalog;
1026
- if (!catalog || typeof catalog !== "object" || Array.isArray(catalog)) {
1027
- return null;
1028
- }
1029
- const templateId = (catalog as Record<string, unknown>).templateId;
1030
- return typeof templateId === "string" && templateId ? templateId : null;
1031
- }
1032
-
1033
- function demoIdFromConfig(config: Record<string, unknown>): string | null {
1034
- const demo = config.demo;
1035
- if (!demo || typeof demo !== "object" || Array.isArray(demo)) {
1036
- return null;
1037
- }
1038
- const demoId = (demo as Record<string, unknown>).id;
1039
- return typeof demoId === "string" && demoId ? demoId : null;
1040
- }
1041
-
1042
1028
  function installedDashboardForTemplate(
1043
- row: DashboardRecord,
1029
+ row: DashboardSummaryRecord,
1044
1030
  entry: DashboardCatalogEntry,
1045
1031
  ): boolean {
1046
- const templateId = templateIdFromConfig(row.config);
1047
- const demoId = demoIdFromConfig(row.config);
1048
1032
  return (
1049
- templateId === entry.id ||
1050
- demoId === entry.id ||
1033
+ row.catalogTemplateId === entry.id ||
1034
+ row.demoId === entry.id ||
1051
1035
  row.id === entry.defaultDashboardId
1052
1036
  );
1053
1037
  }
@@ -1055,10 +1039,11 @@ function installedDashboardForTemplate(
1055
1039
  export async function listDashboardCatalog(
1056
1040
  ctx: AccessCtx,
1057
1041
  ): Promise<DashboardCatalogTemplate[]> {
1058
- const dashboards = await listDashboards(ctx, {
1042
+ const dashboards = await listDashboardSummaries(ctx, {
1059
1043
  kind: "sql",
1060
1044
  archived: "all",
1061
1045
  hidden: "all",
1046
+ includeCatalogMetadata: true,
1062
1047
  });
1063
1048
 
1064
1049
  return dashboardCatalogEntries
@@ -1068,10 +1053,7 @@ export async function listDashboardCatalog(
1068
1053
  .filter((row) => installedDashboardForTemplate(row, entry))
1069
1054
  .map((row) => ({
1070
1055
  id: row.id,
1071
- name:
1072
- typeof row.config.name === "string" && row.config.name.trim()
1073
- ? row.config.name
1074
- : row.title,
1056
+ name: row.configName?.trim() || row.name,
1075
1057
  visibility: row.visibility,
1076
1058
  updatedAt: row.updatedAt,
1077
1059
  archivedAt: row.archivedAt,
@@ -62,6 +62,9 @@ export interface DashboardSummaryRecord {
62
62
  id: string;
63
63
  kind: DashboardKind;
64
64
  name: string;
65
+ configName: string | null;
66
+ catalogTemplateId: string | null;
67
+ demoId: string | null;
65
68
  parentId: string | null;
66
69
  ownerEmail: string;
67
70
  orgId: string | null;
@@ -287,6 +290,24 @@ function configFromSettings(data: Record<string, unknown>): {
287
290
  return { title, config: data };
288
291
  }
289
292
 
293
+ function stringProperty(value: unknown, key: string): string | null {
294
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
295
+ const property = (value as Record<string, unknown>)[key];
296
+ return typeof property === "string" ? property : null;
297
+ }
298
+
299
+ function catalogMetadataFromConfig(config: Record<string, unknown>): {
300
+ configName: string | null;
301
+ catalogTemplateId: string | null;
302
+ demoId: string | null;
303
+ } {
304
+ return {
305
+ configName: typeof config.name === "string" ? config.name : null,
306
+ catalogTemplateId: stringProperty(config.catalog, "templateId"),
307
+ demoId: stringProperty(config.demo, "id"),
308
+ };
309
+ }
310
+
290
311
  async function migrateDashboardFromSettings(
291
312
  id: string,
292
313
  kind: DashboardKind,
@@ -510,11 +531,13 @@ export async function listDashboardSummaries(
510
531
  kind?: DashboardKind;
511
532
  archived?: DashboardArchiveFilter;
512
533
  hidden?: DashboardHiddenFilter;
534
+ includeCatalogMetadata?: boolean;
513
535
  },
514
536
  ): Promise<DashboardSummaryRecord[]> {
515
537
  const db = getDb() as any;
516
538
  const archived = filter?.archived ?? "active";
517
539
  const hidden = filter?.hidden ?? "visible";
540
+ const includeCatalogMetadata = filter?.includeCatalogMetadata === true;
518
541
  const conditions: any[] = [
519
542
  accessFilter(schema.dashboards, schema.dashboardShares, {
520
543
  userEmail: ctx.email,
@@ -535,11 +558,31 @@ export async function listDashboardSummaries(
535
558
  : sql<
536
559
  string | null
537
560
  >`json_extract(${schema.dashboards.config}, '$.parentId')`;
561
+ const configName = isPostgres()
562
+ ? sql<string | null>`(${schema.dashboards.config}::jsonb ->> 'name')`
563
+ : sql<string | null>`json_extract(${schema.dashboards.config}, '$.name')`;
564
+ const catalogTemplateId = isPostgres()
565
+ ? sql<
566
+ string | null
567
+ >`(${schema.dashboards.config}::jsonb -> 'catalog' ->> 'templateId')`
568
+ : sql<
569
+ string | null
570
+ >`json_extract(${schema.dashboards.config}, '$.catalog.templateId')`;
571
+ const demoId = isPostgres()
572
+ ? sql<
573
+ string | null
574
+ >`(${schema.dashboards.config}::jsonb -> 'demo' ->> 'id')`
575
+ : sql<
576
+ string | null
577
+ >`json_extract(${schema.dashboards.config}, '$.demo.id')`;
538
578
  const rows = await db
539
579
  .select({
540
580
  id: schema.dashboards.id,
541
581
  kind: schema.dashboards.kind,
542
582
  name: schema.dashboards.title,
583
+ ...(includeCatalogMetadata
584
+ ? { configName, catalogTemplateId, demoId }
585
+ : {}),
543
586
  parentId,
544
587
  ownerEmail: schema.dashboards.ownerEmail,
545
588
  orgId: schema.dashboards.orgId,
@@ -554,6 +597,10 @@ export async function listDashboardSummaries(
554
597
  .where(where);
555
598
  const out: DashboardSummaryRecord[] = rows.map((row: any) => ({
556
599
  ...row,
600
+ configName: typeof row.configName === "string" ? row.configName : null,
601
+ catalogTemplateId:
602
+ typeof row.catalogTemplateId === "string" ? row.catalogTemplateId : null,
603
+ demoId: typeof row.demoId === "string" ? row.demoId : null,
557
604
  parentId: typeof row.parentId === "string" ? row.parentId : null,
558
605
  orgId: row.orgId ?? null,
559
606
  archivedAt: row.archivedAt ?? null,
@@ -590,12 +637,20 @@ export async function listDashboardSummaries(
590
637
  seen.add(id);
591
638
  const config = value as Record<string, unknown>;
592
639
  const { title } = configFromSettings(config);
640
+ const catalogMetadata = includeCatalogMetadata
641
+ ? catalogMetadataFromConfig(config)
642
+ : {
643
+ configName: null,
644
+ catalogTemplateId: null,
645
+ demoId: null,
646
+ };
593
647
  const createdAt =
594
648
  typeof config.createdAt === "string" ? config.createdAt : nowIso();
595
649
  out.push({
596
650
  id,
597
651
  kind,
598
652
  name: title,
653
+ ...catalogMetadata,
599
654
  parentId: typeof config.parentId === "string" ? config.parentId : null,
600
655
  ownerEmail: ctx.email,
601
656
  orgId,
@@ -13,11 +13,33 @@ export const FIRST_PARTY_ANALYTICS_PRESSURE_THRESHOLDS = {
13
13
  recommendMaxQueryMs: 30_000,
14
14
  } as const;
15
15
 
16
- const BIGQUERY_SETUP_LINK = buildDeepLink({
17
- app: "analytics",
18
- view: "data-sources",
19
- to: "/data-sources?source=bigquery",
20
- });
16
+ const EXTERNAL_BACKEND_DEFINITIONS = [
17
+ {
18
+ id: "bigquery",
19
+ label: "BigQuery",
20
+ role: "warehouse",
21
+ requiredKeys: [
22
+ "GOOGLE_APPLICATION_CREDENTIALS_JSON",
23
+ "BIGQUERY_PROJECT_ID",
24
+ ],
25
+ setupLink: buildDeepLink({
26
+ app: "analytics",
27
+ view: "data-sources",
28
+ to: "/data-sources?source=bigquery",
29
+ }),
30
+ },
31
+ {
32
+ id: "amplitude",
33
+ label: "Amplitude",
34
+ role: "product-analytics",
35
+ requiredKeys: ["AMPLITUDE_API_KEY", "AMPLITUDE_SECRET_KEY"],
36
+ setupLink: buildDeepLink({
37
+ app: "analytics",
38
+ view: "data-sources",
39
+ to: "/data-sources?source=amplitude",
40
+ }),
41
+ },
42
+ ] as const;
21
43
 
22
44
  export type FirstPartyAnalyticsQueryClass =
23
45
  | "raw-events"
@@ -39,9 +61,29 @@ export type FirstPartyAnalyticsRecommendation =
39
61
  | "connect_bigquery"
40
62
  | "use_bigquery";
41
63
 
64
+ export type FirstPartyAnalyticsExternalBackendRecommendation =
65
+ | "none"
66
+ | "connect"
67
+ | "use"
68
+ | "unknown";
69
+
70
+ export type FirstPartyAnalyticsBackendId =
71
+ (typeof EXTERNAL_BACKEND_DEFINITIONS)[number]["id"];
72
+
73
+ export interface FirstPartyAnalyticsBackendStatus {
74
+ id: FirstPartyAnalyticsBackendId;
75
+ label: string;
76
+ role: (typeof EXTERNAL_BACKEND_DEFINITIONS)[number]["role"];
77
+ configured: boolean | null;
78
+ missingRequiredKeys: string[];
79
+ setupLink: string;
80
+ }
81
+
42
82
  export interface FirstPartyAnalyticsHealth {
43
83
  status: FirstPartyAnalyticsHealthStatus;
44
84
  recommendation: FirstPartyAnalyticsRecommendation;
85
+ externalBackendRecommendation: FirstPartyAnalyticsExternalBackendRecommendation;
86
+ externalBackends: FirstPartyAnalyticsBackendStatus[];
45
87
  reasons: Array<"event_volume" | "slow_queries" | "query_timeout">;
46
88
  observedAt: string;
47
89
  metrics: {
@@ -56,11 +98,8 @@ export interface FirstPartyAnalyticsHealth {
56
98
  maxQueryDurationMs24h: number;
57
99
  };
58
100
  thresholds: typeof FIRST_PARTY_ANALYTICS_PRESSURE_THRESHOLDS;
59
- bigQuery: {
60
- configured: boolean | null;
61
- missingRequiredKeys: string[];
62
- setupLink: string;
63
- };
101
+ /** Kept for clients that still read the original BigQuery-only field. */
102
+ bigQuery: FirstPartyAnalyticsBackendStatus;
64
103
  }
65
104
 
66
105
  interface QueryPressureEvent {
@@ -199,18 +238,13 @@ function dateSpanDays(
199
238
  return Math.floor((last - first) / 86_400_000) + 1;
200
239
  }
201
240
 
202
- async function bigQueryStatus(scope: AnalyticsScope): Promise<{
203
- configured: boolean | null;
204
- missingRequiredKeys: string[];
205
- setupLink: string;
206
- }> {
207
- const requiredKeys = [
208
- "GOOGLE_APPLICATION_CREDENTIALS_JSON",
209
- "BIGQUERY_PROJECT_ID",
210
- ];
241
+ async function externalBackendStatus(
242
+ scope: AnalyticsScope,
243
+ definition: (typeof EXTERNAL_BACKEND_DEFINITIONS)[number],
244
+ ): Promise<FirstPartyAnalyticsBackendStatus> {
211
245
  try {
212
246
  const configured = await Promise.all(
213
- requiredKeys.map((key) =>
247
+ definition.requiredKeys.map((key) =>
214
248
  hasCredential(key, {
215
249
  userEmail: scope.userEmail,
216
250
  orgId: scope.orgId,
@@ -218,22 +252,41 @@ async function bigQueryStatus(scope: AnalyticsScope): Promise<{
218
252
  ),
219
253
  );
220
254
  return {
255
+ id: definition.id,
256
+ label: definition.label,
257
+ role: definition.role,
221
258
  configured: configured.every(Boolean),
222
- missingRequiredKeys: requiredKeys.filter(
259
+ missingRequiredKeys: definition.requiredKeys.filter(
223
260
  (_, index) => !configured[index],
224
261
  ),
225
- setupLink: BIGQUERY_SETUP_LINK,
262
+ setupLink: definition.setupLink,
226
263
  };
227
264
  } catch (error) {
228
- console.warn("[first-party-analytics] BigQuery status unavailable:", error);
265
+ console.warn(
266
+ `[first-party-analytics] ${definition.label} status unavailable:`,
267
+ error,
268
+ );
229
269
  return {
270
+ id: definition.id,
271
+ label: definition.label,
272
+ role: definition.role,
230
273
  configured: null,
231
274
  missingRequiredKeys: [],
232
- setupLink: BIGQUERY_SETUP_LINK,
275
+ setupLink: definition.setupLink,
233
276
  };
234
277
  }
235
278
  }
236
279
 
280
+ async function externalBackendStatuses(
281
+ scope: AnalyticsScope,
282
+ ): Promise<FirstPartyAnalyticsBackendStatus[]> {
283
+ return Promise.all(
284
+ EXTERNAL_BACKEND_DEFINITIONS.map((definition) =>
285
+ externalBackendStatus(scope, definition),
286
+ ),
287
+ );
288
+ }
289
+
237
290
  function recommendationFor(
238
291
  status: FirstPartyAnalyticsHealthStatus,
239
292
  configured: boolean | null,
@@ -242,6 +295,18 @@ function recommendationFor(
242
295
  return configured === true ? "use_bigquery" : "connect_bigquery";
243
296
  }
244
297
 
298
+ function externalBackendRecommendationFor(
299
+ status: FirstPartyAnalyticsHealthStatus,
300
+ backends: FirstPartyAnalyticsBackendStatus[],
301
+ ): FirstPartyAnalyticsExternalBackendRecommendation {
302
+ if (status !== "recommend_bigquery") return "none";
303
+ if (backends.some((backend) => backend.configured === true)) return "use";
304
+ if (backends.every((backend) => backend.configured === false)) {
305
+ return "connect";
306
+ }
307
+ return "unknown";
308
+ }
309
+
245
310
  export async function getFirstPartyAnalyticsHealth(
246
311
  scope: AnalyticsScope,
247
312
  ): Promise<FirstPartyAnalyticsHealth> {
@@ -256,7 +321,7 @@ export async function getFirstPartyAnalyticsHealth(
256
321
  const pressure = schema.analyticsQueryPressureDaily;
257
322
  const db = getDb() as any;
258
323
 
259
- const [rollupRows, pressureRows, bigQuery] = await Promise.all([
324
+ const [rollupRows, pressureRows, externalBackends] = await Promise.all([
260
325
  db
261
326
  .select({
262
327
  eventCount: sql<number>`coalesce(sum(${rollups.eventCount}), 0)`,
@@ -284,9 +349,14 @@ export async function getFirstPartyAnalyticsHealth(
284
349
  lte(pressure.lastSeenAt, nowTimestamp),
285
350
  ),
286
351
  ),
287
- bigQueryStatus(scope),
352
+ externalBackendStatuses(scope),
288
353
  ]);
289
354
 
355
+ const bigQuery = externalBackends.find(
356
+ (backend) => backend.id === "bigquery",
357
+ );
358
+ if (!bigQuery) throw new Error("BigQuery backend status is missing");
359
+
290
360
  const rollup = (rollupRows[0] ?? {}) as Record<string, unknown>;
291
361
  const eventCount = finiteNumber(rollup.eventCount);
292
362
  const dailyRollupRows = finiteNumber(rollup.dailyRollupRows);
@@ -348,6 +418,11 @@ export async function getFirstPartyAnalyticsHealth(
348
418
  return {
349
419
  status,
350
420
  recommendation: recommendationFor(status, bigQuery.configured),
421
+ externalBackendRecommendation: externalBackendRecommendationFor(
422
+ status,
423
+ externalBackends,
424
+ ),
425
+ externalBackends,
351
426
  reasons,
352
427
  observedAt: nowIso(),
353
428
  metrics: {
@@ -367,9 +442,24 @@ export async function getFirstPartyAnalyticsHealth(
367
442
  }
368
443
 
369
444
  export function unavailableFirstPartyAnalyticsHealth(): FirstPartyAnalyticsHealth {
445
+ const externalBackends = EXTERNAL_BACKEND_DEFINITIONS.map((definition) => ({
446
+ id: definition.id,
447
+ label: definition.label,
448
+ role: definition.role,
449
+ configured: null,
450
+ missingRequiredKeys: [],
451
+ setupLink: definition.setupLink,
452
+ }));
453
+ const bigQuery = externalBackends.find(
454
+ (backend) => backend.id === "bigquery",
455
+ );
456
+ if (!bigQuery) throw new Error("BigQuery backend status is missing");
457
+
370
458
  return {
371
459
  status: "unavailable",
372
460
  recommendation: "none",
461
+ externalBackendRecommendation: "unknown",
462
+ externalBackends,
373
463
  reasons: [],
374
464
  observedAt: nowIso(),
375
465
  metrics: {
@@ -384,10 +474,6 @@ export function unavailableFirstPartyAnalyticsHealth(): FirstPartyAnalyticsHealt
384
474
  maxQueryDurationMs24h: 0,
385
475
  },
386
476
  thresholds: FIRST_PARTY_ANALYTICS_PRESSURE_THRESHOLDS,
387
- bigQuery: {
388
- configured: null,
389
- missingRequiredKeys: [],
390
- setupLink: BIGQUERY_SETUP_LINK,
391
- },
477
+ bigQuery,
392
478
  };
393
479
  }
@@ -1,6 +1,11 @@
1
+ import { isPostgres } from "@agent-native/core/db";
1
2
  import { sql } from "@agent-native/core/db/schema";
2
3
 
3
4
  import { getDb, schema } from "../db/index.js";
5
+ import {
6
+ FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY,
7
+ FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_SQL,
8
+ } from "./analytics-rollup-lock.js";
4
9
 
5
10
  /**
6
11
  * The normalized subset emitted by first-party analytics ingest. Keeping this
@@ -23,15 +28,6 @@ export interface FirstPartyAnalyticsRollupResult {
23
28
  userDayCount: number;
24
29
  }
25
30
 
26
- /**
27
- * Shared with the historical backfill so live ingest and migration work hold
28
- * the same transaction-scoped Postgres lock while they update rollups.
29
- */
30
- export const FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY =
31
- "agent-native:analytics-rollup-backfill";
32
- export const FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_SQL =
33
- "SELECT pg_advisory_xact_lock(hashtextextended(?, 0::bigint))";
34
-
35
31
  interface DailyRollupRow {
36
32
  id: string;
37
33
  tenantKey: string;
@@ -150,6 +146,16 @@ export async function upsertFirstPartyAnalyticsRollups(
150
146
  }
151
147
 
152
148
  const writeRollups = async (tx: any) => {
149
+ if (isPostgres()) {
150
+ // The historical backfill takes this lock before its raw-event
151
+ // snapshot. Holding it through ingest's rollup increment prevents the
152
+ // backfill's monotonic upsert from losing a concurrently committed
153
+ // event.
154
+ await tx.execute({
155
+ sql: FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_SQL,
156
+ args: [FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY],
157
+ });
158
+ }
153
159
  const dailyRows = [...dailyRollups.values()];
154
160
  await tx
155
161
  .insert(schema.analyticsEventDailyRollups)
@@ -1,4 +1,4 @@
1
- import { getDbExec, isPostgres } from "@agent-native/core/db";
1
+ import { getDbExec } from "@agent-native/core/db";
2
2
  import { and, eq, isNull, or } from "drizzle-orm";
3
3
 
4
4
  import { FIRST_PARTY_ANALYTICS_QUERY_TIMEOUT_MS } from "../../shared/dashboard-report-timeouts.js";
@@ -17,11 +17,7 @@ import {
17
17
  queryOutcomeFromError,
18
18
  recordFirstPartyAnalyticsQueryPressure,
19
19
  } from "./first-party-analytics-health.js";
20
- import {
21
- FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY,
22
- FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_SQL,
23
- upsertFirstPartyAnalyticsRollups,
24
- } from "./first-party-analytics-rollups.js";
20
+ import { upsertFirstPartyAnalyticsRollups } from "./first-party-analytics-rollups.js";
25
21
 
26
22
  export interface AnalyticsScope {
27
23
  userEmail: string;
@@ -510,16 +506,9 @@ export async function recordAnalyticsEvents(
510
506
 
511
507
  if (rows.length) {
512
508
  await db.transaction(async (tx: any) => {
513
- if (isPostgres()) {
514
- // The historical backfill takes this lock before its raw-event
515
- // snapshot. Holding it through ingest's rollup increment prevents the
516
- // backfill's monotonic upsert from losing a concurrently committed
517
- // event.
518
- await tx.execute({
519
- sql: FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_SQL,
520
- args: [FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY],
521
- });
522
- }
509
+ // Raw events and incremental rollups share this transaction. The
510
+ // historical boot backfill is disabled, so no global cross-request lock
511
+ // is needed to coordinate a second writer.
523
512
  await tx.insert(schema.analyticsEvents).values(rows);
524
513
  await tx
525
514
  .update(schema.analyticsPublicKeys)
@@ -0,0 +1,61 @@
1
+ import { runAnalyticsRollupBackfillOnce } from "../jobs/analytics-rollup-backfill";
2
+
3
+ const DEFAULT_INTERVAL_MS = 15 * 60 * 1000;
4
+ let skippingLogged = false;
5
+
6
+ declare global {
7
+ var __AGENT_NATIVE_ANALYTICS_ROLLUP_BACKFILL_SCHEDULED_RUNTIME__:
8
+ | boolean
9
+ | undefined;
10
+ }
11
+
12
+ function platformSchedulerOwnsBackfill(): boolean {
13
+ return (
14
+ process.env.NETLIFY === "true" ||
15
+ Boolean(process.env.NETLIFY_FUNCTION_NAME) ||
16
+ globalThis.__AGENT_NATIVE_ANALYTICS_ROLLUP_BACKFILL_SCHEDULED_RUNTIME__ ===
17
+ true
18
+ );
19
+ }
20
+
21
+ function intervalMs(): number {
22
+ const raw = process.env.ANALYTICS_ROLLUP_BACKFILL_INTERVAL_MS?.trim();
23
+ if (!raw) return DEFAULT_INTERVAL_MS;
24
+ const parsed = Number.parseInt(raw, 10);
25
+ return Number.isFinite(parsed) && parsed >= 10_000
26
+ ? parsed
27
+ : DEFAULT_INTERVAL_MS;
28
+ }
29
+
30
+ export default function registerAnalyticsRollupBackfillJobs(): void {
31
+ const isProd = process.env.NODE_ENV === "production";
32
+ const flag =
33
+ process.env.ANALYTICS_ROLLUP_BACKFILL_JOBS ??
34
+ process.env.RUN_BACKGROUND_JOBS;
35
+ const enabled =
36
+ !platformSchedulerOwnsBackfill() &&
37
+ (flag === "1" || (isProd && flag !== "0"));
38
+
39
+ if (!enabled) {
40
+ if (!skippingLogged) {
41
+ console.log(
42
+ platformSchedulerOwnsBackfill()
43
+ ? "[analytics-rollups] Skipping in-process cron because the platform scheduler owns historical rollup backfills."
44
+ : "[analytics-rollups] Skipping historical rollup backfill (set ANALYTICS_ROLLUP_BACKFILL_JOBS=1 or RUN_BACKGROUND_JOBS=1 to enable in dev; on by default in production)",
45
+ );
46
+ skippingLogged = true;
47
+ }
48
+ return;
49
+ }
50
+
51
+ const ms = intervalMs();
52
+ setInterval(() => {
53
+ runAnalyticsRollupBackfillOnce().catch((err) =>
54
+ console.error("[analytics-rollups] historical backfill failed:", err),
55
+ );
56
+ }, ms);
57
+
58
+ console.log(
59
+ `[analytics-rollups] Historical rollup backfill scheduled every ${ms / 1000}s.`,
60
+ );
61
+ }