@agent-native/core 0.145.8 → 0.146.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/turn-into-app/SKILL.md +41 -11
  3. package/corpus/templates/analytics/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  4. package/corpus/templates/analytics/actions/compose-dashboard.ts +2 -27
  5. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +18 -3
  6. package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +75 -24
  7. package/corpus/templates/analytics/actions/mutate-dashboard.ts +16 -24
  8. package/corpus/templates/analytics/actions/rename-dashboard.ts +2 -24
  9. package/corpus/templates/analytics/actions/reorder-dashboard-panels.ts +2 -24
  10. package/corpus/templates/analytics/actions/restore-dashboard-revision.ts +2 -25
  11. package/corpus/templates/analytics/actions/update-dashboard.ts +4 -32
  12. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +1 -40
  13. package/corpus/templates/analytics/changelog/2026-08-07-analytics-migrations-now-use-bounded-pressure-aware-backgrou.md +6 -0
  14. package/corpus/templates/analytics/changelog/2026-08-07-language-selection-now-lives-in-settings-instead-of-the-comm.md +6 -0
  15. package/corpus/templates/analytics/changelog/2026-08-07-template-activity-charts-now-show-only-core-agent-native-app.md +6 -0
  16. package/corpus/templates/analytics/netlify.toml +7 -0
  17. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +110 -0
  18. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +450 -0
  19. package/corpus/templates/analytics/server/jobs/analytics-rollup-backfill.ts +2 -2
  20. package/corpus/templates/analytics/server/lib/analytics-rollup-lock.ts +4 -5
  21. package/corpus/templates/analytics/server/lib/dashboard-collab-sync.ts +77 -0
  22. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +2 -23
  23. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +10 -4
  24. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +4 -11
  25. package/corpus/templates/analytics/server/plugins/analytics-rollup-backfill-jobs.ts +2 -5
  26. package/corpus/templates/analytics/server/plugins/db.ts +42 -0
  27. package/corpus/templates/analytics/server/routes/api/analytics-bigquery/backfill.post.ts +55 -0
  28. package/corpus/templates/analytics/server/routes/api/dashboard-agent-context.json.get.ts +18 -4
  29. package/corpus/templates/assets/.agents/skills/turn-into-app/SKILL.md +41 -11
  30. package/corpus/templates/assets/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  31. package/corpus/templates/brain/.agents/skills/turn-into-app/SKILL.md +41 -11
  32. package/corpus/templates/brain/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  33. package/corpus/templates/brain/server/lib/connectors.ts +38 -14
  34. package/corpus/templates/calendar/.agents/skills/turn-into-app/SKILL.md +41 -11
  35. package/corpus/templates/calendar/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  36. package/corpus/templates/chat/.agents/skills/turn-into-app/SKILL.md +41 -11
  37. package/corpus/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  38. package/corpus/templates/clips/.agents/skills/turn-into-app/SKILL.md +41 -11
  39. package/corpus/templates/clips/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  40. package/corpus/templates/clips/app/components/player/video-player.tsx +1 -1
  41. package/corpus/templates/clips/changelog/2026-08-07-fixed-trimmed-clips-durations-so-the-main-player-display-mat.md +6 -0
  42. package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +1 -1
  43. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  44. package/corpus/templates/content/.agents/skills/turn-into-app/SKILL.md +41 -11
  45. package/corpus/templates/content/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  46. package/corpus/templates/crm/.agents/skills/turn-into-app/SKILL.md +41 -11
  47. package/corpus/templates/crm/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  48. package/corpus/templates/design/.agents/skills/turn-into-app/SKILL.md +41 -11
  49. package/corpus/templates/design/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  50. package/corpus/templates/dispatch/.agents/skills/turn-into-app/SKILL.md +41 -11
  51. package/corpus/templates/dispatch/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  52. package/corpus/templates/dispatch/changelog/2026-08-07-language-selection-now-lives-in-settings-instead-of-the-disp.md +6 -0
  53. package/corpus/templates/factory/.agents/skills/turn-into-app/SKILL.md +41 -11
  54. package/corpus/templates/factory/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  55. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +59 -22
  56. package/corpus/templates/forms/.agents/skills/turn-into-app/SKILL.md +41 -11
  57. package/corpus/templates/forms/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  58. package/corpus/templates/macros/.agents/skills/turn-into-app/SKILL.md +41 -11
  59. package/corpus/templates/macros/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  60. package/corpus/templates/mail/.agents/skills/turn-into-app/SKILL.md +41 -11
  61. package/corpus/templates/mail/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  62. package/corpus/templates/plan/.agents/skills/turn-into-app/SKILL.md +41 -11
  63. package/corpus/templates/plan/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  64. package/corpus/templates/slides/.agents/skills/image-generation-via-a2a/SKILL.md +10 -1
  65. package/corpus/templates/slides/.agents/skills/slide-images/SKILL.md +10 -3
  66. package/corpus/templates/slides/.agents/skills/turn-into-app/SKILL.md +41 -11
  67. package/corpus/templates/slides/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  68. package/corpus/templates/slides/actions/generate-image-api.ts +123 -0
  69. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +10 -2
  70. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +13 -0
  71. package/corpus/templates/slides/app/components/editor/GeneratingSlidePreview.tsx +5 -0
  72. package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +1 -1
  73. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +5 -0
  74. package/corpus/templates/slides/app/components/presentation/PresenterView.tsx +5 -0
  75. package/corpus/templates/slides/app/pages/DeckEditor.tsx +3 -0
  76. package/corpus/templates/slides/app/pages/Presentation.tsx +3 -0
  77. package/corpus/templates/slides/app/pages/SharedPresentation.tsx +5 -0
  78. package/corpus/templates/slides/app/routes/slide.tsx +10 -5
  79. package/corpus/templates/slides/changelog/2026-08-07-deleting-a-deck-no-longer-freezes-the-slides-browser.md +6 -0
  80. package/corpus/templates/slides/changelog/2026-08-07-slide-previews-and-present-now-keep-deck-colors-consistent.md +6 -0
  81. package/corpus/templates/slides/changelog/2026-08-07-slides-verify-generated-images-are-persisted-on-the-target-s.md +6 -0
  82. package/corpus/templates/slides/server/db/schema.ts +1 -0
  83. package/corpus/templates/slides/server/handlers/share.ts +51 -3
  84. package/corpus/templates/slides/server/lib/slide-image-insertion.ts +141 -0
  85. package/corpus/templates/slides/server/plugins/agent-chat.ts +2 -0
  86. package/corpus/templates/slides/server/plugins/db.ts +5 -0
  87. package/corpus/templates/slides/shared/api.ts +2 -0
  88. package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +41 -11
  89. package/corpus/templates/tasks/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  90. package/dist/cli/code-agent-executor.js +8 -3
  91. package/dist/client/AgentPanel.d.ts +10 -4
  92. package/dist/client/AgentPanel.js +29 -82
  93. package/dist/client/ConnectBuilderCard.d.ts +7 -7
  94. package/dist/client/ConnectBuilderCard.js +45 -20
  95. package/dist/collab/awareness.d.ts +2 -2
  96. package/dist/config.d.ts +9 -2
  97. package/dist/config.js +38 -2
  98. package/dist/db/client.d.ts +13 -1
  99. package/dist/db/client.js +76 -2
  100. package/dist/db/create-get-db.js +2 -2
  101. package/dist/db/migrations.js +22 -4
  102. package/dist/deploy/build.d.ts +1 -0
  103. package/dist/deploy/build.js +32 -23
  104. package/dist/notifications/routes.d.ts +3 -3
  105. package/dist/progress/routes.d.ts +1 -1
  106. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  107. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  108. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  109. package/dist/scripts/docs/search.js +3 -2
  110. package/dist/server/agent-chat/prompt-resources.js +4 -3
  111. package/dist/server/agents-bundle.d.ts +20 -3
  112. package/dist/server/agents-bundle.js +57 -30
  113. package/dist/server/better-auth-instance.js +2 -2
  114. package/dist/server/builder-browser.d.ts +34 -0
  115. package/dist/server/builder-browser.js +179 -0
  116. package/dist/server/core-routes-plugin.d.ts +5 -0
  117. package/dist/server/core-routes-plugin.js +24 -8
  118. package/dist/server/index.d.ts +1 -1
  119. package/dist/server/index.js +1 -1
  120. package/dist/shared/cache-control.d.ts +2 -2
  121. package/dist/shared/cache-control.js +2 -2
  122. package/dist/shared/runtime-config.js +1 -1
  123. package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +41 -11
  124. package/dist/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  125. package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +41 -11
  126. package/dist/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  127. package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +41 -11
  128. package/dist/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  129. package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +41 -11
  130. package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  131. package/dist/vite/agent-native-config-loader.d.ts +10 -0
  132. package/dist/vite/agent-native-config-loader.js +56 -0
  133. package/dist/vite/agents-bundle-plugin.d.ts +5 -1
  134. package/dist/vite/agents-bundle-plugin.js +29 -6
  135. package/dist/vite/client.d.ts +4 -2
  136. package/dist/vite/client.js +17 -50
  137. package/dist/vite/index.d.ts +2 -2
  138. package/dist/vite/index.js +1 -1
  139. package/docs/content/agent-native-config.mdx +43 -17
  140. package/docs/content/drop-in-agent.mdx +3 -3
  141. package/docs/content/environment-variables.mdx +1 -1
  142. package/docs/content/getting-started-actions.mdx +1 -2
  143. package/docs/content/getting-started-pages.mdx +1 -2
  144. package/docs/content/getting-started.mdx +0 -4
  145. package/docs/content/what-is-agent-native.mdx +4 -4
  146. package/package.json +1 -1
  147. package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +41 -11
  148. package/src/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  149. package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +41 -11
  150. package/src/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  151. package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +41 -11
  152. package/src/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  153. package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +41 -11
  154. package/src/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +21 -2
  155. package/docs/content/getting-started-database.mdx +0 -173
@@ -0,0 +1,450 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { getDbExec, isPostgres } from "@agent-native/core/db";
4
+ import { runWithRequestContext } from "@agent-native/core/server";
5
+
6
+ import {
7
+ backfillFirstPartyAnalyticsBatch,
8
+ type FirstPartyAnalyticsScope,
9
+ } from "../lib/first-party-analytics-backend.js";
10
+
11
+ const JOB_TABLE = "analytics_bigquery_backfill_jobs";
12
+ const LEASE_MS = 5 * 60 * 1000;
13
+ const ERROR_RETRY_MS = 5 * 60 * 1000;
14
+ const DEFAULT_BATCH_SIZE = 250;
15
+ const MAX_BATCH_SIZE = 750;
16
+ const DEFAULT_MAX_BATCHES_PER_SWEEP = 2;
17
+ const MAX_BATCHES_PER_SWEEP = 4;
18
+ const DEFAULT_MAX_ACTIVE_SESSIONS = 80;
19
+ const DEFAULT_MAX_TOTAL_SESSIONS = 250;
20
+
21
+ type Query =
22
+ | string
23
+ | {
24
+ sql: string;
25
+ args?: unknown[];
26
+ timeoutMs?: number;
27
+ maxAttempts?: number;
28
+ };
29
+
30
+ interface QueryResult {
31
+ rows?: unknown[];
32
+ rowsAffected?: number;
33
+ }
34
+
35
+ interface Executor {
36
+ execute(query: Query): Promise<QueryResult>;
37
+ transaction?<T>(fn: (tx: Executor) => Promise<T>): Promise<T>;
38
+ }
39
+
40
+ export type BigQueryBackfillJobStatus = "pending" | "running" | "completed";
41
+
42
+ export interface BigQueryBackfillJob {
43
+ id: string;
44
+ orgId: string;
45
+ ownerEmail: string;
46
+ table: string;
47
+ batchSize: number;
48
+ cursor: string | null;
49
+ status: BigQueryBackfillJobStatus;
50
+ copied: number;
51
+ leaseToken: string | null;
52
+ leaseExpiresAt: string | null;
53
+ nextRunAt: string;
54
+ lastError: string | null;
55
+ completedAt: string | null;
56
+ updatedAt: string;
57
+ }
58
+
59
+ export interface BigQueryBackfillSweepResult {
60
+ status:
61
+ | "disabled"
62
+ | "idle"
63
+ | "paused-pressure"
64
+ | "progress"
65
+ | "completed"
66
+ | "retry-scheduled";
67
+ batches: number;
68
+ copied: number;
69
+ remaining: number;
70
+ reason?: string;
71
+ error?: string;
72
+ }
73
+
74
+ function executor(): Executor {
75
+ return getDbExec() as unknown as Executor;
76
+ }
77
+
78
+ function jobId(orgId: string): string {
79
+ return `first-party-analytics:${orgId}`;
80
+ }
81
+
82
+ function boundedBatchSize(value: number | undefined): number {
83
+ if (!Number.isFinite(value)) return DEFAULT_BATCH_SIZE;
84
+ return Math.min(Math.max(Math.floor(value as number), 1), MAX_BATCH_SIZE);
85
+ }
86
+
87
+ function maxBatchesPerSweep(): number {
88
+ const raw = process.env.ANALYTICS_BIGQUERY_BACKFILL_SWEEP_LIMIT?.trim();
89
+ if (!raw) return DEFAULT_MAX_BATCHES_PER_SWEEP;
90
+ const parsed = Number.parseInt(raw, 10);
91
+ return Number.isFinite(parsed) && parsed > 0
92
+ ? Math.min(parsed, MAX_BATCHES_PER_SWEEP)
93
+ : DEFAULT_MAX_BATCHES_PER_SWEEP;
94
+ }
95
+
96
+ function positiveEnvNumber(name: string, fallback: number): number {
97
+ const raw = process.env[name]?.trim();
98
+ if (!raw) return fallback;
99
+ const parsed = Number.parseInt(raw, 10);
100
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
101
+ }
102
+
103
+ function stringValue(row: Record<string, unknown>, ...keys: string[]): string {
104
+ for (const key of keys) {
105
+ const value = row[key];
106
+ if (typeof value === "string") return value;
107
+ }
108
+ return "";
109
+ }
110
+
111
+ function nullableStringValue(
112
+ row: Record<string, unknown>,
113
+ ...keys: string[]
114
+ ): string | null {
115
+ const value = stringValue(row, ...keys);
116
+ return value || null;
117
+ }
118
+
119
+ function numberValue(row: Record<string, unknown>, ...keys: string[]): number {
120
+ for (const key of keys) {
121
+ const value = row[key];
122
+ const parsed = typeof value === "number" ? value : Number(value);
123
+ if (Number.isFinite(parsed)) return parsed;
124
+ }
125
+ return 0;
126
+ }
127
+
128
+ function rowToJob(row: Record<string, unknown>): BigQueryBackfillJob {
129
+ const status = stringValue(row, "status");
130
+ if (status !== "pending" && status !== "running" && status !== "completed") {
131
+ throw new Error(
132
+ `Unknown BigQuery backfill job status: ${status || "empty"}`,
133
+ );
134
+ }
135
+ return {
136
+ id: stringValue(row, "id"),
137
+ orgId: stringValue(row, "org_id", "orgId"),
138
+ ownerEmail: stringValue(row, "owner_email", "ownerEmail"),
139
+ table: stringValue(row, "table_ref", "tableRef"),
140
+ batchSize: boundedBatchSize(numberValue(row, "batch_size", "batchSize")),
141
+ cursor: nullableStringValue(row, "backfill_cursor", "backfillCursor"),
142
+ status,
143
+ copied: numberValue(row, "copied_count", "copiedCount"),
144
+ leaseToken: nullableStringValue(row, "lease_token", "leaseToken"),
145
+ leaseExpiresAt: nullableStringValue(
146
+ row,
147
+ "lease_expires_at",
148
+ "leaseExpiresAt",
149
+ ),
150
+ nextRunAt: stringValue(row, "next_run_at", "nextRunAt"),
151
+ lastError: nullableStringValue(row, "last_error", "lastError"),
152
+ completedAt: nullableStringValue(row, "completed_at", "completedAt"),
153
+ updatedAt: stringValue(row, "updated_at", "updatedAt"),
154
+ };
155
+ }
156
+
157
+ function rowFromResult(result: QueryResult): Record<string, unknown> | null {
158
+ const row = result.rows?.[0];
159
+ return row && typeof row === "object"
160
+ ? (row as Record<string, unknown>)
161
+ : null;
162
+ }
163
+
164
+ export async function getFirstPartyAnalyticsBigQueryBackfillJob(
165
+ scope: FirstPartyAnalyticsScope,
166
+ ): Promise<BigQueryBackfillJob | null> {
167
+ const result = await executor().execute({
168
+ sql: `SELECT id, org_id, owner_email, table_ref, batch_size,
169
+ backfill_cursor, status, copied_count, lease_token,
170
+ lease_expires_at, next_run_at, last_error, completed_at,
171
+ updated_at
172
+ FROM ${JOB_TABLE}
173
+ WHERE id = ?
174
+ LIMIT 1`,
175
+ args: [jobId(scope.orgId ?? "")],
176
+ timeoutMs: 3_000,
177
+ maxAttempts: 1,
178
+ });
179
+ const row = rowFromResult(result);
180
+ return row ? rowToJob(row) : null;
181
+ }
182
+
183
+ export async function queueFirstPartyAnalyticsBigQueryBackfill(
184
+ scope: FirstPartyAnalyticsScope,
185
+ table: string,
186
+ requestedBatchSize?: number,
187
+ initialCursor?: string | null,
188
+ ): Promise<BigQueryBackfillJob> {
189
+ const now = new Date().toISOString();
190
+ const id = jobId(scope.orgId ?? "");
191
+ if (!scope.orgId)
192
+ throw new Error("BigQuery backfill requires an organization");
193
+ await executor().execute({
194
+ sql: `INSERT INTO ${JOB_TABLE} (
195
+ id, org_id, owner_email, table_ref, batch_size, backfill_cursor,
196
+ status, copied_count, lease_token, lease_expires_at, next_run_at,
197
+ last_error, completed_at, updated_at
198
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, ?, NULL, NULL, ?)
199
+ ON CONFLICT (id) DO NOTHING`,
200
+ args: [
201
+ id,
202
+ scope.orgId,
203
+ scope.userEmail,
204
+ table,
205
+ boundedBatchSize(requestedBatchSize),
206
+ initialCursor ?? null,
207
+ now,
208
+ now,
209
+ ],
210
+ timeoutMs: 5_000,
211
+ maxAttempts: 1,
212
+ });
213
+ const job = await getFirstPartyAnalyticsBigQueryBackfillJob(scope);
214
+ if (!job) throw new Error("BigQuery backfill job was not persisted");
215
+ if (job.table !== table) {
216
+ throw new Error(
217
+ `BigQuery backfill already targets ${job.table}; prepare the existing migration before changing tables`,
218
+ );
219
+ }
220
+ return job;
221
+ }
222
+
223
+ async function claimNextJob(
224
+ db: Executor,
225
+ now: string,
226
+ ): Promise<BigQueryBackfillJob | null> {
227
+ if (!db.transaction) {
228
+ throw new Error("BigQuery backfill requires a database transaction");
229
+ }
230
+ const token = randomUUID();
231
+ const leaseExpiresAt = new Date(Date.now() + LEASE_MS).toISOString();
232
+ return db.transaction(async (tx) => {
233
+ const candidateResult = await tx.execute({
234
+ sql: `SELECT id, org_id, owner_email, table_ref, batch_size,
235
+ backfill_cursor, status, copied_count, lease_token,
236
+ lease_expires_at, next_run_at, last_error, completed_at,
237
+ updated_at
238
+ FROM ${JOB_TABLE}
239
+ WHERE next_run_at <= ?
240
+ AND (
241
+ status = 'pending'
242
+ OR (status = 'running' AND lease_expires_at IS NOT NULL
243
+ AND lease_expires_at <= ?)
244
+ )
245
+ ORDER BY updated_at ASC
246
+ LIMIT 1`,
247
+ args: [now, now],
248
+ timeoutMs: 3_000,
249
+ maxAttempts: 1,
250
+ });
251
+ const candidate = rowFromResult(candidateResult);
252
+ if (!candidate) return null;
253
+
254
+ const updated = await tx.execute({
255
+ sql: `UPDATE ${JOB_TABLE}
256
+ SET status = 'running', lease_token = ?, lease_expires_at = ?,
257
+ updated_at = ?
258
+ WHERE id = ?
259
+ AND next_run_at <= ?
260
+ AND (
261
+ status = 'pending'
262
+ OR (status = 'running' AND lease_expires_at IS NOT NULL
263
+ AND lease_expires_at <= ?)
264
+ )`,
265
+ args: [token, leaseExpiresAt, now, candidate.id, now, now],
266
+ timeoutMs: 3_000,
267
+ maxAttempts: 1,
268
+ });
269
+ if (updated.rowsAffected !== 1) return null;
270
+ return rowToJob({
271
+ ...candidate,
272
+ status: "running",
273
+ lease_token: token,
274
+ lease_expires_at: leaseExpiresAt,
275
+ updated_at: now,
276
+ });
277
+ });
278
+ }
279
+
280
+ async function finishJob(
281
+ db: Executor,
282
+ job: BigQueryBackfillJob,
283
+ result: { nextCursor: string | null; copied: number; complete: boolean },
284
+ now: string,
285
+ ): Promise<void> {
286
+ const completedAt = result.complete ? now : null;
287
+ const updated = await db.execute({
288
+ sql: `UPDATE ${JOB_TABLE}
289
+ SET status = ?, backfill_cursor = ?, copied_count = copied_count + ?,
290
+ lease_token = NULL, lease_expires_at = NULL,
291
+ next_run_at = ?, last_error = NULL, completed_at = ?,
292
+ updated_at = ?
293
+ WHERE id = ? AND lease_token = ?`,
294
+ args: [
295
+ result.complete ? "completed" : "pending",
296
+ result.nextCursor,
297
+ result.copied,
298
+ now,
299
+ completedAt,
300
+ now,
301
+ job.id,
302
+ job.leaseToken,
303
+ ],
304
+ timeoutMs: 5_000,
305
+ maxAttempts: 1,
306
+ });
307
+ if (updated.rowsAffected !== 1) {
308
+ throw new Error("BigQuery backfill lost its lease before saving progress");
309
+ }
310
+ }
311
+
312
+ async function scheduleRetry(
313
+ db: Executor,
314
+ job: BigQueryBackfillJob,
315
+ error: string,
316
+ now: string,
317
+ ): Promise<void> {
318
+ const retryAt = new Date(Date.now() + ERROR_RETRY_MS).toISOString();
319
+ await db.execute({
320
+ sql: `UPDATE ${JOB_TABLE}
321
+ SET status = 'pending', lease_token = NULL, lease_expires_at = NULL,
322
+ next_run_at = ?, last_error = ?, updated_at = ?
323
+ WHERE id = ? AND lease_token = ?`,
324
+ args: [retryAt, error.slice(0, 1_000), now, job.id, job.leaseToken],
325
+ timeoutMs: 5_000,
326
+ maxAttempts: 1,
327
+ });
328
+ }
329
+
330
+ async function pressureSnapshot(
331
+ db: Executor,
332
+ ): Promise<{ paused: boolean; reason?: string }> {
333
+ if (!isPostgres()) return { paused: false };
334
+ try {
335
+ const result = await db.execute({
336
+ sql: `SELECT
337
+ COUNT(*) AS total_sessions,
338
+ SUM(CASE WHEN state = 'active' THEN 1 ELSE 0 END) AS active_sessions,
339
+ SUM(CASE WHEN wait_event_type IS NOT NULL THEN 1 ELSE 0 END) AS waiting_sessions,
340
+ SUM(CASE WHEN wait_event_type = 'Lock' THEN 1 ELSE 0 END) AS lock_waiters
341
+ FROM pg_stat_activity
342
+ WHERE pid <> pg_backend_pid()`,
343
+ timeoutMs: 2_000,
344
+ maxAttempts: 1,
345
+ });
346
+ const row = rowFromResult(result);
347
+ if (!row) return { paused: true, reason: "pressure probe returned no row" };
348
+ const total = numberValue(row, "total_sessions");
349
+ const active = numberValue(row, "active_sessions");
350
+ const waiting = numberValue(row, "waiting_sessions");
351
+ const lockWaiters = numberValue(row, "lock_waiters");
352
+ const maxActive = positiveEnvNumber(
353
+ "ANALYTICS_BIGQUERY_BACKFILL_MAX_ACTIVE_SESSIONS",
354
+ DEFAULT_MAX_ACTIVE_SESSIONS,
355
+ );
356
+ const maxTotal = positiveEnvNumber(
357
+ "ANALYTICS_BIGQUERY_BACKFILL_MAX_TOTAL_SESSIONS",
358
+ DEFAULT_MAX_TOTAL_SESSIONS,
359
+ );
360
+ if (lockWaiters > 0 || active >= maxActive || total >= maxTotal) {
361
+ return {
362
+ paused: true,
363
+ reason: `database pressure: total=${total}, active=${active}, waiting=${waiting}, lockWaiters=${lockWaiters}`,
364
+ };
365
+ }
366
+ return { paused: false };
367
+ } catch (error) {
368
+ return {
369
+ paused: true,
370
+ reason: `pressure probe failed: ${error instanceof Error ? error.message : String(error)}`,
371
+ };
372
+ }
373
+ }
374
+
375
+ export async function runFirstPartyAnalyticsBigQueryBackfillOnce(): Promise<BigQueryBackfillSweepResult> {
376
+ if (process.env.ANALYTICS_BIGQUERY_BACKFILL_JOBS?.trim() === "0") {
377
+ return { status: "disabled", batches: 0, copied: 0, remaining: 0 };
378
+ }
379
+
380
+ const db = executor();
381
+ const initialPressure = await pressureSnapshot(db);
382
+ if (initialPressure.paused) {
383
+ return {
384
+ status: "paused-pressure",
385
+ batches: 0,
386
+ copied: 0,
387
+ remaining: 1,
388
+ reason: initialPressure.reason,
389
+ };
390
+ }
391
+
392
+ let batches = 0;
393
+ let copied = 0;
394
+ for (let index = 0; index < maxBatchesPerSweep(); index += 1) {
395
+ const pressure = await pressureSnapshot(db);
396
+ if (pressure.paused) {
397
+ return {
398
+ status: "paused-pressure",
399
+ batches,
400
+ copied,
401
+ remaining: 1,
402
+ reason: pressure.reason,
403
+ };
404
+ }
405
+
406
+ const job = await claimNextJob(db, new Date().toISOString());
407
+ if (!job) {
408
+ return {
409
+ status: batches ? "progress" : "idle",
410
+ batches,
411
+ copied,
412
+ remaining: batches ? 1 : 0,
413
+ };
414
+ }
415
+
416
+ try {
417
+ const batch = await runWithRequestContext(
418
+ { userEmail: job.ownerEmail, orgId: job.orgId },
419
+ () =>
420
+ backfillFirstPartyAnalyticsBatch(
421
+ { userEmail: job.ownerEmail, orgId: job.orgId },
422
+ job.cursor,
423
+ job.batchSize,
424
+ job.table,
425
+ ),
426
+ );
427
+ const now = new Date().toISOString();
428
+ // The lease row is the progress source of truth; the backend setting is
429
+ // only the migration configuration and cutover marker.
430
+ await finishJob(db, job, batch, now);
431
+ batches += 1;
432
+ copied += batch.copied;
433
+ if (batch.complete) {
434
+ return { status: "completed", batches, copied, remaining: 0 };
435
+ }
436
+ } catch (error) {
437
+ const message = error instanceof Error ? error.message : String(error);
438
+ await scheduleRetry(db, job, message, new Date().toISOString());
439
+ return {
440
+ status: "retry-scheduled",
441
+ batches,
442
+ copied,
443
+ remaining: 1,
444
+ error: message,
445
+ };
446
+ }
447
+ }
448
+
449
+ return { status: "progress", batches, copied, remaining: 1 };
450
+ }
@@ -1,6 +1,6 @@
1
1
  import { getDbExec, getDialect, isPostgres } from "@agent-native/core/db";
2
2
 
3
- import { FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY } from "../lib/analytics-rollup-lock";
3
+ import { FIRST_PARTY_ANALYTICS_ROLLUP_BACKFILL_LOCK_KEY } from "../lib/analytics-rollup-lock";
4
4
 
5
5
  const BACKFILL_STATE_ID = "historical-v1";
6
6
  const BACKFILL_LEASE_MINUTES = 15;
@@ -254,7 +254,7 @@ async function runTransactionalBackfill(
254
254
  );
255
255
  const lockResult = await tx.execute({
256
256
  sql: "SELECT pg_try_advisory_xact_lock(hashtextextended(?, 0::bigint)) AS acquired",
257
- args: [FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY],
257
+ args: [FIRST_PARTY_ANALYTICS_ROLLUP_BACKFILL_LOCK_KEY],
258
258
  });
259
259
  const acquired = lockResult.rows[0]?.acquired;
260
260
  if (acquired !== true && acquired !== "t") return "skipped-lock";
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Shared with the historical backfill so live ingest and migration work hold
3
- * the same transaction-scoped Postgres lock while they update rollups.
2
+ * Prevents concurrent historical backfills. Foreground ingest deliberately
3
+ * does not take this lock: a rebuild must never make request traffic queue
4
+ * behind a long-running transaction.
4
5
  */
