@agent-native/core 0.146.6 → 0.146.8

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.
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 7921
34
+ - template files: 7922
@@ -183,6 +183,65 @@ Conventions:
183
183
  - Use `fatal()` for required arg validation
184
184
  - `helpers.ts` loads `dotenv/config` so env vars are available
185
185
 
186
+ ### Dedicated first-party Analytics backfill
187
+
188
+ `backfill:first-party-bigquery` is a resumable, bounded Neon-to-BigQuery worker
189
+ for an explicitly selected organization. It is dry-run by default. It uses the
190
+ scoped credential resolver, fixed high-water marks, separate organization and
191
+ legacy-owner cursors, atomic local checkpoints, a local lock, and a durable
192
+ database lease shared with the shipped worker. The tuple-paging indexes are
193
+ installed by the additive analytics migration. It does not change the Analytics
194
+ sink or perform cutover.
195
+
196
+ ```bash
197
+ pnpm backfill:first-party-bigquery \
198
+ --owner-email=<owner-email> \
199
+ --org-id=<org-id>
200
+ ```
201
+
202
+ Production execution requires both `--execute` and the explicit
203
+ `AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1` environment gate. Start with a bounded
204
+ `--max-batches` budget and increase concurrency only after checking database
205
+ health between runs. The dedicated worker claims the durable migration-job
206
+ lease, so the shipped worker and another operator using a different checkpoint
207
+ path cannot copy the same job concurrently. A crashed dedicated worker leaves
208
+ that lease to expire before the shipped worker can resume it.
209
+
210
+ ```bash
211
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 pnpm backfill:first-party-bigquery \
212
+ --execute \
213
+ --owner-email=<owner-email> \
214
+ --org-id=<org-id> \
215
+ --batch-size=1000 \
216
+ --concurrency=2 \
217
+ --max-batches=100
218
+ ```
219
+
220
+ The checkpoint file contains cursors and counts, not event payloads or
221
+ credentials. A short page triggers a fresh high-water check so rows written
222
+ while the worker is running are included. A partial BigQuery acknowledgement
223
+ does not advance the cursor; retry it promptly, or run an insertId-deduplication
224
+ or `MERGE` pass on the target table before finalizing.
225
+
226
+ Before finalization, quiesce the source event writes and verify the final
227
+ high-water marks. The `--owner-email` and `--org-id` values are trusted operator
228
+ inputs, so execution is limited to the approved production operator and the
229
+ two explicit environment gates. Then use the separate `--finalize` command
230
+ with both gates below. The command rechecks the source high-water marks before
231
+ marking the durable job complete; the normal migration action's approval gate
232
+ still controls cutover:
233
+
234
+ ```bash
235
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW=1 \
236
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT=1 \
237
+ pnpm backfill:first-party-bigquery \
238
+ --finalize \
239
+ --owner-email=<owner-email> \
240
+ --org-id=<org-id>
241
+ ```
242
+
243
+ Do not delete a lock file until the owning process has been verified stopped.
244
+
186
245
  ## TypeScript Everywhere
187
246
 
188
247
  All code in this project must be TypeScript (`.ts`). Never create `.js`, `.cjs`, or `.mjs` files. Node 22+ runs `.ts` files natively, so no compilation step is needed for scripts. Use ESM imports (`import`), not CommonJS (`require`).
@@ -11,6 +11,7 @@
11
11
  "format.fix": "oxfmt --write .",
12
12
  "typecheck": "agent-native typecheck",
13
13
  "migrate:production": "tsx scripts/migrate-production.ts",
14
+ "backfill:first-party-bigquery": "tsx scripts/first-party-analytics-bigquery-backfill.ts",
14
15
  "action": "agent-native action",
15
16
  "script": "agent-native script"
16
17
  },
