@agent-native/core 0.146.7 → 0.147.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 (167) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/turn-into-app/SKILL.md +18 -6
  3. package/corpus/templates/analytics/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  4. package/corpus/templates/analytics/DEVELOPING.md +59 -0
  5. package/corpus/templates/analytics/app/routes/status.$slug.tsx +4 -2
  6. package/corpus/templates/analytics/package.json +1 -0
  7. package/corpus/templates/analytics/scripts/first-party-analytics-bigquery-backfill.ts +889 -0
  8. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +70 -0
  9. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +117 -12
  10. package/corpus/templates/assets/.agents/skills/turn-into-app/SKILL.md +18 -6
  11. package/corpus/templates/assets/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  12. package/corpus/templates/brain/.agents/skills/adding-a-feature/SKILL.md +9 -0
  13. package/corpus/templates/brain/.agents/skills/turn-into-app/SKILL.md +18 -6
  14. package/corpus/templates/brain/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  15. package/corpus/templates/calendar/.agents/skills/turn-into-app/SKILL.md +18 -6
  16. package/corpus/templates/calendar/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  17. package/corpus/templates/chat/.agents/skills/adding-a-feature/SKILL.md +9 -0
  18. package/corpus/templates/chat/.agents/skills/turn-into-app/SKILL.md +18 -6
  19. package/corpus/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  20. package/corpus/templates/clips/.agents/skills/turn-into-app/SKILL.md +18 -6
  21. package/corpus/templates/clips/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  22. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +16 -4
  23. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +14 -5
  24. package/corpus/templates/clips/package.json +1 -0
  25. package/corpus/templates/clips/react-router.config.ts +7 -0
  26. package/corpus/templates/clips/server/plugins/auth.ts +6 -1
  27. package/corpus/templates/clips/shared/prerendered-public-paths.ts +21 -0
  28. package/corpus/templates/clips/shared/share-meta.ts +8 -3
  29. package/corpus/templates/content/.agents/skills/turn-into-app/SKILL.md +18 -6
  30. package/corpus/templates/content/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  31. package/corpus/templates/crm/.agents/skills/turn-into-app/SKILL.md +18 -6
  32. package/corpus/templates/crm/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  33. package/corpus/templates/design/.agents/skills/turn-into-app/SKILL.md +18 -6
  34. package/corpus/templates/design/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  35. package/corpus/templates/dispatch/.agents/skills/turn-into-app/SKILL.md +18 -6
  36. package/corpus/templates/dispatch/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  37. package/corpus/templates/dispatch/app/chat-first-pages.ts +747 -0
  38. package/corpus/templates/dispatch/app/i18n-data.ts +181 -0
  39. package/corpus/templates/dispatch/app/routes/settings.tsx +62 -1
  40. package/corpus/templates/dispatch/changelog/2026-08-08-chat-first-navigation-keeps-conversations-central-while-open.md +6 -0
  41. package/corpus/templates/factory/.agents/skills/adding-a-feature/SKILL.md +9 -0
  42. package/corpus/templates/factory/.agents/skills/turn-into-app/SKILL.md +18 -6
  43. package/corpus/templates/factory/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  44. package/corpus/templates/factory/actions/run-factory-automation.ts +1 -0
  45. package/corpus/templates/factory/app/routes/factory.tsx +108 -76
  46. package/corpus/templates/factory/changelog/2026-08-08-scheduler-health-moves-to-settings.md +6 -0
  47. package/corpus/templates/factory/netlify.toml +1 -1
  48. package/corpus/templates/forms/.agents/skills/turn-into-app/SKILL.md +18 -6
  49. package/corpus/templates/forms/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  50. package/corpus/templates/macros/.agents/skills/turn-into-app/SKILL.md +18 -6
  51. package/corpus/templates/macros/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  52. package/corpus/templates/mail/.agents/skills/turn-into-app/SKILL.md +18 -6
  53. package/corpus/templates/mail/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  54. package/corpus/templates/plan/.agents/skills/adding-a-feature/SKILL.md +9 -0
  55. package/corpus/templates/plan/.agents/skills/turn-into-app/SKILL.md +18 -6
  56. package/corpus/templates/plan/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  57. package/corpus/templates/plan/app/lib/plan-document-title.ts +3 -1
  58. package/corpus/templates/plan/app/root.tsx +1 -0
  59. package/corpus/templates/slides/.agents/skills/turn-into-app/SKILL.md +18 -6
  60. package/corpus/templates/slides/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  61. package/corpus/templates/tasks/.agents/skills/adding-a-feature/SKILL.md +9 -0
  62. package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +18 -6
  63. package/corpus/templates/tasks/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  64. package/dist/agent/engine/builder-engine.d.ts +11 -1
  65. package/dist/agent/engine/builder-engine.js +11 -4
  66. package/dist/chat-threads/store.d.ts +2 -0
  67. package/dist/chat-threads/store.js +1 -1
  68. package/dist/cli/agent-plugin.d.ts +5 -2
  69. package/dist/cli/agent-plugin.js +17 -9
  70. package/dist/cli/code-agent-executor.js +25 -5
  71. package/dist/cli/code-agent-mcp-config.d.ts +21 -0
  72. package/dist/cli/code-agent-mcp-config.js +103 -0
  73. package/dist/client/NewWorkspaceAppFlow.js +4 -1
  74. package/dist/client/agent-chat/index.d.ts +4 -1
  75. package/dist/client/agent-chat/index.js +4 -1
  76. package/dist/client/agent-page/AgentTabsPage.d.ts +1 -0
  77. package/dist/client/agent-page/AgentTabsPage.js +4 -3
  78. package/dist/client/app-providers.d.ts +3 -1
  79. package/dist/client/app-providers.js +50 -5
  80. package/dist/client/chat/runtime.js +63 -0
  81. package/dist/client/chat-first/agents-pane.d.ts +2 -0
  82. package/dist/client/chat-first/agents-pane.js +6 -0
  83. package/dist/client/chat-first/app-pane.d.ts +2 -0
  84. package/dist/client/chat-first/app-pane.js +6 -0
  85. package/dist/client/chat-first/apps-rail.d.ts +2 -0
  86. package/dist/client/chat-first/apps-rail.js +103 -0
  87. package/dist/client/chat-first/browser-pane.d.ts +2 -0
  88. package/dist/client/chat-first/browser-pane.js +64 -0
  89. package/dist/client/chat-first/chat-history.d.ts +12 -0
  90. package/dist/client/chat-first/chat-history.js +10 -0
  91. package/dist/client/chat-first/copy.d.ts +2 -0
  92. package/dist/client/chat-first/copy.js +117 -0
  93. package/dist/client/chat-first/index.d.ts +13 -0
  94. package/dist/client/chat-first/index.js +12 -0
  95. package/dist/client/chat-first/primary-nav.d.ts +8 -0
  96. package/dist/client/chat-first/primary-nav.js +6 -0
  97. package/dist/client/chat-first/session-watch-pane.d.ts +2 -0
  98. package/dist/client/chat-first/session-watch-pane.js +9 -0
  99. package/dist/client/chat-first/surface-panel.d.ts +9 -0
  100. package/dist/client/chat-first/surface-panel.js +9 -0
  101. package/dist/client/chat-first/surface-tabs.d.ts +9 -0
  102. package/dist/client/chat-first/surface-tabs.js +74 -0
  103. package/dist/client/chat-first/types.d.ts +74 -0
  104. package/dist/client/chat-first/types.js +1 -0
  105. package/dist/client/chat-first-agent-activity.d.ts +11 -0
  106. package/dist/client/chat-first-agent-activity.js +51 -0
  107. package/dist/client/chat-first-surface-panel-toggle.d.ts +5 -0
  108. package/dist/client/chat-first-surface-panel-toggle.js +6 -0
  109. package/dist/client/chat-first.d.ts +208 -0
  110. package/dist/client/chat-first.js +816 -0
  111. package/dist/client/extensions/ExtensionViewer.js +8 -1
  112. package/dist/client/resources/McpIntegrationDialog.js +3 -2
  113. package/dist/client/resources/McpServerDetail.js +6 -11
  114. package/dist/client/resources/index.d.ts +1 -0
  115. package/dist/client/resources/index.js +1 -0
  116. package/dist/client/resources/use-mcp-servers.d.ts +20 -0
  117. package/dist/client/resources/use-mcp-servers.js +82 -46
  118. package/dist/client/settings/index.d.ts +1 -0
  119. package/dist/client/settings/index.js +1 -0
  120. package/dist/collab/awareness.d.ts +2 -2
  121. package/dist/collab/routes.d.ts +1 -1
  122. package/dist/jobs/background-automation-runner.js +19 -2
  123. package/dist/jobs/scheduler-health.d.ts +20 -0
  124. package/dist/jobs/scheduler-health.js +86 -1
  125. package/dist/jobs/scheduler.js +38 -1
  126. package/dist/mcp-client/routes.d.ts +3 -0
  127. package/dist/mcp-client/routes.js +14 -0
  128. package/dist/notifications/routes.d.ts +1 -1
  129. package/dist/provider-api/actions/provider-api.d.ts +2 -2
  130. package/dist/server/agents-bundle.d.ts +2 -0
  131. package/dist/server/agents-bundle.js +31 -2
  132. package/dist/server/credential-provider.d.ts +8 -2
  133. package/dist/server/credential-provider.js +13 -9
  134. package/dist/shared/chat-first-app-creation.d.ts +19 -0
  135. package/dist/shared/chat-first-app-creation.js +75 -0
  136. package/dist/shared/document-title.d.ts +7 -0
  137. package/dist/shared/document-title.js +29 -0
  138. package/dist/shared/index.d.ts +2 -0
  139. package/dist/shared/index.js +2 -0
  140. package/dist/styles/chat-history-list.css +16 -0
  141. package/dist/templates/chat/.agents/skills/adding-a-feature/SKILL.md +9 -0
  142. package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +18 -6
  143. package/dist/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  144. package/dist/templates/default/.agents/skills/adding-a-feature/SKILL.md +9 -0
  145. package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +18 -6
  146. package/dist/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  147. package/dist/templates/default/react-router.config.ts +18 -0
  148. package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +18 -6
  149. package/dist/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  150. package/dist/templates/workspace-core/.agents/skills/adding-a-feature/SKILL.md +9 -0
  151. package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +18 -6
  152. package/dist/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  153. package/dist/templates/workspace-core/.agents/skills/workspace-conventions/SKILL.md +7 -0
  154. package/package.json +4 -3
  155. package/src/templates/chat/.agents/skills/adding-a-feature/SKILL.md +9 -0
  156. package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +18 -6
  157. package/src/templates/chat/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  158. package/src/templates/default/.agents/skills/adding-a-feature/SKILL.md +9 -0
  159. package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +18 -6
  160. package/src/templates/default/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  161. package/src/templates/default/react-router.config.ts +18 -0
  162. package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +18 -6
  163. package/src/templates/headless/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  164. package/src/templates/workspace-core/.agents/skills/adding-a-feature/SKILL.md +9 -0
  165. package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +18 -6
  166. package/src/templates/workspace-core/.agents/skills/turn-into-app/references/fresh-project.md +8 -1
  167. package/src/templates/workspace-core/.agents/skills/workspace-conventions/SKILL.md +7 -0
@@ -0,0 +1,889 @@
1
+ import {
2
+ open,
3
+ mkdir,
4
+ readFile,
5
+ rename,
6
+ unlink,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import {
13
+ closeDbExec,
14
+ getDbExec,
15
+ withMigrationRuntime,
16
+ } from "@agent-native/core/db";
17
+ import { runWithRequestContext } from "@agent-native/core/server";
18
+
19
+ import { output, parseArgs } from "../actions/helpers.js";
20
+ import {
21
+ acquireDedicatedFirstPartyAnalyticsBackfillLease,
22
+ getFirstPartyAnalyticsBigQueryBackfillJob,
23
+ } from "../server/jobs/analytics-bigquery-backfill.js";
24
+ import {
25
+ createFirstPartyAnalyticsInserter,
26
+ FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS,
27
+ getFirstPartyAnalyticsBackend,
28
+ saveFirstPartyAnalyticsBackend,
29
+ type FirstPartyAnalyticsScope,
30
+ } from "../server/lib/first-party-analytics-backend.js";
31
+
32
+ const DEFAULT_BATCH_SIZE = 1_000;
33
+ const MAX_BATCH_SIZE = 2_500;
34
+ const DEFAULT_CONCURRENCY = 2;
35
+ const MAX_CONCURRENCY = 4;
36
+ const DEFAULT_MAX_BATCHES = 1_000;
37
+ const MAX_MAX_BATCHES = 10_000;
38
+ const DEFAULT_CHECKPOINT_PATH = ".analytics-first-party-bigquery-backfill.json";
39
+ const QUERY_TIMEOUT_MS = 30_000;
40
+ const MAX_HYDRATION_IDS = 500;
41
+ const JOB_TABLE = "analytics_bigquery_backfill_jobs";
42
+ const FINALIZE_ALLOW_ENV =
43
+ "AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW";
44
+ const FINALIZE_QUIESCENT_ENV =
45
+ "AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT";
46
+
47
+ type BranchName = "org" | "legacy";
48
+
49
+ export interface BackfillCursor {
50
+ receivedAt: string;
51
+ id: string;
52
+ }
53
+
54
+ export interface BranchCheckpoint {
55
+ cutoff: BackfillCursor | null;
56
+ cursor: BackfillCursor | null;
57
+ copied: number;
58
+ complete: boolean;
59
+ }
60
+
61
+ export interface DedicatedBackfillCheckpoint {
62
+ version: 1;
63
+ scope: {
64
+ userEmail: string;
65
+ orgId: string;
66
+ };
67
+ table: string | null;
68
+ branches: Record<BranchName, BranchCheckpoint>;
69
+ updatedAt: string;
70
+ }
71
+
72
+ export interface DedicatedBackfillOptions {
73
+ scope: FirstPartyAnalyticsScope & { orgId: string };
74
+ table: string | null;
75
+ batchSize: number;
76
+ concurrency: number;
77
+ maxBatches: number;
78
+ checkpointPath: string;
79
+ }
80
+
81
+ export interface FinalizeResult {
82
+ mode: "finalize";
83
+ copied: number;
84
+ cursor: BackfillCursor | null;
85
+ jobId: string;
86
+ status: "completed";
87
+ }
88
+
89
+ interface DbQuery {
90
+ sql: string;
91
+ args: unknown[];
92
+ timeoutMs: number;
93
+ maxAttempts: 1;
94
+ }
95
+
96
+ interface DbExecutor {
97
+ execute(query: DbQuery): Promise<{ rows: unknown[] }>;
98
+ }
99
+
100
+ export interface CheckpointStore {
101
+ load(): Promise<DedicatedBackfillCheckpoint | null>;
102
+ save(checkpoint: DedicatedBackfillCheckpoint): Promise<void>;
103
+ }
104
+
105
+ export interface DedicatedBackfillDependencies {
106
+ db: DbExecutor;
107
+ upload: (rows: Array<Record<string, unknown>>) => Promise<number>;
108
+ checkpointStore: CheckpointStore;
109
+ now?: () => string;
110
+ onProgress?: (progress: {
111
+ branch: BranchName;
112
+ batches: number;
113
+ copiedThisRun: number;
114
+ copiedTotal: number;
115
+ }) => void;
116
+ }
117
+
118
+ export interface DedicatedBackfillResult {
119
+ batches: number;
120
+ copiedThisRun: number;
121
+ copiedTotal: number;
122
+ complete: boolean;
123
+ checkpoint: DedicatedBackfillCheckpoint;
124
+ }
125
+
126
+ interface BranchSpec {
127
+ name: BranchName;
128
+ predicate: string;
129
+ predicateArgs: string[];
130
+ }
131
+
132
+ const PROJECTED_COLUMNS = FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS.join(", ");
133
+
134
+ function positiveInteger(
135
+ raw: string | undefined,
136
+ name: string,
137
+ fallback: number,
138
+ maximum: number,
139
+ ): number {
140
+ if (raw === undefined) return fallback;
141
+ const value = Number(raw);
142
+ if (!Number.isInteger(value) || value < 1) {
143
+ throw new Error(`${name} must be a positive integer`);
144
+ }
145
+ return Math.min(value, maximum);
146
+ }
147
+
148
+ function requiredArg(args: Record<string, string>, name: string): string {
149
+ const value = args[name]?.trim();
150
+ if (!value) throw new Error(`--${name} is required`);
151
+ return value;
152
+ }
153
+
154
+ export function parseDedicatedBackfillOptions(
155
+ args = parseArgs(),
156
+ ): DedicatedBackfillOptions & { execute: boolean; finalize: boolean } {
157
+ const userEmail = requiredArg(args, "owner-email");
158
+ const orgId = requiredArg(args, "org-id");
159
+ return {
160
+ scope: { userEmail, orgId },
161
+ table: args.table?.trim() || null,
162
+ batchSize: positiveInteger(
163
+ args["batch-size"],
164
+ "--batch-size",
165
+ DEFAULT_BATCH_SIZE,
166
+ MAX_BATCH_SIZE,
167
+ ),
168
+ concurrency: positiveInteger(
169
+ args.concurrency,
170
+ "--concurrency",
171
+ DEFAULT_CONCURRENCY,
172
+ MAX_CONCURRENCY,
173
+ ),
174
+ maxBatches: positiveInteger(
175
+ args["max-batches"],
176
+ "--max-batches",
177
+ DEFAULT_MAX_BATCHES,
178
+ MAX_MAX_BATCHES,
179
+ ),
180
+ checkpointPath: resolve(args.checkpoint || DEFAULT_CHECKPOINT_PATH),
181
+ execute: args.execute === "true",
182
+ finalize: args.finalize === "true",
183
+ };
184
+ }
185
+
186
+ function branchSpecs(
187
+ scope: FirstPartyAnalyticsScope & { orgId: string },
188
+ ): BranchSpec[] {
189
+ return [
190
+ {
191
+ name: "org",
192
+ predicate: "org_id = ?",
193
+ predicateArgs: [scope.orgId],
194
+ },
195
+ {
196
+ name: "legacy",
197
+ predicate: "org_id IS NULL AND owner_email = ?",
198
+ predicateArgs: [scope.userEmail],
199
+ },
200
+ ];
201
+ }
202
+
203
+ export function buildHighWaterMarkQuery(spec: BranchSpec): DbQuery {
204
+ return {
205
+ sql: `SELECT received_at, id
206
+ FROM analytics_events
207
+ WHERE ${spec.predicate}
208
+ ORDER BY received_at DESC, id DESC
209
+ LIMIT 1`,
210
+ args: spec.predicateArgs,
211
+ timeoutMs: QUERY_TIMEOUT_MS,
212
+ maxAttempts: 1,
213
+ };
214
+ }
215
+
216
+ export function buildPageKeyQuery(
217
+ spec: BranchSpec,
218
+ state: BranchCheckpoint,
219
+ batchSize: number,
220
+ ): DbQuery {
221
+ if (!state.cutoff) {
222
+ throw new Error(`Cannot page ${spec.name} without a high-water mark`);
223
+ }
224
+ const args: unknown[] = [
225
+ ...spec.predicateArgs,
226
+ state.cutoff.receivedAt,
227
+ state.cutoff.receivedAt,
228
+ state.cutoff.id,
229
+ ];
230
+ let cursorClause = "";
231
+ if (state.cursor) {
232
+ cursorClause = " AND (received_at > ? OR (received_at = ? AND id > ?))";
233
+ args.push(
234
+ state.cursor.receivedAt,
235
+ state.cursor.receivedAt,
236
+ state.cursor.id,
237
+ );
238
+ }
239
+ args.push(batchSize);
240
+ return {
241
+ sql: `SELECT received_at, id
242
+ FROM analytics_events
243
+ WHERE ${spec.predicate}
244
+ AND (received_at < ? OR (received_at = ? AND id <= ?))${cursorClause}
245
+ ORDER BY received_at ASC, id ASC
246
+ LIMIT ?`,
247
+ args,
248
+ timeoutMs: QUERY_TIMEOUT_MS,
249
+ maxAttempts: 1,
250
+ };
251
+ }
252
+
253
+ export function buildHydrationQuery(ids: string[]): DbQuery {
254
+ if (!ids.length || ids.length > MAX_HYDRATION_IDS) {
255
+ throw new Error(
256
+ `BigQuery backfill hydration requires 1-${MAX_HYDRATION_IDS} ids`,
257
+ );
258
+ }
259
+ return {
260
+ sql: `SELECT ${PROJECTED_COLUMNS}
261
+ FROM analytics_events
262
+ WHERE id IN (${ids.map(() => "?").join(", ")})`,
263
+ args: ids,
264
+ timeoutMs: QUERY_TIMEOUT_MS,
265
+ maxAttempts: 1,
266
+ };
267
+ }
268
+
269
+ function cursorFromRow(row: unknown, description: string): BackfillCursor {
270
+ if (!row || typeof row !== "object") {
271
+ throw new Error(`${description} is not an object`);
272
+ }
273
+ const record = row as Record<string, unknown>;
274
+ const receivedAt = record.received_at;
275
+ const id = record.id;
276
+ if (typeof receivedAt !== "string" || !receivedAt) {
277
+ throw new Error(`${description} is missing received_at`);
278
+ }
279
+ if (typeof id !== "string" || !id) {
280
+ throw new Error(`${description} is missing id`);
281
+ }
282
+ return { receivedAt, id };
283
+ }
284
+
285
+ function assertCheckpointCursor(
286
+ cursor: unknown,
287
+ description: string,
288
+ ): asserts cursor is BackfillCursor {
289
+ if (!cursor || typeof cursor !== "object") {
290
+ throw new Error(`Backfill checkpoint ${description} is not an object`);
291
+ }
292
+ const record = cursor as Record<string, unknown>;
293
+ if (typeof record.receivedAt !== "string" || !record.receivedAt) {
294
+ throw new Error(`Backfill checkpoint ${description} is missing receivedAt`);
295
+ }
296
+ if (typeof record.id !== "string" || !record.id) {
297
+ throw new Error(`Backfill checkpoint ${description} is missing id`);
298
+ }
299
+ }
300
+
301
+ function emptyBranchCheckpoint(): BranchCheckpoint {
302
+ return { cutoff: null, cursor: null, copied: 0, complete: false };
303
+ }
304
+
305
+ function newCheckpoint(
306
+ options: DedicatedBackfillOptions,
307
+ now: string,
308
+ ): DedicatedBackfillCheckpoint {
309
+ return {
310
+ version: 1,
311
+ scope: {
312
+ userEmail: options.scope.userEmail,
313
+ orgId: options.scope.orgId,
314
+ },
315
+ table: options.table,
316
+ branches: {
317
+ org: emptyBranchCheckpoint(),
318
+ legacy: emptyBranchCheckpoint(),
319
+ },
320
+ updatedAt: now,
321
+ };
322
+ }
323
+
324
+ function assertCheckpointMatches(
325
+ checkpoint: DedicatedBackfillCheckpoint,
326
+ options: DedicatedBackfillOptions,
327
+ ): void {
328
+ if (checkpoint.version !== 1) {
329
+ throw new Error(
330
+ `Unsupported backfill checkpoint version: ${checkpoint.version}`,
331
+ );
332
+ }
333
+ if (
334
+ checkpoint.scope.userEmail !== options.scope.userEmail ||
335
+ checkpoint.scope.orgId !== options.scope.orgId
336
+ ) {
337
+ throw new Error(
338
+ "Backfill checkpoint scope does not match --owner-email/--org-id",
339
+ );
340
+ }
341
+ if (checkpoint.table !== options.table) {
342
+ throw new Error(
343
+ "Backfill checkpoint table does not match the current BigQuery table",
344
+ );
345
+ }
346
+ for (const branch of ["org", "legacy"] as const) {
347
+ const state = checkpoint.branches[branch];
348
+ if (!state)
349
+ throw new Error(`Backfill checkpoint is missing ${branch} state`);
350
+ if (!Number.isInteger(state.copied) || state.copied < 0) {
351
+ throw new Error(`Backfill checkpoint has an invalid ${branch} count`);
352
+ }
353
+ if (state.cutoff) {
354
+ assertCheckpointCursor(state.cutoff, `${branch} cutoff`);
355
+ }
356
+ if (state.cursor) {
357
+ assertCheckpointCursor(state.cursor, `${branch} cursor`);
358
+ }
359
+ if (state.cursor && !state.cutoff) {
360
+ throw new Error(
361
+ `Backfill checkpoint has a ${branch} cursor without a cutoff`,
362
+ );
363
+ }
364
+ }
365
+ }
366
+
367
+ function latestCursor(
368
+ checkpoint: DedicatedBackfillCheckpoint,
369
+ ): BackfillCursor | null {
370
+ const cursors = [
371
+ checkpoint.branches.org.cursor,
372
+ checkpoint.branches.legacy.cursor,
373
+ ]
374
+ .filter((cursor): cursor is BackfillCursor => cursor !== null)
375
+ .sort(
376
+ (left, right) =>
377
+ left.receivedAt.localeCompare(right.receivedAt) ||
378
+ left.id.localeCompare(right.id),
379
+ );
380
+ return cursors[cursors.length - 1] ?? null;
381
+ }
382
+
383
+ function compareCursors(left: BackfillCursor, right: BackfillCursor): number {
384
+ return (
385
+ left.receivedAt.localeCompare(right.receivedAt) ||
386
+ left.id.localeCompare(right.id)
387
+ );
388
+ }
389
+
390
+ async function hydratePageRows(
391
+ keyRows: Array<Record<string, unknown>>,
392
+ db: DbExecutor,
393
+ ): Promise<Array<Record<string, unknown>>> {
394
+ const rowsById = new Map<string, Record<string, unknown>>();
395
+ for (let offset = 0; offset < keyRows.length; offset += MAX_HYDRATION_IDS) {
396
+ const ids = keyRows
397
+ .slice(offset, offset + MAX_HYDRATION_IDS)
398
+ .map((row, index) => {
399
+ const cursor = cursorFromRow(row, `backfill key row ${offset + index}`);
400
+ return cursor.id;
401
+ });
402
+ const result = await db.execute(buildHydrationQuery(ids));
403
+ for (const row of result.rows as Array<Record<string, unknown>>) {
404
+ const id = row.id;
405
+ if (typeof id !== "string" || !id) {
406
+ throw new Error("Backfill hydration row is missing id");
407
+ }
408
+ rowsById.set(id, row);
409
+ }
410
+ }
411
+
412
+ return keyRows.map((keyRow, index) => {
413
+ const { id } = cursorFromRow(keyRow, `backfill key row ${index}`);
414
+ const row = rowsById.get(id);
415
+ if (!row) {
416
+ throw new Error(`Backfill row ${id} disappeared during hydration`);
417
+ }
418
+ return row;
419
+ });
420
+ }
421
+
422
+ export async function assertSourceAtCheckpoint(
423
+ scope: DedicatedBackfillOptions["scope"],
424
+ checkpoint: DedicatedBackfillCheckpoint,
425
+ db: DbExecutor = getDbExec(),
426
+ ): Promise<void> {
427
+ for (const spec of branchSpecs(scope)) {
428
+ const result = await db.execute(buildHighWaterMarkQuery(spec));
429
+ const latestRow = result.rows[0];
430
+ if (!latestRow) continue;
431
+ const latest = cursorFromRow(
432
+ latestRow,
433
+ `${spec.name} final high-water mark`,
434
+ );
435
+ const cutoff = checkpoint.branches[spec.name].cutoff;
436
+ if (!cutoff || compareCursors(latest, cutoff) > 0) {
437
+ throw new Error(
438
+ `Refusing to finalize: ${spec.name} branch has events after the checkpoint cutoff; quiesce source writes and retry the finalization`,
439
+ );
440
+ }
441
+ }
442
+ }
443
+
444
+ export class JsonCheckpointStore implements CheckpointStore {
445
+ constructor(private readonly path: string) {}
446
+
447
+ async load(): Promise<DedicatedBackfillCheckpoint | null> {
448
+ try {
449
+ const raw = await readFile(this.path, "utf8");
450
+ const parsed: unknown = JSON.parse(raw);
451
+ if (!parsed || typeof parsed !== "object") {
452
+ throw new Error("checkpoint is not an object");
453
+ }
454
+ return parsed as DedicatedBackfillCheckpoint;
455
+ } catch (error) {
456
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
457
+ throw new Error(
458
+ `Unable to read backfill checkpoint ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
459
+ );
460
+ }
461
+ }
462
+
463
+ async save(checkpoint: DedicatedBackfillCheckpoint): Promise<void> {
464
+ await mkdir(dirname(this.path), { recursive: true });
465
+ const temporaryPath = `${this.path}.${process.pid}.tmp`;
466
+ await writeFile(temporaryPath, `${JSON.stringify(checkpoint, null, 2)}\n`, {
467
+ encoding: "utf8",
468
+ mode: 0o600,
469
+ });
470
+ await rename(temporaryPath, this.path);
471
+ }
472
+ }
473
+
474
+ async function refreshBranchCutoff(
475
+ spec: BranchSpec,
476
+ state: BranchCheckpoint,
477
+ checkpoint: DedicatedBackfillCheckpoint,
478
+ dependencies: DedicatedBackfillDependencies,
479
+ now: () => string,
480
+ ): Promise<void> {
481
+ const result = await dependencies.db.execute(buildHighWaterMarkQuery(spec));
482
+ const row = result.rows[0];
483
+ const latest = row
484
+ ? cursorFromRow(row, `${spec.name} high-water mark`)
485
+ : null;
486
+ if (latest && (!state.cutoff || compareCursors(latest, state.cutoff) > 0)) {
487
+ state.cutoff = latest;
488
+ state.complete = false;
489
+ } else {
490
+ state.complete = true;
491
+ }
492
+ checkpoint.updatedAt = now();
493
+ await dependencies.checkpointStore.save(checkpoint);
494
+ }
495
+
496
+ export async function acquireCheckpointLock(
497
+ checkpointPath: string,
498
+ ): Promise<() => Promise<void>> {
499
+ const lockPath = `${checkpointPath}.lock`;
500
+ await mkdir(dirname(lockPath), { recursive: true });
501
+ let handle: Awaited<ReturnType<typeof open>>;
502
+ try {
503
+ handle = await open(lockPath, "wx", 0o600);
504
+ } catch (error) {
505
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
506
+ throw new Error(
507
+ `Another backfill worker owns ${lockPath}; inspect it before removing the lock`,
508
+ );
509
+ }
510
+ throw error;
511
+ }
512
+ try {
513
+ await handle.writeFile(
514
+ `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
515
+ "utf8",
516
+ );
517
+ } catch (error) {
518
+ await handle.close();
519
+ await unlink(lockPath).catch(() => undefined);
520
+ throw error;
521
+ }
522
+ await handle.close();
523
+ return async () => {
524
+ await unlink(lockPath).catch((error: unknown) => {
525
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
526
+ });
527
+ };
528
+ }
529
+
530
+ export async function runDedicatedBackfill(
531
+ options: DedicatedBackfillOptions,
532
+ dependencies: DedicatedBackfillDependencies,
533
+ ): Promise<DedicatedBackfillResult> {
534
+ const now = dependencies.now ?? (() => new Date().toISOString());
535
+ const checkpoint =
536
+ (await dependencies.checkpointStore.load()) ??
537
+ newCheckpoint(options, now());
538
+ assertCheckpointMatches(checkpoint, options);
539
+ await dependencies.checkpointStore.save(checkpoint);
540
+
541
+ let batches = 0;
542
+ let copiedThisRun = 0;
543
+ let stop = false;
544
+ for (const spec of branchSpecs(options.scope)) {
545
+ const state = checkpoint.branches[spec.name];
546
+ if (state.complete) continue;
547
+
548
+ if (!state.cutoff) {
549
+ await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
550
+ if (state.complete) continue;
551
+ }
552
+
553
+ while (!state.complete) {
554
+ if (batches >= options.maxBatches) {
555
+ stop = true;
556
+ break;
557
+ }
558
+ const keyResult = await dependencies.db.execute(
559
+ buildPageKeyQuery(spec, state, options.batchSize),
560
+ );
561
+ const keyRows = keyResult.rows as Array<Record<string, unknown>>;
562
+ if (!keyRows.length) {
563
+ await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
564
+ if (state.complete) break;
565
+ continue;
566
+ }
567
+ const rows = await hydratePageRows(keyRows, dependencies.db);
568
+
569
+ const copied = await dependencies.upload(rows);
570
+ if (copied !== rows.length) {
571
+ throw new Error(
572
+ `BigQuery acknowledged ${copied} of ${rows.length} rows; checkpoint was not advanced`,
573
+ );
574
+ }
575
+
576
+ state.cursor = cursorFromRow(rows[rows.length - 1], `${spec.name} page`);
577
+ state.copied += copied;
578
+ copiedThisRun += copied;
579
+ batches += 1;
580
+ state.complete = false;
581
+ checkpoint.updatedAt = now();
582
+ await dependencies.checkpointStore.save(checkpoint);
583
+ dependencies.onProgress?.({
584
+ branch: spec.name,
585
+ batches,
586
+ copiedThisRun,
587
+ copiedTotal: state.copied,
588
+ });
589
+ if (rows.length < options.batchSize) {
590
+ await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
591
+ }
592
+ }
593
+ if (stop) break;
594
+ }
595
+
596
+ return {
597
+ batches,
598
+ copiedThisRun,
599
+ copiedTotal:
600
+ checkpoint.branches.org.copied + checkpoint.branches.legacy.copied,
601
+ complete:
602
+ checkpoint.branches.org.complete && checkpoint.branches.legacy.complete,
603
+ checkpoint,
604
+ };
605
+ }
606
+
607
+ const USAGE = `Dedicated first-party Analytics Neon -> BigQuery backfill
608
+
609
+ Dry-run (default):
610
+ pnpm backfill:first-party-bigquery --owner-email=<email> --org-id=<org>
611
+
612
+ Execute only after an explicit production approval:
613
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 pnpm backfill:first-party-bigquery \
614
+ --execute --owner-email=<email> --org-id=<org>
615
+
616
+ Options:
617
+ --table=<dataset.table> Optional override, must match the approved table
618
+ --batch-size=<n> Source page size, default 1000, max 2500
619
+ --concurrency=<n> BigQuery requests in flight, default 2, max 4
620
+ --max-batches=<n> Work budget per invocation, default 1000
621
+ --checkpoint=<path> Atomic local checkpoint path
622
+
623
+ After the checkpoint is complete, reconcile it into the shipped durable job
624
+ after quiescing source event writes and checking the final high-water marks:
625
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW=1 \
626
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT=1 \
627
+ pnpm backfill:first-party-bigquery \
628
+ --finalize --owner-email=<email> --org-id=<org>
629
+ `;
630
+
631
+ async function runApprovedWorker(
632
+ options: DedicatedBackfillOptions,
633
+ ): Promise<DedicatedBackfillResult> {
634
+ let releaseLock: (() => Promise<void>) | null = null;
635
+ let releaseLease: (() => Promise<void>) | null = null;
636
+ try {
637
+ releaseLock = await acquireCheckpointLock(options.checkpointPath);
638
+ return await withMigrationRuntime(async () =>
639
+ runWithRequestContext(options.scope, async () => {
640
+ const backend = await getFirstPartyAnalyticsBackend(options.scope);
641
+ if (backend.sink !== "dual") {
642
+ throw new Error(
643
+ `Refusing to backfill while the source sink is ${backend.sink}; prepare must establish dual-write first`,
644
+ );
645
+ }
646
+ if (options.table && backend.table && options.table !== backend.table) {
647
+ throw new Error(
648
+ "--table does not match the scoped first-party Analytics backend setting",
649
+ );
650
+ }
651
+ const configuredTable = options.table ?? backend.table;
652
+ if (!configuredTable) {
653
+ throw new Error(
654
+ "Refusing to start the dedicated backfill without a configured BigQuery table",
655
+ );
656
+ }
657
+ const workerOptions = { ...options, table: configuredTable };
658
+ releaseLease = await acquireDedicatedFirstPartyAnalyticsBackfillLease(
659
+ options.scope,
660
+ configuredTable,
661
+ );
662
+ const insertRows = await createFirstPartyAnalyticsInserter(
663
+ configuredTable,
664
+ {
665
+ maxRowsPerRequest: 500,
666
+ maxConcurrentRequests: options.concurrency,
667
+ },
668
+ );
669
+ const result = await runDedicatedBackfill(workerOptions, {
670
+ db: getDbExec(),
671
+ upload: (rows) => insertRows(rows),
672
+ checkpointStore: new JsonCheckpointStore(options.checkpointPath),
673
+ onProgress: (progress) => {
674
+ console.error(JSON.stringify({ type: "progress", ...progress }));
675
+ },
676
+ });
677
+ return result;
678
+ }),
679
+ );
680
+ } finally {
681
+ const leaseToRelease = releaseLease as (() => Promise<void>) | null;
682
+ const lockToRelease = releaseLock as (() => Promise<void>) | null;
683
+ if (leaseToRelease) {
684
+ try {
685
+ await leaseToRelease();
686
+ } finally {
687
+ if (lockToRelease) {
688
+ try {
689
+ await closeDbExec();
690
+ } finally {
691
+ await lockToRelease();
692
+ }
693
+ }
694
+ }
695
+ } else if (lockToRelease) {
696
+ try {
697
+ await closeDbExec();
698
+ } finally {
699
+ await lockToRelease();
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ async function runApprovedFinalize(
706
+ options: DedicatedBackfillOptions,
707
+ ): Promise<FinalizeResult> {
708
+ let releaseLock: (() => Promise<void>) | null = null;
709
+ try {
710
+ releaseLock = await acquireCheckpointLock(options.checkpointPath);
711
+ return await withMigrationRuntime(async () =>
712
+ runWithRequestContext(options.scope, async () => {
713
+ const backend = await getFirstPartyAnalyticsBackend(options.scope);
714
+ if (backend.sink !== "dual") {
715
+ throw new Error(
716
+ `Refusing to finalize while the source sink is ${backend.sink}; prepare must establish dual-write first`,
717
+ );
718
+ }
719
+ const configuredTable = options.table ?? backend.table;
720
+ const finalizeOptions = { ...options, table: configuredTable };
721
+ const checkpoint = await new JsonCheckpointStore(
722
+ options.checkpointPath,
723
+ ).load();
724
+ if (!checkpoint) {
725
+ throw new Error(
726
+ `Backfill checkpoint ${options.checkpointPath} does not exist`,
727
+ );
728
+ }
729
+ assertCheckpointMatches(checkpoint, finalizeOptions);
730
+ if (
731
+ !checkpoint.branches.org.complete ||
732
+ !checkpoint.branches.legacy.complete
733
+ ) {
734
+ throw new Error(
735
+ "Refusing to finalize before both organization and legacy-owner branches are complete",
736
+ );
737
+ }
738
+
739
+ const job = await getFirstPartyAnalyticsBigQueryBackfillJob(
740
+ options.scope,
741
+ );
742
+ if (!job) {
743
+ throw new Error(
744
+ "Prepare the organization before reconciling the dedicated backfill checkpoint",
745
+ );
746
+ }
747
+ if (job.table !== checkpoint.table) {
748
+ throw new Error(
749
+ `Durable backfill targets ${job.table}, checkpoint targets ${checkpoint.table}`,
750
+ );
751
+ }
752
+ if (job.status === "completed") {
753
+ return {
754
+ mode: "finalize",
755
+ copied:
756
+ checkpoint.branches.org.copied +
757
+ checkpoint.branches.legacy.copied,
758
+ cursor: latestCursor(checkpoint),
759
+ jobId: job.id,
760
+ status: "completed",
761
+ };
762
+ }
763
+ if (
764
+ job.status === "running" &&
765
+ (!job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) > Date.now())
766
+ ) {
767
+ throw new Error(
768
+ "The shipped backfill worker currently owns the durable job lease; wait for it to stop before finalizing",
769
+ );
770
+ }
771
+
772
+ await assertSourceAtCheckpoint(options.scope, checkpoint);
773
+
774
+ const cursor = latestCursor(checkpoint);
775
+ const now = new Date().toISOString();
776
+ const update = await getDbExec().execute({
777
+ sql: `UPDATE ${JOB_TABLE}
778
+ SET status = 'completed', backfill_cursor = ?,
779
+ copied_count = ?, lease_token = NULL,
780
+ lease_expires_at = NULL, next_run_at = ?,
781
+ last_error = NULL, completed_at = ?, updated_at = ?
782
+ WHERE id = ? AND table_ref = ?
783
+ AND (status = 'pending'
784
+ OR (status = 'running' AND lease_expires_at IS NOT NULL
785
+ AND lease_expires_at <= ?))`,
786
+ args: [
787
+ cursor ? JSON.stringify(cursor) : null,
788
+ Math.max(
789
+ job.copied,
790
+ checkpoint.branches.org.copied +
791
+ checkpoint.branches.legacy.copied,
792
+ ),
793
+ now,
794
+ now,
795
+ now,
796
+ job.id,
797
+ checkpoint.table,
798
+ now,
799
+ ],
800
+ timeoutMs: 5_000,
801
+ maxAttempts: 1,
802
+ });
803
+ if (update.rowsAffected !== 1) {
804
+ throw new Error(
805
+ "The durable backfill job changed while it was being finalized; inspect status before retrying",
806
+ );
807
+ }
808
+ await saveFirstPartyAnalyticsBackend(options.scope, {
809
+ ...backend,
810
+ table: checkpoint.table,
811
+ backfillCursor: cursor ? JSON.stringify(cursor) : null,
812
+ backfillCompleted: true,
813
+ });
814
+ return {
815
+ mode: "finalize",
816
+ copied:
817
+ checkpoint.branches.org.copied + checkpoint.branches.legacy.copied,
818
+ cursor,
819
+ jobId: job.id,
820
+ status: "completed",
821
+ };
822
+ }),
823
+ );
824
+ } finally {
825
+ if (releaseLock) {
826
+ try {
827
+ await closeDbExec();
828
+ } finally {
829
+ await releaseLock();
830
+ }
831
+ }
832
+ }
833
+ }
834
+
835
+ async function main(): Promise<void> {
836
+ const args = parseArgs();
837
+ if (args.help) {
838
+ console.log(USAGE);
839
+ return;
840
+ }
841
+ const options = parseDedicatedBackfillOptions(args);
842
+ if (options.execute && options.finalize) {
843
+ throw new Error("Choose either --execute or --finalize, not both");
844
+ }
845
+ if (!options.execute && !options.finalize) {
846
+ output({
847
+ mode: "dry-run",
848
+ batchSize: options.batchSize,
849
+ concurrency: options.concurrency,
850
+ maxBatches: options.maxBatches,
851
+ checkpointPath: options.checkpointPath,
852
+ message:
853
+ "No data was copied. Pass --execute and AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 after production approval.",
854
+ });
855
+ return;
856
+ }
857
+ if (options.finalize) {
858
+ if (process.env[FINALIZE_ALLOW_ENV] !== "1") {
859
+ throw new Error(
860
+ `Checkpoint finalization is disabled. Set ${FINALIZE_ALLOW_ENV}=1 only after explicit production approval.`,
861
+ );
862
+ }
863
+ if (process.env[FINALIZE_QUIESCENT_ENV] !== "1") {
864
+ throw new Error(
865
+ `Checkpoint finalization requires source event writes to be quiesced. Set ${FINALIZE_QUIESCENT_ENV}=1 only after the final high-water check and explicit production approval.`,
866
+ );
867
+ }
868
+ output(await runApprovedFinalize(options));
869
+ return;
870
+ }
871
+ if (process.env.AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW !== "1") {
872
+ throw new Error(
873
+ "Production execution is disabled. Set AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 only after explicit approval.",
874
+ );
875
+ }
876
+ output(await runApprovedWorker(options));
877
+ }
878
+
879
+ if (
880
+ process.argv[1] &&
881
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
882
+ ) {
883
+ try {
884
+ await main();
885
+ } catch (error) {
886
+ console.error(error instanceof Error ? error.message : String(error));
887
+ process.exitCode = 1;
888
+ }
889
+ }