5
- export const FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY =
6
+ export const FIRST_PARTY_ANALYTICS_ROLLUP_BACKFILL_LOCK_KEY =
6
7
  "agent-native:analytics-rollup-backfill";
7
- export const FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_SQL =
8
- "SELECT pg_advisory_xact_lock(hashtextextended(?, 0::bigint))";
@@ -0,0 +1,77 @@
1
+ import {
2
+ applyText,
3
+ hasCollabState,
4
+ seedFromText,
5
+ } from "@agent-native/core/collab";
6
+
7
+ export const DASHBOARD_COLLAB_SYNC_TIMEOUT_MS = 2_000;
8
+
9
+ class DashboardCollabSyncTimeoutError extends Error {
10
+ constructor(timeoutMs: number) {
11
+ super(`timed out after ${timeoutMs}ms`);
12
+ this.name = "DashboardCollabSyncTimeoutError";
13
+ }
14
+ }
15
+
16
+ async function withTimeout<T>(
17
+ promise: Promise<T>,
18
+ timeoutMs: number,
19
+ ): Promise<T> {
20
+ let timer: ReturnType<typeof setTimeout> | undefined;
21
+ try {
22
+ return await Promise.race([
23
+ promise,
24
+ new Promise<T>((_, reject) => {
25
+ timer = setTimeout(
26
+ () => reject(new DashboardCollabSyncTimeoutError(timeoutMs)),
27
+ timeoutMs,
28
+ );
29
+ }),
30
+ ]);
31
+ } finally {
32
+ if (timer !== undefined) clearTimeout(timer);
33
+ }
34
+ }
35
+
36
+ async function syncDashboardToCollab(
37
+ dashboardId: string,
38
+ config: Record<string, unknown>,
39
+ requestSource?: string,
40
+ ): Promise<void> {
41
+ const docId = `dash-${dashboardId}`;
42
+ const startedAt = Date.now();
43
+ try {
44
+ const configStr = JSON.stringify(config);
45
+ await withTimeout(
46
+ (async () => {
47
+ const exists = await hasCollabState(docId);
48
+ if (exists) {
49
+ await applyText(docId, configStr, "content", requestSource);
50
+ } else {
51
+ await seedFromText(docId, configStr);
52
+ }
53
+ })(),
54
+ DASHBOARD_COLLAB_SYNC_TIMEOUT_MS,
55
+ );
56
+ } catch (error) {
57
+ const elapsedMs = Date.now() - startedAt;
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ const outcome =
60
+ error instanceof DashboardCollabSyncTimeoutError ? "timed out" : "failed";
61
+ console.warn(
62
+ `[analytics] Dashboard collab sync ${outcome} for ${dashboardId} after ${elapsedMs}ms: ${message}`,
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Queue the live-editor sync after SQL has committed. SQL remains the source
69
+ * of truth, so a collab lock or unavailable database must not hold a mutation.
70
+ */
71
+ export function queueDashboardCollabSync(
72
+ dashboardId: string,
73
+ config: Record<string, unknown>,
74
+ requestSource?: string,
75
+ ): void {
76
+ void syncDashboardToCollab(dashboardId, config, requestSource);
77
+ }
@@ -1,12 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
- import {
4
- applyText,
5
- hasCollabState,
6
- seedFromText,
7
- } from "@agent-native/core/collab";
8
3
  import { getUserSetting, putUserSetting } from "@agent-native/core/settings";
9
4
 
5
+ import { queueDashboardCollabSync } from "./dashboard-collab-sync";
10
6
  import { loadDashboardSeed } from "./dashboard-seeds";
11
7
  import { getDashboard, upsertDashboard } from "./dashboards-store";
12
8
 
@@ -187,23 +183,6 @@ function applyDemoMetadata(
187
183
  };
188
184
  }
189
185
 
190
- async function syncToCollab(
191
- dashboardId: string,
192
- config: Record<string, unknown>,
193
- ): Promise<void> {
194
- const docId = `dash-${dashboardId}`;
195
- const configStr = JSON.stringify(config);
196
- try {
197
- if (await hasCollabState(docId)) {
198
- await applyText(docId, configStr, "content", "agent");
199
- } else {
200
- await seedFromText(docId, configStr);
201
- }
202
- } catch {
203
- // SQL is the source of truth; collab can seed lazily later.
204
- }
205
- }
206
-
207
186
  async function readDemoState(email: string): Promise<DemoDashboardState> {
208
187
  return normalizeState(await getUserSetting(email, DEMO_DASHBOARD_STATE_KEY));
209
188
  }
@@ -277,7 +256,7 @@ export async function ensureDemoDashboardsForUser(
277
256
  // SQL is the source of truth and collab state can seed lazily. Do not
278
257
  // hold first-open installation (and the whole app shell) behind a remote
279
258
  // collab read/write that may be slow or temporarily unavailable.
280
- void syncToCollab(dashboardId, config);
259
+ queueDashboardCollabSync(dashboardId, config, "agent");
281
260
  archivedAt = row.archivedAt;
282
261
  created = !existing;
283
262
  }
@@ -73,7 +73,7 @@ const FIRST_PARTY_QUERY_TABLES = [
73
73
  "analytics_user_days",
74
74
  ] as const;
75
75
  const BACKEND_CONFIG_CACHE_TTL_MS = 30_000;
76
- const MAX_BACKFILL_BATCH_SIZE = 5_000;
76
+ const MAX_BACKFILL_BATCH_SIZE = 750;
77
77
  const MAX_INSERT_BATCH_SIZE = 200;
78
78
 
79
79
  const backendConfigCache = new Map<
@@ -1153,9 +1153,15 @@ export async function backfillFirstPartyAnalyticsBatch(
1153
1153
  const hydratedById = new Map(
1154
1154
  hydratedRows.map((row) => [backfillRowCursor(row).id, row]),
1155
1155
  );
1156
- const selectedEvents = selectedIds
1157
- .map((id) => hydratedById.get(id))
1158
- .filter((row): row is Record<string, unknown> => row !== undefined);
1156
+ const selectedEvents = selectedIds.map((id) => {
1157
+ const row = hydratedById.get(id);
1158
+ if (!row) {
1159
+ throw new Error(
1160
+ `First-party analytics backfill row ${id} disappeared before hydration`,
1161
+ );
1162
+ }
1163
+ return row;
1164
+ });
1159
1165
  await insertFirstPartyAnalyticsRows(selectedEvents, configuredTable);
1160
1166
  const lastCursor = backfillRowCursor(selectedRows[selectedRows.length - 1]!);
1161
1167
  return {
@@ -1,8 +1,6 @@
1
- import { isPostgres } from "@agent-native/core/db";
2
1
  import { sql } from "@agent-native/core/db/schema";
3
2
 
4
3
  import { getDb, schema } from "../db/index.js";
5
- import { FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY } from "./analytics-rollup-lock.js";
6
4
 
7
5
  /**
8
6
  * The normalized subset emitted by first-party analytics ingest. Keeping this
@@ -143,15 +141,10 @@ export async function upsertFirstPartyAnalyticsRollups(
143
141
  }
144
142
 
145
143
  const writeRollups = async (tx: any) => {
146
- if (isPostgres()) {
147
- // The historical backfill takes this lock before its raw-event
148
- // snapshot. Holding it through ingest's rollup increment prevents the
149
- // backfill's monotonic upsert from losing a concurrently committed
150
- // event.
151
- await tx.execute(
152
- sql`SELECT pg_advisory_xact_lock(hashtextextended(${FIRST_PARTY_ANALYTICS_ROLLUP_LOCK_KEY}, 0::bigint))`,
153
- );
154
- }
144
+ // Do not take the historical backfill advisory lock here. Foreground
145
+ // ingest must not wait behind a long-running rebuild; the incremental
146
+ // conflict update and the backfill's GREATEST upsert are both monotonic,
147
+ // and Postgres serializes the conflicting row updates itself.
155
148
  const dailyRows = [...dailyRollups.values()];
156
149
  await tx
157
150
  .insert(schema.analyticsEventDailyRollups)
@@ -29,20 +29,17 @@ function intervalMs(): number {
29
29
  }
30
30
 
31
31
  export default function registerAnalyticsRollupBackfillJobs(): void {
32
- const isProd = process.env.NODE_ENV === "production";
33
32
  const flag =
34
33
  process.env.ANALYTICS_ROLLUP_BACKFILL_JOBS ??
35
34
  process.env.RUN_BACKGROUND_JOBS;
36
- const enabled =
37
- !platformSchedulerOwnsBackfill() &&
38
- (flag === "1" || (isProd && flag !== "0"));
35
+ const enabled = !platformSchedulerOwnsBackfill() && flag === "1";
39
36
 
40
37
  if (!enabled) {
41
38
  if (!skippingLogged) {
42
39
  console.log(
43
40
  platformSchedulerOwnsBackfill()
44
41
  ? "[analytics-rollups] Skipping in-process cron because the platform scheduler owns historical rollup backfills."
45
- : "[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)",
42
+ : "[analytics-rollups] Skipping historical rollup backfill (set ANALYTICS_ROLLUP_BACKFILL_JOBS=1 or RUN_BACKGROUND_JOBS=1 to enable explicitly)",
46
43
  );
47
44
  skippingLogged = true;
48
45
  }