@@ -0,0 +1,839 @@
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 JOB_TABLE = "analytics_bigquery_backfill_jobs";
41
+ const FINALIZE_ALLOW_ENV =
42
+ "AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW";
43
+ const FINALIZE_QUIESCENT_ENV =
44
+ "AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT";
45
+
46
+ type BranchName = "org" | "legacy";
47
+
48
+ export interface BackfillCursor {
49
+ receivedAt: string;
50
+ id: string;
51
+ }
52
+
53
+ export interface BranchCheckpoint {
54
+ cutoff: BackfillCursor | null;
55
+ cursor: BackfillCursor | null;
56
+ copied: number;
57
+ complete: boolean;
58
+ }
59
+
60
+ export interface DedicatedBackfillCheckpoint {
61
+ version: 1;
62
+ scope: {
63
+ userEmail: string;
64
+ orgId: string;
65
+ };
66
+ table: string | null;
67
+ branches: Record<BranchName, BranchCheckpoint>;
68
+ updatedAt: string;
69
+ }
70
+
71
+ export interface DedicatedBackfillOptions {
72
+ scope: FirstPartyAnalyticsScope & { orgId: string };
73
+ table: string | null;
74
+ batchSize: number;
75
+ concurrency: number;
76
+ maxBatches: number;
77
+ checkpointPath: string;
78
+ }
79
+
80
+ export interface FinalizeResult {
81
+ mode: "finalize";
82
+ copied: number;
83
+ cursor: BackfillCursor | null;
84
+ jobId: string;
85
+ status: "completed";
86
+ }
87
+
88
+ interface DbQuery {
89
+ sql: string;
90
+ args: unknown[];
91
+ timeoutMs: number;
92
+ maxAttempts: 1;
93
+ }
94
+
95
+ interface DbExecutor {
96
+ execute(query: DbQuery): Promise<{ rows: unknown[] }>;
97
+ }
98
+
99
+ export interface CheckpointStore {
100
+ load(): Promise<DedicatedBackfillCheckpoint | null>;
101
+ save(checkpoint: DedicatedBackfillCheckpoint): Promise<void>;
102
+ }
103
+
104
+ export interface DedicatedBackfillDependencies {
105
+ db: DbExecutor;
106
+ upload: (rows: Array<Record<string, unknown>>) => Promise<number>;
107
+ checkpointStore: CheckpointStore;
108
+ now?: () => string;
109
+ onProgress?: (progress: {
110
+ branch: BranchName;
111
+ batches: number;
112
+ copiedThisRun: number;
113
+ copiedTotal: number;
114
+ }) => void;
115
+ }
116
+
117
+ export interface DedicatedBackfillResult {
118
+ batches: number;
119
+ copiedThisRun: number;
120
+ copiedTotal: number;
121
+ complete: boolean;
122
+ checkpoint: DedicatedBackfillCheckpoint;
123
+ }
124
+
125
+ interface BranchSpec {
126
+ name: BranchName;
127
+ predicate: string;
128
+ predicateArgs: string[];
129
+ }
130
+
131
+ const PROJECTED_COLUMNS = FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS.join(", ");
132
+
133
+ function positiveInteger(
134
+ raw: string | undefined,
135
+ name: string,
136
+ fallback: number,
137
+ maximum: number,
138
+ ): number {
139
+ if (raw === undefined) return fallback;
140
+ const value = Number(raw);
141
+ if (!Number.isInteger(value) || value < 1) {
142
+ throw new Error(`${name} must be a positive integer`);
143
+ }
144
+ return Math.min(value, maximum);
145
+ }
146
+
147
+ function requiredArg(args: Record<string, string>, name: string): string {
148
+ const value = args[name]?.trim();
149
+ if (!value) throw new Error(`--${name} is required`);
150
+ return value;
151
+ }
152
+
153
+ export function parseDedicatedBackfillOptions(
154
+ args = parseArgs(),
155
+ ): DedicatedBackfillOptions & { execute: boolean; finalize: boolean } {
156
+ const userEmail = requiredArg(args, "owner-email");
157
+ const orgId = requiredArg(args, "org-id");
158
+ return {
159
+ scope: { userEmail, orgId },
160
+ table: args.table?.trim() || null,
161
+ batchSize: positiveInteger(
162
+ args["batch-size"],
163
+ "--batch-size",
164
+ DEFAULT_BATCH_SIZE,
165
+ MAX_BATCH_SIZE,
166
+ ),
167
+ concurrency: positiveInteger(
168
+ args.concurrency,
169
+ "--concurrency",
170
+ DEFAULT_CONCURRENCY,
171
+ MAX_CONCURRENCY,
172
+ ),
173
+ maxBatches: positiveInteger(
174
+ args["max-batches"],
175
+ "--max-batches",
176
+ DEFAULT_MAX_BATCHES,
177
+ MAX_MAX_BATCHES,
178
+ ),
179
+ checkpointPath: resolve(args.checkpoint || DEFAULT_CHECKPOINT_PATH),
180
+ execute: args.execute === "true",
181
+ finalize: args.finalize === "true",
182
+ };
183
+ }
184
+
185
+ function branchSpecs(
186
+ scope: FirstPartyAnalyticsScope & { orgId: string },
187
+ ): BranchSpec[] {
188
+ return [
189
+ {
190
+ name: "org",
191
+ predicate: "org_id = ?",
192
+ predicateArgs: [scope.orgId],
193
+ },
194
+ {
195
+ name: "legacy",
196
+ predicate: "org_id IS NULL AND owner_email = ?",
197
+ predicateArgs: [scope.userEmail],
198
+ },
199
+ ];
200
+ }
201
+
202
+ export function buildHighWaterMarkQuery(spec: BranchSpec): DbQuery {
203
+ return {
204
+ sql: `SELECT received_at, id
205
+ FROM analytics_events
206
+ WHERE ${spec.predicate}
207
+ ORDER BY received_at DESC, id DESC
208
+ LIMIT 1`,
209
+ args: spec.predicateArgs,
210
+ timeoutMs: QUERY_TIMEOUT_MS,
211
+ maxAttempts: 1,
212
+ };
213
+ }
214
+
215
+ export function buildPageQuery(
216
+ spec: BranchSpec,
217
+ state: BranchCheckpoint,
218
+ batchSize: number,
219
+ ): DbQuery {
220
+ if (!state.cutoff) {
221
+ throw new Error(`Cannot page ${spec.name} without a high-water mark`);
222
+ }
223
+ const args: unknown[] = [
224
+ ...spec.predicateArgs,
225
+ state.cutoff.receivedAt,
226
+ state.cutoff.receivedAt,
227
+ state.cutoff.id,
228
+ ];
229
+ let cursorClause = "";
230
+ if (state.cursor) {
231
+ cursorClause = " AND (received_at > ? OR (received_at = ? AND id > ?))";
232
+ args.push(
233
+ state.cursor.receivedAt,
234
+ state.cursor.receivedAt,
235
+ state.cursor.id,
236
+ );
237
+ }
238
+ args.push(batchSize);
239
+ return {
240
+ sql: `SELECT ${PROJECTED_COLUMNS}
241
+ FROM analytics_events
242
+ WHERE ${spec.predicate}
243
+ AND (received_at < ? OR (received_at = ? AND id <= ?))${cursorClause}
244
+ ORDER BY received_at ASC, id ASC
245
+ LIMIT ?`,
246
+ args,
247
+ timeoutMs: QUERY_TIMEOUT_MS,
248
+ maxAttempts: 1,
249
+ };
250
+ }
251
+
252
+ function cursorFromRow(row: unknown, description: string): BackfillCursor {
253
+ if (!row || typeof row !== "object") {
254
+ throw new Error(`${description} is not an object`);
255
+ }
256
+ const record = row as Record<string, unknown>;
257
+ const receivedAt = record.received_at;
258
+ const id = record.id;
259
+ if (typeof receivedAt !== "string" || !receivedAt) {
260
+ throw new Error(`${description} is missing received_at`);
261
+ }
262
+ if (typeof id !== "string" || !id) {
263
+ throw new Error(`${description} is missing id`);
264
+ }
265
+ return { receivedAt, id };
266
+ }
267
+
268
+ function assertCheckpointCursor(
269
+ cursor: unknown,
270
+ description: string,
271
+ ): asserts cursor is BackfillCursor {
272
+ if (!cursor || typeof cursor !== "object") {
273
+ throw new Error(`Backfill checkpoint ${description} is not an object`);
274
+ }
275
+ const record = cursor as Record<string, unknown>;
276
+ if (typeof record.receivedAt !== "string" || !record.receivedAt) {
277
+ throw new Error(`Backfill checkpoint ${description} is missing receivedAt`);
278
+ }
279
+ if (typeof record.id !== "string" || !record.id) {
280
+ throw new Error(`Backfill checkpoint ${description} is missing id`);
281
+ }
282
+ }
283
+
284
+ function emptyBranchCheckpoint(): BranchCheckpoint {
285
+ return { cutoff: null, cursor: null, copied: 0, complete: false };
286
+ }
287
+
288
+ function newCheckpoint(
289
+ options: DedicatedBackfillOptions,
290
+ now: string,
291
+ ): DedicatedBackfillCheckpoint {
292
+ return {
293
+ version: 1,
294
+ scope: {
295
+ userEmail: options.scope.userEmail,
296
+ orgId: options.scope.orgId,
297
+ },
298
+ table: options.table,
299
+ branches: {
300
+ org: emptyBranchCheckpoint(),
301
+ legacy: emptyBranchCheckpoint(),
302
+ },
303
+ updatedAt: now,
304
+ };
305
+ }
306
+
307
+ function assertCheckpointMatches(
308
+ checkpoint: DedicatedBackfillCheckpoint,
309
+ options: DedicatedBackfillOptions,
310
+ ): void {
311
+ if (checkpoint.version !== 1) {
312
+ throw new Error(
313
+ `Unsupported backfill checkpoint version: ${checkpoint.version}`,
314
+ );
315
+ }
316
+ if (
317
+ checkpoint.scope.userEmail !== options.scope.userEmail ||
318
+ checkpoint.scope.orgId !== options.scope.orgId
319
+ ) {
320
+ throw new Error(
321
+ "Backfill checkpoint scope does not match --owner-email/--org-id",
322
+ );
323
+ }
324
+ if (checkpoint.table !== options.table) {
325
+ throw new Error(
326
+ "Backfill checkpoint table does not match the current BigQuery table",
327
+ );
328
+ }
329
+ for (const branch of ["org", "legacy"] as const) {
330
+ const state = checkpoint.branches[branch];
331
+ if (!state)
332
+ throw new Error(`Backfill checkpoint is missing ${branch} state`);
333
+ if (!Number.isInteger(state.copied) || state.copied < 0) {
334
+ throw new Error(`Backfill checkpoint has an invalid ${branch} count`);
335
+ }
336
+ if (state.cutoff) {
337
+ assertCheckpointCursor(state.cutoff, `${branch} cutoff`);
338
+ }
339
+ if (state.cursor) {
340
+ assertCheckpointCursor(state.cursor, `${branch} cursor`);
341
+ }
342
+ if (state.cursor && !state.cutoff) {
343
+ throw new Error(
344
+ `Backfill checkpoint has a ${branch} cursor without a cutoff`,
345
+ );
346
+ }
347
+ }
348
+ }
349
+
350
+ function latestCursor(
351
+ checkpoint: DedicatedBackfillCheckpoint,
352
+ ): BackfillCursor | null {
353
+ const cursors = [
354
+ checkpoint.branches.org.cursor,
355
+ checkpoint.branches.legacy.cursor,
356
+ ]
357
+ .filter((cursor): cursor is BackfillCursor => cursor !== null)
358
+ .sort(
359
+ (left, right) =>
360
+ left.receivedAt.localeCompare(right.receivedAt) ||
361
+ left.id.localeCompare(right.id),
362
+ );
363
+ return cursors[cursors.length - 1] ?? null;
364
+ }
365
+
366
+ function compareCursors(left: BackfillCursor, right: BackfillCursor): number {
367
+ return (
368
+ left.receivedAt.localeCompare(right.receivedAt) ||
369
+ left.id.localeCompare(right.id)
370
+ );
371
+ }
372
+
373
+ export async function assertSourceAtCheckpoint(
374
+ scope: DedicatedBackfillOptions["scope"],
375
+ checkpoint: DedicatedBackfillCheckpoint,
376
+ db: DbExecutor = getDbExec(),
377
+ ): Promise<void> {
378
+ for (const spec of branchSpecs(scope)) {
379
+ const result = await db.execute(buildHighWaterMarkQuery(spec));
380
+ const latestRow = result.rows[0];
381
+ if (!latestRow) continue;
382
+ const latest = cursorFromRow(
383
+ latestRow,
384
+ `${spec.name} final high-water mark`,
385
+ );
386
+ const cutoff = checkpoint.branches[spec.name].cutoff;
387
+ if (!cutoff || compareCursors(latest, cutoff) > 0) {
388
+ throw new Error(
389
+ `Refusing to finalize: ${spec.name} branch has events after the checkpoint cutoff; quiesce source writes and retry the finalization`,
390
+ );
391
+ }
392
+ }
393
+ }
394
+
395
+ export class JsonCheckpointStore implements CheckpointStore {
396
+ constructor(private readonly path: string) {}
397
+
398
+ async load(): Promise<DedicatedBackfillCheckpoint | null> {
399
+ try {
400
+ const raw = await readFile(this.path, "utf8");
401
+ const parsed: unknown = JSON.parse(raw);
402
+ if (!parsed || typeof parsed !== "object") {
403
+ throw new Error("checkpoint is not an object");
404
+ }
405
+ return parsed as DedicatedBackfillCheckpoint;
406
+ } catch (error) {
407
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
408
+ throw new Error(
409
+ `Unable to read backfill checkpoint ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
410
+ );
411
+ }
412
+ }
413
+
414
+ async save(checkpoint: DedicatedBackfillCheckpoint): Promise<void> {
415
+ await mkdir(dirname(this.path), { recursive: true });
416
+ const temporaryPath = `${this.path}.${process.pid}.tmp`;
417
+ await writeFile(temporaryPath, `${JSON.stringify(checkpoint, null, 2)}\n`, {
418
+ encoding: "utf8",
419
+ mode: 0o600,
420
+ });
421
+ await rename(temporaryPath, this.path);
422
+ }
423
+ }
424
+
425
+ async function refreshBranchCutoff(
426
+ spec: BranchSpec,
427
+ state: BranchCheckpoint,
428
+ checkpoint: DedicatedBackfillCheckpoint,
429
+ dependencies: DedicatedBackfillDependencies,
430
+ now: () => string,
431
+ ): Promise<void> {
432
+ const result = await dependencies.db.execute(buildHighWaterMarkQuery(spec));
433
+ const row = result.rows[0];
434
+ const latest = row
435
+ ? cursorFromRow(row, `${spec.name} high-water mark`)
436
+ : null;
437
+ if (latest && (!state.cutoff || compareCursors(latest, state.cutoff) > 0)) {
438
+ state.cutoff = latest;
439
+ state.complete = false;
440
+ } else {
441
+ state.complete = true;
442
+ }
443
+ checkpoint.updatedAt = now();
444
+ await dependencies.checkpointStore.save(checkpoint);
445
+ }
446
+
447
+ export async function acquireCheckpointLock(
448
+ checkpointPath: string,
449
+ ): Promise<() => Promise<void>> {
450
+ const lockPath = `${checkpointPath}.lock`;
451
+ await mkdir(dirname(lockPath), { recursive: true });
452
+ let handle: Awaited<ReturnType<typeof open>>;
453
+ try {
454
+ handle = await open(lockPath, "wx", 0o600);
455
+ } catch (error) {
456
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
457
+ throw new Error(
458
+ `Another backfill worker owns ${lockPath}; inspect it before removing the lock`,
459
+ );
460
+ }
461
+ throw error;
462
+ }
463
+ try {
464
+ await handle.writeFile(
465
+ `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
466
+ "utf8",
467
+ );
468
+ } catch (error) {
469
+ await handle.close();
470
+ await unlink(lockPath).catch(() => undefined);
471
+ throw error;
472
+ }
473
+ await handle.close();
474
+ return async () => {
475
+ await unlink(lockPath).catch((error: unknown) => {
476
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
477
+ });
478
+ };
479
+ }
480
+
481
+ export async function runDedicatedBackfill(
482
+ options: DedicatedBackfillOptions,
483
+ dependencies: DedicatedBackfillDependencies,
484
+ ): Promise<DedicatedBackfillResult> {
485
+ const now = dependencies.now ?? (() => new Date().toISOString());
486
+ const checkpoint =
487
+ (await dependencies.checkpointStore.load()) ??
488
+ newCheckpoint(options, now());
489
+ assertCheckpointMatches(checkpoint, options);
490
+ await dependencies.checkpointStore.save(checkpoint);
491
+
492
+ let batches = 0;
493
+ let copiedThisRun = 0;
494
+ let stop = false;
495
+ for (const spec of branchSpecs(options.scope)) {
496
+ const state = checkpoint.branches[spec.name];
497
+ if (state.complete) continue;
498
+
499
+ if (!state.cutoff) {
500
+ await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
501
+ if (state.complete) continue;
502
+ }
503
+
504
+ while (!state.complete) {
505
+ if (batches >= options.maxBatches) {
506
+ stop = true;
507
+ break;
508
+ }
509
+ const result = await dependencies.db.execute(
510
+ buildPageQuery(spec, state, options.batchSize),
511
+ );
512
+ const rows = result.rows as Array<Record<string, unknown>>;
513
+ if (!rows.length) {
514
+ await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
515
+ if (state.complete) break;
516
+ continue;
517
+ }
518
+
519
+ const copied = await dependencies.upload(rows);
520
+ if (copied !== rows.length) {
521
+ throw new Error(
522
+ `BigQuery acknowledged ${copied} of ${rows.length} rows; checkpoint was not advanced`,
523
+ );
524
+ }
525
+
526
+ state.cursor = cursorFromRow(rows[rows.length - 1], `${spec.name} page`);
527
+ state.copied += copied;
528
+ copiedThisRun += copied;
529
+ batches += 1;
530
+ state.complete = false;
531
+ checkpoint.updatedAt = now();
532
+ await dependencies.checkpointStore.save(checkpoint);
533
+ dependencies.onProgress?.({
534
+ branch: spec.name,
535
+ batches,
536
+ copiedThisRun,
537
+ copiedTotal: state.copied,
538
+ });
539
+ if (rows.length < options.batchSize) {
540
+ await refreshBranchCutoff(spec, state, checkpoint, dependencies, now);
541
+ }
542
+ }
543
+ if (stop) break;
544
+ }
545
+
546
+ return {
547
+ batches,
548
+ copiedThisRun,
549
+ copiedTotal:
550
+ checkpoint.branches.org.copied + checkpoint.branches.legacy.copied,
551
+ complete:
552
+ checkpoint.branches.org.complete && checkpoint.branches.legacy.complete,
553
+ checkpoint,
554
+ };
555
+ }
556
+
557
+ const USAGE = `Dedicated first-party Analytics Neon -> BigQuery backfill
558
+
559
+ Dry-run (default):
560
+ pnpm backfill:first-party-bigquery --owner-email=<email> --org-id=<org>
561
+
562
+ Execute only after an explicit production approval:
563
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 pnpm backfill:first-party-bigquery \
564
+ --execute --owner-email=<email> --org-id=<org>
565
+
566
+ Options:
567
+ --table=<dataset.table> Optional override, must match the approved table
568
+ --batch-size=<n> Source page size, default 1000, max 2500
569
+ --concurrency=<n> BigQuery requests in flight, default 2, max 4
570
+ --max-batches=<n> Work budget per invocation, default 1000
571
+ --checkpoint=<path> Atomic local checkpoint path
572
+
573
+ After the checkpoint is complete, reconcile it into the shipped durable job
574
+ after quiescing source event writes and checking the final high-water marks:
575
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_FINALIZE_ALLOW=1 \
576
+ AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_QUIESCENT=1 \
577
+ pnpm backfill:first-party-bigquery \
578
+ --finalize --owner-email=<email> --org-id=<org>
579
+ `;
580
+
581
+ async function runApprovedWorker(
582
+ options: DedicatedBackfillOptions,
583
+ ): Promise<DedicatedBackfillResult> {
584
+ let releaseLock: (() => Promise<void>) | null = null;
585
+ let releaseLease: (() => Promise<void>) | null = null;
586
+ try {
587
+ releaseLock = await acquireCheckpointLock(options.checkpointPath);
588
+ return await withMigrationRuntime(async () =>
589
+ runWithRequestContext(options.scope, async () => {
590
+ const backend = await getFirstPartyAnalyticsBackend(options.scope);
591
+ if (backend.sink !== "dual") {
592
+ throw new Error(
593
+ `Refusing to backfill while the source sink is ${backend.sink}; prepare must establish dual-write first`,
594
+ );
595
+ }
596
+ if (options.table && backend.table && options.table !== backend.table) {
597
+ throw new Error(
598
+ "--table does not match the scoped first-party Analytics backend setting",
599
+ );
600
+ }
601
+ const configuredTable = options.table ?? backend.table;
602
+ if (!configuredTable) {
603
+ throw new Error(
604
+ "Refusing to start the dedicated backfill without a configured BigQuery table",
605
+ );
606
+ }
607
+ const workerOptions = { ...options, table: configuredTable };
608
+ releaseLease = await acquireDedicatedFirstPartyAnalyticsBackfillLease(
609
+ options.scope,
610
+ configuredTable,
611
+ );
612
+ const insertRows = await createFirstPartyAnalyticsInserter(
613
+ configuredTable,
614
+ {
615
+ maxRowsPerRequest: 500,
616
+ maxConcurrentRequests: options.concurrency,
617
+ },
618
+ );
619
+ const result = await runDedicatedBackfill(workerOptions, {
620
+ db: getDbExec(),
621
+ upload: (rows) => insertRows(rows),
622
+ checkpointStore: new JsonCheckpointStore(options.checkpointPath),
623
+ onProgress: (progress) => {
624
+ console.error(JSON.stringify({ type: "progress", ...progress }));
625
+ },
626
+ });
627
+ return result;
628
+ }),
629
+ );
630
+ } finally {
631
+ const leaseToRelease = releaseLease as (() => Promise<void>) | null;
632
+ const lockToRelease = releaseLock as (() => Promise<void>) | null;
633
+ if (leaseToRelease) {
634
+ try {
635
+ await leaseToRelease();
636
+ } finally {
637
+ if (lockToRelease) {
638
+ try {
639
+ await closeDbExec();
640
+ } finally {
641
+ await lockToRelease();
642
+ }
643
+ }
644
+ }
645
+ } else if (lockToRelease) {
646
+ try {
647
+ await closeDbExec();
648
+ } finally {
649
+ await lockToRelease();
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ async function runApprovedFinalize(
656
+ options: DedicatedBackfillOptions,
657
+ ): Promise<FinalizeResult> {
658
+ let releaseLock: (() => Promise<void>) | null = null;
659
+ try {
660
+ releaseLock = await acquireCheckpointLock(options.checkpointPath);
661
+ return await withMigrationRuntime(async () =>
662
+ runWithRequestContext(options.scope, async () => {
663
+ const backend = await getFirstPartyAnalyticsBackend(options.scope);
664
+ if (backend.sink !== "dual") {
665
+ throw new Error(
666
+ `Refusing to finalize while the source sink is ${backend.sink}; prepare must establish dual-write first`,
667
+ );
668
+ }
669
+ const configuredTable = options.table ?? backend.table;
670
+ const finalizeOptions = { ...options, table: configuredTable };
671
+ const checkpoint = await new JsonCheckpointStore(
672
+ options.checkpointPath,
673
+ ).load();
674
+ if (!checkpoint) {
675
+ throw new Error(
676
+ `Backfill checkpoint ${options.checkpointPath} does not exist`,
677
+ );
678
+ }
679
+ assertCheckpointMatches(checkpoint, finalizeOptions);
680
+ if (
681
+ !checkpoint.branches.org.complete ||
682
+ !checkpoint.branches.legacy.complete
683
+ ) {
684
+ throw new Error(
685
+ "Refusing to finalize before both organization and legacy-owner branches are complete",
686
+ );
687
+ }
688
+
689
+ const job = await getFirstPartyAnalyticsBigQueryBackfillJob(
690
+ options.scope,
691
+ );
692
+ if (!job) {
693
+ throw new Error(
694
+ "Prepare the organization before reconciling the dedicated backfill checkpoint",
695
+ );
696
+ }
697
+ if (job.table !== checkpoint.table) {
698
+ throw new Error(
699
+ `Durable backfill targets ${job.table}, checkpoint targets ${checkpoint.table}`,
700
+ );
701
+ }
702
+ if (job.status === "completed") {
703
+ return {
704
+ mode: "finalize",
705
+ copied:
706
+ checkpoint.branches.org.copied +
707
+ checkpoint.branches.legacy.copied,
708
+ cursor: latestCursor(checkpoint),
709
+ jobId: job.id,
710
+ status: "completed",
711
+ };
712
+ }
713
+ if (
714
+ job.status === "running" &&
715
+ (!job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) > Date.now())
716
+ ) {
717
+ throw new Error(
718
+ "The shipped backfill worker currently owns the durable job lease; wait for it to stop before finalizing",
719
+ );
720
+ }
721
+
722
+ await assertSourceAtCheckpoint(options.scope, checkpoint);
723
+
724
+ const cursor = latestCursor(checkpoint);
725
+ const now = new Date().toISOString();
726
+ const update = await getDbExec().execute({
727
+ sql: `UPDATE ${JOB_TABLE}
728
+ SET status = 'completed', backfill_cursor = ?,
729
+ copied_count = ?, lease_token = NULL,
730
+ lease_expires_at = NULL, next_run_at = ?,
731
+ last_error = NULL, completed_at = ?, updated_at = ?
732
+ WHERE id = ? AND table_ref = ?
733
+ AND (status = 'pending'
734
+ OR (status = 'running' AND lease_expires_at IS NOT NULL
735
+ AND lease_expires_at <= ?))`,
736
+ args: [
737
+ cursor ? JSON.stringify(cursor) : null,
738
+ Math.max(
739
+ job.copied,
740
+ checkpoint.branches.org.copied +
741
+ checkpoint.branches.legacy.copied,
742
+ ),
743
+ now,
744
+ now,
745
+ now,
746
+ job.id,
747
+ checkpoint.table,
748
+ now,
749
+ ],
750
+ timeoutMs: 5_000,
751
+ maxAttempts: 1,
752
+ });
753
+ if (update.rowsAffected !== 1) {
754
+ throw new Error(
755
+ "The durable backfill job changed while it was being finalized; inspect status before retrying",
756
+ );
757
+ }
758
+ await saveFirstPartyAnalyticsBackend(options.scope, {
759
+ ...backend,
760
+ table: checkpoint.table,
761
+ backfillCursor: cursor ? JSON.stringify(cursor) : null,
762
+ backfillCompleted: true,
763
+ });
764
+ return {
765
+ mode: "finalize",
766
+ copied:
767
+ checkpoint.branches.org.copied + checkpoint.branches.legacy.copied,
768
+ cursor,
769
+ jobId: job.id,
770
+ status: "completed",
771
+ };
772
+ }),
773
+ );
774
+ } finally {
775
+ if (releaseLock) {
776
+ try {
777
+ await closeDbExec();
778
+ } finally {
779
+ await releaseLock();
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ async function main(): Promise<void> {
786
+ const args = parseArgs();
787
+ if (args.help) {
788
+ console.log(USAGE);
789
+ return;
790
+ }
791
+ const options = parseDedicatedBackfillOptions(args);
792
+ if (options.execute && options.finalize) {
793
+ throw new Error("Choose either --execute or --finalize, not both");
794
+ }
795
+ if (!options.execute && !options.finalize) {
796
+ output({
797
+ mode: "dry-run",
798
+ batchSize: options.batchSize,
799
+ concurrency: options.concurrency,
800
+ maxBatches: options.maxBatches,
801
+ checkpointPath: options.checkpointPath,
802
+ message:
803
+ "No data was copied. Pass --execute and AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 after production approval.",
804
+ });
805
+ return;
806
+ }
807
+ if (options.finalize) {
808
+ if (process.env[FINALIZE_ALLOW_ENV] !== "1") {
809
+ throw new Error(
810
+ `Checkpoint finalization is disabled. Set ${FINALIZE_ALLOW_ENV}=1 only after explicit production approval.`,
811
+ );
812
+ }
813
+ if (process.env[FINALIZE_QUIESCENT_ENV] !== "1") {
814
+ throw new Error(
815
+ `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.`,
816
+ );
817
+ }
818
+ output(await runApprovedFinalize(options));
819
+ return;
820
+ }
821
+ if (process.env.AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW !== "1") {
822
+ throw new Error(
823
+ "Production execution is disabled. Set AGENT_NATIVE_ANALYTICS_BIGQUERY_BACKFILL_ALLOW=1 only after explicit approval.",
824
+ );
825
+ }
826
+ output(await runApprovedWorker(options));
827
+ }
828
+
829
+ if (
830
+ process.argv[1] &&
831
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
832
+ ) {
833
+ try {
834
+ await main();
835
+ } catch (error) {
836
+ console.error(error instanceof Error ? error.message : String(error));
837
+ process.exitCode = 1;
838
+ }
839
+ }
@@ -10,6 +10,7 @@ import {
10
10
 
11
11
  const JOB_TABLE = "analytics_bigquery_backfill_jobs";
12
12
  const LEASE_MS = 5 * 60 * 1000;
13
+ const DEDICATED_LEASE_MS = 24 * 60 * 60 * 1000;
13
14
  const ERROR_RETRY_MS = 5 * 60 * 1000;
14
15
  const DEFAULT_BATCH_SIZE = 250;
15
16
  const MAX_BATCH_SIZE = 750;
@@ -191,6 +192,75 @@ export async function getFirstPartyAnalyticsBigQueryBackfillJob(
191
192
  return row ? rowToJob(row) : null;
192
193
  }
193
194
 
195
+ export async function acquireDedicatedFirstPartyAnalyticsBackfillLease(
196
+ scope: FirstPartyAnalyticsScope,
197
+ table: string,
198
+ ): Promise<() => Promise<void>> {
199
+ if (!scope.orgId) {
200
+ throw new Error("Dedicated BigQuery backfill requires an organization");
201
+ }
202
+ const id = jobId(scope.orgId);
203
+ const token = randomUUID();
204
+ const now = new Date().toISOString();
205
+ const leaseExpiresAt = new Date(
206
+ Date.now() + DEDICATED_LEASE_MS,
207
+ ).toISOString();
208
+ const claimed = await executor().execute({
209
+ sql: `UPDATE ${JOB_TABLE}
210
+ SET status = 'running', lease_token = ?, lease_expires_at = ?,
211
+ updated_at = ?
212
+ WHERE id = ? AND table_ref = ?
213
+ AND (status = 'pending'
214
+ OR (status = 'running' AND lease_expires_at IS NOT NULL
215
+ AND lease_expires_at <= ?))`,
216
+ args: [token, leaseExpiresAt, now, id, table, now],
217
+ timeoutMs: 5_000,
218
+ maxAttempts: 1,
219
+ });
220
+ if (claimed.rowsAffected !== 1) {
221
+ const job = await getFirstPartyAnalyticsBigQueryBackfillJob(scope);
222
+ if (!job) {
223
+ throw new Error(
224
+ "Prepare the organization before starting the dedicated BigQuery backfill",
225
+ );
226
+ }
227
+ if (job.table !== table) {
228
+ throw new Error(
229
+ `BigQuery backfill already targets ${job.table}; the dedicated worker requested ${table}`,
230
+ );
231
+ }
232
+ if (job.status === "completed") {
233
+ throw new Error(
234
+ "The durable BigQuery backfill is already completed; do not run the dedicated worker again",
235
+ );
236
+ }
237
+ throw new Error(
238
+ "Another BigQuery backfill worker owns the durable lease; wait for it to stop before retrying",
239
+ );
240
+ }
241
+
242
+ let released = false;
243
+ return async () => {
244
+ if (released) return;
245
+ const releasedAt = new Date().toISOString();
246
+ const result = await executor().execute({
247
+ sql: `UPDATE ${JOB_TABLE}
248
+ SET status = 'pending', lease_token = NULL,
249
+ lease_expires_at = NULL, next_run_at = ?, updated_at = ?
250
+ WHERE id = ? AND lease_token = ?`,
251
+ args: [leaseExpiresAt, releasedAt, id, token],
252
+ timeoutMs: 5_000,
253
+ maxAttempts: 1,
254
+ });
255
+ if (result.rowsAffected !== 1) {
256
+ throw new Error(
257
+ "The dedicated BigQuery backfill lost its durable lease before release",
258
+ );
259
+ }
260
+ released = true;
261
+ };
262
+ }
263
+
194
264
  export async function queueFirstPartyAnalyticsBigQueryBackfill(
195
265
  scope: FirstPartyAnalyticsScope,
196
266
  table: string,
@@ -75,6 +75,16 @@ const FIRST_PARTY_QUERY_TABLES = [
75
75
  const BACKEND_CONFIG_CACHE_TTL_MS = 30_000;
76
76
  const MAX_BACKFILL_BATCH_SIZE = 750;
77
77
  const MAX_INSERT_BATCH_SIZE = 200;
78
+ const MAX_DEDICATED_INSERT_BATCH_SIZE = 500;
79
+ const MAX_DEDICATED_INSERT_CONCURRENCY = 4;
80
+ const MAX_INSERT_REQUEST_BYTES = 8 * 1024 * 1024;
81
+
82
+ export interface FirstPartyAnalyticsInsertOptions {
83
+ /** Maximum rows in one BigQuery insertAll request. */
84
+ maxRowsPerRequest?: number;
85
+ /** Maximum insertAll requests in flight for a dedicated backfill worker. */
86
+ maxConcurrentRequests?: number;
87
+ }
78
88
 
79
89
  const backendConfigCache = new Map<
80
90
  string,
@@ -224,7 +234,7 @@ const FIRST_PARTY_ANALYTICS_RAW_SCHEMA = [
224
234
  ["org_id", "STRING"],
225
235
  ] as const;
226
236
 
227
- const FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS = [
237
+ export const FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS = [
228
238
  "id",
229
239
  "public_key_id",
230
240
  "event_name",
@@ -352,9 +362,114 @@ async function insertBatch(
352
362
  }
353
363
  }
354
364
 
365
+ function boundedInsertOption(
366
+ value: number | undefined,
367
+ fallback: number,
368
+ maximum: number,
369
+ name: string,
370
+ ): number {
371
+ if (value === undefined) return fallback;
372
+ if (!Number.isInteger(value) || value < 1) {
373
+ throw new Error(`${name} must be a positive integer`);
374
+ }
375
+ return Math.min(value, maximum);
376
+ }
377
+
378
+ async function insertPayloadRows(
379
+ table: BigQueryTableRef,
380
+ token: string,
381
+ payloadRows: Record<string, unknown>[],
382
+ options: FirstPartyAnalyticsInsertOptions = {},
383
+ ): Promise<void> {
384
+ const maxRowsPerRequest = boundedInsertOption(
385
+ options.maxRowsPerRequest,
386
+ MAX_INSERT_BATCH_SIZE,
387
+ MAX_DEDICATED_INSERT_BATCH_SIZE,
388
+ "maxRowsPerRequest",
389
+ );
390
+ const maxConcurrentRequests = boundedInsertOption(
391
+ options.maxConcurrentRequests,
392
+ 1,
393
+ MAX_DEDICATED_INSERT_CONCURRENCY,
394
+ "maxConcurrentRequests",
395
+ );
396
+ const batches: Record<string, unknown>[][] = [];
397
+ const encoder = new TextEncoder();
398
+ let currentBatch: Record<string, unknown>[] = [];
399
+ let currentBytes = 2;
400
+ for (const row of payloadRows) {
401
+ const rowBytes = encoder.encode(
402
+ JSON.stringify({
403
+ insertId: typeof row.id === "string" ? row.id : undefined,
404
+ json: row,
405
+ }),
406
+ ).byteLength;
407
+ if (rowBytes + 2 > MAX_INSERT_REQUEST_BYTES) {
408
+ throw new Error(
409
+ "A first-party Analytics row exceeds the BigQuery request limit",
410
+ );
411
+ }
412
+ if (
413
+ currentBatch.length > 0 &&
414
+ (currentBatch.length >= maxRowsPerRequest ||
415
+ currentBytes + rowBytes + 1 > MAX_INSERT_REQUEST_BYTES)
416
+ ) {
417
+ batches.push(currentBatch);
418
+ currentBatch = [];
419
+ currentBytes = 2;
420
+ }
421
+ currentBatch.push(row);
422
+ currentBytes += rowBytes + 1;
423
+ }
424
+ if (currentBatch.length > 0) batches.push(currentBatch);
425
+
426
+ let nextBatch = 0;
427
+ const worker = async (): Promise<void> => {
428
+ while (true) {
429
+ const batchIndex = nextBatch;
430
+ nextBatch += 1;
431
+ const batch = batches[batchIndex];
432
+ if (!batch) return;
433
+ await insertBatch(table, token, batch);
434
+ }
435
+ };
436
+
437
+ await Promise.all(
438
+ Array.from(
439
+ { length: Math.min(maxConcurrentRequests, batches.length) },
440
+ () => worker(),
441
+ ),
442
+ );
443
+ }
444
+
445
+ /**
446
+ * Create a long-lived inserter for a dedicated backfill process. The table is
447
+ * resolved once, while the token is refreshed through the existing scoped
448
+ * resolver for every page so a multi-hour run does not use an expired token.
449
+ */
450
+ export async function createFirstPartyAnalyticsInserter(
451
+ configuredTable?: string | null,
452
+ options: FirstPartyAnalyticsInsertOptions = {},
453
+ ): Promise<
454
+ (
455
+ rows: Array<FirstPartyAnalyticsEventRow | Record<string, unknown>>,
456
+ ) => Promise<number>
457
+ > {
458
+ requireRequestCredentialContext("GOOGLE_APPLICATION_CREDENTIALS_JSON");
459
+ const table = await getFirstPartyAnalyticsTable(configuredTable);
460
+ return async (rows) => {
461
+ if (!rows.length) return 0;
462
+ const token = await getAccessToken();
463
+ const payloadRows = rows.map(firstPartyEventRowToBigQuery);
464
+ await insertPayloadRows(table, token, payloadRows, options);
465
+ return payloadRows.length;
466
+ };
467
+ }
468
+
355
469
  export async function insertFirstPartyAnalyticsRows(
356
470
  rows: Array<FirstPartyAnalyticsEventRow | Record<string, unknown>>,
357
471
  configuredTable?: string | null,
472
+ options: FirstPartyAnalyticsInsertOptions = {},
358
473
  ): Promise<number> {
359
474
  if (!rows.length) return 0;
360
475
  requireRequestCredentialContext("GOOGLE_APPLICATION_CREDENTIALS_JSON");
@@ -363,17 +478,7 @@ export async function insertFirstPartyAnalyticsRows(
363
478
  getAccessToken(),
364
479
  ]);
365
480
  const payloadRows = rows.map(firstPartyEventRowToBigQuery);
366
- for (
367
- let offset = 0;
368
- offset < payloadRows.length;
369
- offset += MAX_INSERT_BATCH_SIZE
370
- ) {
371
- await insertBatch(
372
- table,
373
- token,
374
- payloadRows.slice(offset, offset + MAX_INSERT_BATCH_SIZE),
375
- );
376
- }
481
+ await insertPayloadRows(table, token, payloadRows, options);
377
482
  return payloadRows.length;
378
483
  }
379
484
 
@@ -35,6 +35,7 @@ export default defineAction({
35
35
  return queueAutomationRunNow({
36
36
  userEmail,
37
37
  orgId,
38
+ appId: "factory",
38
39
  scope: "organization",
39
40
  name: definition.name,
40
41
  });
@@ -1,5 +1,5 @@
1
1
  [build]
2
- command = "NITRO_PRESET=netlify pnpm --filter factory build && if [ \"${CONTEXT:-}\" = \"production\" ]; then pnpm --filter factory migrate:production; fi"
2
+ command = "export DATABASE_URL=\"${NETLIFY_DATABASE_URL:-$DATABASE_URL}\" && pnpm install && DATABASE_URL=\"${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL}\" NITRO_PRESET=netlify pnpm --filter factory build && if [ \"${CONTEXT:-}\" = \"production\" ]; then DATABASE_URL=\"${NETLIFY_DATABASE_URL_UNPOOLED:-$DATABASE_URL}\" pnpm --filter factory migrate:production; fi"
3
3
  publish = "dist"
4
4
  functions = ".netlify/functions-internal"
5
5
 
@@ -1317,6 +1317,13 @@ function createAuthGuardFn() {
1317
1317
  if (p === "/_agent-native/integrations/process-a2a-continuation") {
1318
1318
  return;
1319
1319
  }
1320
+ // Scheduled recurring-job sweeps are self-fired by the platform scheduler
1321
+ // through the durable background function and authenticate with the same
1322
+ // short-lived HMAC token as the other internal processors. They do not
1323
+ // carry a browser session, so let the route perform its own token check.
1324
+ if (p === "/_agent-native/jobs/_process-sweep") {
1325
+ return;
1326
+ }
1320
1327
  // Agent Teams durable sub-agent processor. Self-fired by `spawnTask` to run
1321
1328
  // a queued sub-agent in a fresh function invocation; authenticity is
1322
1329
  // verified by the same HMAC internal-token scheme plus an atomic SQL claim,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.146.6",
3
+ "version": "0.146.8",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {