@agent-native/core 0.122.1 → 0.122.2

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 (57) hide show
  1. package/README.md +1 -13
  2. package/corpus/README.md +1 -1
  3. package/corpus/core/CHANGELOG.md +6 -0
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/production-agent.ts +65 -1
  6. package/corpus/core/src/agent/run-store.ts +61 -1
  7. package/corpus/core/src/server/builder-design-systems.ts +1 -1
  8. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +15 -0
  9. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +3 -3
  10. package/corpus/templates/analytics/changelog/2026-07-25-analytics-dashboards-and-scheduled-reports-are-faster-and-mo.md +6 -0
  11. package/corpus/templates/analytics/server/db/schema.ts +12 -0
  12. package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +8 -4
  13. package/corpus/templates/analytics/server/lib/dashboard-report.ts +136 -5
  14. package/corpus/templates/analytics/server/lib/dashboard-time-scope.ts +250 -1
  15. package/corpus/templates/analytics/server/lib/first-party-analytics-cache.ts +167 -0
  16. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
  17. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +105 -23
  18. package/corpus/templates/analytics/server/lib/first-party-unbounded-panel-repair.ts +169 -0
  19. package/corpus/templates/analytics/server/plugins/db.ts +41 -1
  20. package/corpus/templates/analytics/shared/dashboard-report-timeouts.ts +6 -3
  21. package/corpus/templates/design/actions/edit-design.ts +66 -27
  22. package/corpus/templates/design/changelog/2026-07-25-design-edits-retry-concurrent-changes.md +6 -0
  23. package/corpus/templates/design/server/source-workspace.ts +5 -1
  24. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +6 -4
  25. package/corpus/templates/forms/actions/create-form.ts +5 -1
  26. package/corpus/templates/forms/actions/export-responses.ts +22 -11
  27. package/corpus/templates/forms/actions/update-form.ts +5 -1
  28. package/corpus/templates/forms/changelog/2026-07-25-fields-can-be-created-without-ids.md +6 -0
  29. package/corpus/templates/forms/changelog/2026-07-25-response-exports-use-file-storage.md +6 -0
  30. package/corpus/templates/forms/server/lib/validate-fields.ts +43 -0
  31. package/corpus/templates/slides/actions/extract-pdf.ts +4 -4
  32. package/corpus/templates/slides/actions/import-file.ts +3 -4
  33. package/corpus/templates/slides/changelog/2026-07-25-gemini-image-models-updated.md +6 -0
  34. package/corpus/templates/slides/changelog/2026-07-25-pdf-imports-tolerate-missing-canvas.md +6 -0
  35. package/corpus/templates/slides/server/handlers/image-providers/gemini.ts +5 -2
  36. package/corpus/templates/slides/server/lib/pdf-parse-setup.ts +150 -0
  37. package/dist/agent/production-agent.d.ts.map +1 -1
  38. package/dist/agent/production-agent.js +63 -1
  39. package/dist/agent/production-agent.js.map +1 -1
  40. package/dist/agent/run-store.d.ts.map +1 -1
  41. package/dist/agent/run-store.js +49 -0
  42. package/dist/agent/run-store.js.map +1 -1
  43. package/dist/collab/awareness.d.ts +2 -2
  44. package/dist/collab/awareness.d.ts.map +1 -1
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/collab/struct-routes.d.ts +1 -1
  47. package/dist/notifications/routes.d.ts +3 -3
  48. package/dist/observability/routes.d.ts +1 -1
  49. package/dist/resources/handlers.d.ts +1 -1
  50. package/dist/secrets/routes.d.ts +9 -9
  51. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  52. package/dist/server/builder-design-systems.js +1 -1
  53. package/dist/server/builder-design-systems.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/agent/production-agent.ts +65 -1
  56. package/src/agent/run-store.ts +61 -1
  57. package/src/server/builder-design-systems.ts +1 -1
@@ -0,0 +1,169 @@
1
+ import type { Dialect } from "@agent-native/core/db";
2
+
3
+ import type { DashboardPanelLike } from "./dashboard-time-scope.js";
4
+
5
+ const POSTGRES_DATE_BOUND =
6
+ "to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')";
7
+ const SQLITE_DATE_BOUND = "date('now', '-365 days')";
8
+
9
+ /**
10
+ * Fixes for first-party dashboard panels found, via a full-org audit
11
+ * (2026-07-25), reading `analytics_events` with no date bound at all in some
12
+ * or all of their scan units — a full 6.7M-row table scan on every render.
13
+ * Unlike the id-keyed replacements in first-party-metric-catalog.ts (which
14
+ * repair one specific dashboard's known panel ids), these are matched purely
15
+ * by exact SQL text so the same fix applies wherever the identical broken
16
+ * query was cloned into a different dashboard under a different panel id.
17
+ * Each entry only changes the added bound (`AND event_date >= ...365 days`,
18
+ * or the exact same bound already live on the repaired canonical dashboard
19
+ * for the retention-cohort case) — never the query's selected columns,
20
+ * grouping, or business logic.
21
+ */
22
+ export type UnboundedFirstPartyPanelFix = {
23
+ legacySql: string;
24
+ sql: string;
25
+ };
26
+
27
+ function dialectBoundedSql(sql: string, dialect: Dialect): string {
28
+ return dialect === "postgres"
29
+ ? sql
30
+ : sql.split(POSTGRES_DATE_BOUND).join(SQLITE_DATE_BOUND);
31
+ }
32
+
33
+ export const UNBOUNDED_FIRST_PARTY_PANEL_FIXES: readonly UnboundedFirstPartyPanelFix[] =
34
+ [
35
+ {
36
+ legacySql:
37
+ "SELECT event_date AS date, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' GROUP BY event_date ORDER BY date",
38
+ sql: "SELECT event_date AS date, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') GROUP BY event_date ORDER BY date",
39
+ },
40
+ {
41
+ legacySql:
42
+ "SELECT COALESCE(NULLIF(signed_in, ''), 'unknown') AS signed_in, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' GROUP BY COALESCE(NULLIF(signed_in, ''), 'unknown') ORDER BY signed_in",
43
+ sql: "SELECT COALESCE(NULLIF(signed_in, ''), 'unknown') AS signed_in, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') GROUP BY COALESCE(NULLIF(signed_in, ''), 'unknown') ORDER BY signed_in",
44
+ },
45
+ {
46
+ legacySql:
47
+ "SELECT COALESCE(NULLIF(app, ''), 'unknown') AS app, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' GROUP BY COALESCE(NULLIF(app, ''), 'unknown') ORDER BY count DESC LIMIT 20",
48
+ sql: "SELECT COALESCE(NULLIF(app, ''), 'unknown') AS app, COUNT(*) AS count FROM analytics_events WHERE event_name = 'session status' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') GROUP BY COALESCE(NULLIF(app, ''), 'unknown') ORDER BY count DESC LIMIT 20",
49
+ },
50
+ {
51
+ legacySql:
52
+ "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'skills_cli started'",
53
+ sql: "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'skills_cli started' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')",
54
+ },
55
+ {
56
+ legacySql:
57
+ "SELECT COUNT(DISTINCT anonymous_id) AS count FROM analytics_events WHERE event_name LIKE 'skills_cli %'",
58
+ sql: "SELECT COUNT(DISTINCT anonymous_id) AS count FROM analytics_events WHERE event_name LIKE 'skills_cli %' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')",
59
+ },
60
+ {
61
+ legacySql:
62
+ "SELECT COUNT(DISTINCT session_id) AS count FROM analytics_events WHERE event_name = 'skills_cli install completed'",
63
+ sql: "SELECT COUNT(DISTINCT session_id) AS count FROM analytics_events WHERE event_name = 'skills_cli install completed' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')",
64
+ },
65
+ {
66
+ legacySql:
67
+ "WITH started AS (SELECT COUNT(DISTINCT session_id) AS n FROM analytics_events WHERE event_name = 'skills_cli started'), completed AS (SELECT COUNT(DISTINCT session_id) AS n FROM analytics_events WHERE event_name = 'skills_cli install completed') SELECT CASE WHEN started.n = 0 THEN 0 ELSE ROUND(completed.n * 100.0 / started.n) END AS rate FROM started, completed",
68
+ sql: "WITH started AS (SELECT COUNT(DISTINCT session_id) AS n FROM analytics_events WHERE event_name = 'skills_cli started' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), completed AS (SELECT COUNT(DISTINCT session_id) AS n FROM analytics_events WHERE event_name = 'skills_cli install completed' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')) SELECT CASE WHEN started.n = 0 THEN 0 ELSE ROUND(completed.n * 100.0 / started.n) END AS rate FROM started, completed",
69
+ },
70
+ {
71
+ legacySql:
72
+ "WITH steps AS (SELECT 1 AS step_order, 'Started' AS step, COUNT(DISTINCT session_id) AS count FROM analytics_events WHERE event_name = 'skills_cli started' UNION ALL SELECT 2, 'Skills prompted', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills prompted' UNION ALL SELECT 3, 'Skills selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills selected' UNION ALL SELECT 4, 'Clients selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli clients selected' UNION ALL SELECT 5, 'Scope selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli scope selected' UNION ALL SELECT 6, 'Install completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli install completed' UNION ALL SELECT 7, 'Completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli completed') SELECT step, count FROM steps ORDER BY step_order",
73
+ sql: "WITH steps AS (SELECT 1 AS step_order, 'Started' AS step, COUNT(DISTINCT session_id) AS count FROM analytics_events WHERE event_name = 'skills_cli started' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 2, 'Skills prompted', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills prompted' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 3, 'Skills selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 4, 'Clients selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli clients selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 5, 'Scope selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli scope selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 6, 'Install completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli install completed' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 7, 'Completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli completed' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')) SELECT step, count FROM steps ORDER BY step_order",
74
+ },
75
+ {
76
+ legacySql:
77
+ "SELECT trim(s) AS skill, COUNT(*) AS count FROM analytics_events, LATERAL unnest(string_to_array(properties::jsonb ->> 'selected', ',')) AS s WHERE event_name = 'skills_cli skills selected' AND (properties::jsonb ->> 'selected') <> '' GROUP BY trim(s) ORDER BY count DESC LIMIT 30",
78
+ sql: "SELECT trim(s) AS skill, COUNT(*) AS count FROM analytics_events, LATERAL unnest(string_to_array(properties::jsonb ->> 'selected', ',')) AS s WHERE event_name = 'skills_cli skills selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND (properties::jsonb ->> 'selected') <> '' GROUP BY trim(s) ORDER BY count DESC LIMIT 30",
79
+ },
80
+ {
81
+ legacySql:
82
+ "SELECT substr(timestamp, 1, 10) AS date, COUNT(*) AS count FROM analytics_events WHERE event_name = 'skills_cli started' GROUP BY substr(timestamp, 1, 10) ORDER BY date",
83
+ sql: "SELECT substr(timestamp, 1, 10) AS date, COUNT(*) AS count FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND event_name = 'skills_cli started' GROUP BY substr(timestamp, 1, 10) ORDER BY date",
84
+ },
85
+ {
86
+ legacySql:
87
+ "SELECT COALESCE(NULLIF(properties::jsonb ->> 'cli', ''), 'unknown') AS cli, COUNT(*) AS count FROM analytics_events WHERE event_name = 'skills_cli started' GROUP BY COALESCE(NULLIF(properties::jsonb ->> 'cli', ''), 'unknown') ORDER BY count DESC",
88
+ sql: "SELECT COALESCE(NULLIF(properties::jsonb ->> 'cli', ''), 'unknown') AS cli, COUNT(*) AS count FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND event_name = 'skills_cli started' GROUP BY COALESCE(NULLIF(properties::jsonb ->> 'cli', ''), 'unknown') ORDER BY count DESC",
89
+ },
90
+ {
91
+ legacySql:
92
+ "SELECT COALESCE(NULLIF(properties::jsonb ->> 'platform', ''), 'unknown') AS platform, COUNT(*) AS count FROM analytics_events WHERE event_name = 'skills_cli started' GROUP BY COALESCE(NULLIF(properties::jsonb ->> 'platform', ''), 'unknown') ORDER BY count DESC",
93
+ sql: "SELECT COALESCE(NULLIF(properties::jsonb ->> 'platform', ''), 'unknown') AS platform, COUNT(*) AS count FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND event_name = 'skills_cli started' GROUP BY COALESCE(NULLIF(properties::jsonb ->> 'platform', ''), 'unknown') ORDER BY count DESC",
94
+ },
95
+ {
96
+ legacySql:
97
+ "SELECT COALESCE(NULLIF(properties::jsonb ->> 'scope', ''), 'unknown') AS scope, COUNT(*) AS count FROM analytics_events WHERE event_name = 'skills_cli scope selected' GROUP BY COALESCE(NULLIF(properties::jsonb ->> 'scope', ''), 'unknown') ORDER BY count DESC",
98
+ sql: "SELECT COALESCE(NULLIF(properties::jsonb ->> 'scope', ''), 'unknown') AS scope, COUNT(*) AS count FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND event_name = 'skills_cli scope selected' GROUP BY COALESCE(NULLIF(properties::jsonb ->> 'scope', ''), 'unknown') ORDER BY count DESC",
99
+ },
100
+ {
101
+ legacySql:
102
+ "SELECT trim(c) AS client, COUNT(*) AS count FROM analytics_events, LATERAL unnest(string_to_array(properties::jsonb ->> 'clients', ',')) AS c WHERE event_name = 'skills_cli clients selected' AND (properties::jsonb ->> 'clients') <> '' GROUP BY trim(c) ORDER BY count DESC LIMIT 30",
103
+ sql: "SELECT trim(c) AS client, COUNT(*) AS count FROM analytics_events, LATERAL unnest(string_to_array(properties::jsonb ->> 'clients', ',')) AS c WHERE event_name = 'skills_cli clients selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND (properties::jsonb ->> 'clients') <> '' GROUP BY trim(c) ORDER BY count DESC LIMIT 30",
104
+ },
105
+ {
106
+ legacySql:
107
+ "WITH steps AS (SELECT 1 AS step_order, 'Started' AS step, COUNT(DISTINCT session_id) AS reached FROM analytics_events WHERE event_name = 'skills_cli started' UNION ALL SELECT 2, 'Skills prompted', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills prompted' UNION ALL SELECT 3, 'Skills selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills selected' UNION ALL SELECT 4, 'Clients selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli clients selected' UNION ALL SELECT 5, 'Scope selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli scope selected' UNION ALL SELECT 6, 'Install completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli install completed' UNION ALL SELECT 7, 'Completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli completed'), start_total AS (SELECT COUNT(DISTINCT session_id) AS n FROM analytics_events WHERE event_name = 'skills_cli started') SELECT steps.step, steps.reached, CASE WHEN start_total.n = 0 THEN 0 ELSE ROUND(steps.reached * 100.0 / start_total.n) END AS pct_of_start FROM steps, start_total ORDER BY steps.step_order",
108
+ sql: "WITH steps AS (SELECT 1 AS step_order, 'Started' AS step, COUNT(DISTINCT session_id) AS reached FROM analytics_events WHERE event_name = 'skills_cli started' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 2, 'Skills prompted', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills prompted' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 3, 'Skills selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli skills selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 4, 'Clients selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli clients selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 5, 'Scope selected', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli scope selected' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 6, 'Install completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli install completed' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') UNION ALL SELECT 7, 'Completed', COUNT(DISTINCT session_id) FROM analytics_events WHERE event_name = 'skills_cli completed' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), start_total AS (SELECT COUNT(DISTINCT session_id) AS n FROM analytics_events WHERE event_name = 'skills_cli started' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')) SELECT steps.step, steps.reached, CASE WHEN start_total.n = 0 THEN 0 ELSE ROUND(steps.reached * 100.0 / start_total.n) END AS pct_of_start FROM steps, start_total ORDER BY steps.step_order",
109
+ },
110
+ {
111
+ legacySql: "SELECT COUNT(*) AS value FROM analytics_events",
112
+ sql: "SELECT COUNT(*) AS value FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')",
113
+ },
114
+ {
115
+ legacySql:
116
+ "SELECT COUNT(DISTINCT session_id) AS value FROM analytics_events",
117
+ sql: "SELECT COUNT(DISTINCT session_id) AS value FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')",
118
+ },
119
+ {
120
+ legacySql:
121
+ "SELECT event_name AS label, COUNT(*) AS value FROM analytics_events GROUP BY event_name ORDER BY value DESC LIMIT 8",
122
+ sql: "SELECT event_name AS label, COUNT(*) AS value FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') GROUP BY event_name ORDER BY value DESC LIMIT 8",
123
+ },
124
+ {
125
+ legacySql:
126
+ "SELECT CASE WHEN signed_in = 'true' THEN 'Signed In' ELSE 'Anonymous' END AS user_type, COUNT(*) AS events FROM analytics_events GROUP BY signed_in",
127
+ sql: "SELECT CASE WHEN signed_in = 'true' THEN 'Signed In' ELSE 'Anonymous' END AS user_type, COUNT(*) AS events FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') GROUP BY signed_in",
128
+ },
129
+ {
130
+ legacySql:
131
+ "SELECT timestamp, event_name, app, signed_in FROM analytics_events ORDER BY timestamp DESC LIMIT 50",
132
+ sql: "SELECT timestamp, event_name, app, signed_in FROM analytics_events WHERE event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') ORDER BY timestamp DESC LIMIT 50",
133
+ },
134
+ {
135
+ legacySql:
136
+ "WITH base AS (SELECT NULLIF(user_key, '') AS user_key, event_date AS event_date, user_id FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')) <> 'docs' AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), first_seen AS (SELECT user_key, MIN(event_date) AS cohort_date FROM base GROUP BY user_key), anchor_dates AS (SELECT DISTINCT cohort_date AS date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '14 days', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))), cohort_windows AS (SELECT a.date, f.user_key, f.cohort_date FROM anchor_dates a JOIN first_seen f ON f.cohort_date >= to_char(a.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND f.cohort_date <= a.date), cohort_sizes AS (SELECT date, COUNT(DISTINCT user_key) AS users FROM cohort_windows GROUP BY date), periods AS (SELECT '1-7d return' AS period UNION ALL SELECT '7-14d return' AS period), retained AS (SELECT cw.date, '1-7d return' AS period, COUNT(DISTINCT cw.user_key) AS retained FROM cohort_windows cw JOIN base b ON b.user_key = cw.user_key AND b.event_date > cw.cohort_date AND b.event_date <= to_char(cw.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') GROUP BY cw.date UNION ALL SELECT cw.date, '7-14d return' AS period, COUNT(DISTINCT cw.user_key) AS retained FROM cohort_windows cw JOIN base b ON b.user_key = cw.user_key AND b.event_date >= to_char(cw.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') AND b.event_date <= to_char(cw.cohort_date::date + INTERVAL '14 days', 'YYYY-MM-DD') GROUP BY cw.date) SELECT cs.date, p.period, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs CROSS JOIN periods p LEFT JOIN retained r ON r.date = cs.date AND r.period = p.period WHERE cs.users >= 5 ORDER BY cs.date, p.period",
137
+ sql: "WITH base AS (SELECT NULLIF(user_key, '') AS user_key, event_date AS event_date, user_id FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')) <> 'docs' AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), first_seen AS (SELECT user_key, MIN(event_date) AS cohort_date FROM base GROUP BY user_key), anchor_dates AS (SELECT DISTINCT cohort_date AS date FROM first_seen WHERE cohort_date <= to_char(CURRENT_DATE - INTERVAL '14 days', 'YYYY-MM-DD') AND (cohort_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND cohort_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))))), cohort_windows AS (SELECT a.date, f.user_key, f.cohort_date FROM anchor_dates a JOIN first_seen f ON f.cohort_date >= to_char(a.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND f.cohort_date <= a.date), cohort_sizes AS (SELECT date, COUNT(DISTINCT user_key) AS users FROM cohort_windows GROUP BY date), periods AS (SELECT '1-7d return' AS period UNION ALL SELECT '7-14d return' AS period), retained AS (SELECT cw.date, '1-7d return' AS period, COUNT(DISTINCT cw.user_key) AS retained FROM cohort_windows cw JOIN base b ON b.user_key = cw.user_key AND b.event_date > cw.cohort_date AND b.event_date <= to_char(cw.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') GROUP BY cw.date UNION ALL SELECT cw.date, '7-14d return' AS period, COUNT(DISTINCT cw.user_key) AS retained FROM cohort_windows cw JOIN base b ON b.user_key = cw.user_key AND b.event_date >= to_char(cw.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') AND b.event_date <= to_char(cw.cohort_date::date + INTERVAL '14 days', 'YYYY-MM-DD') GROUP BY cw.date) SELECT cs.date, p.period, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs CROSS JOIN periods p LEFT JOIN retained r ON r.date = cs.date AND r.period = p.period WHERE cs.users >= 5 ORDER BY cs.date, p.period",
138
+ },
139
+ {
140
+ legacySql:
141
+ "WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY event_date) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT event_date AS date, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template FROM analytics_events WHERE event_name = 'signup' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template",
142
+ sql: "WITH signup_events AS (SELECT event_date AS date, COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown') AS template FROM analytics_events WHERE event_name = 'signup' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io'))), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(d, 'YYYY-MM-DD') AS date FROM bounds, generate_series(bounds.start_date, bounds.end_date, INTERVAL '1 day') AS d WHERE bounds.start_date IS NOT NULL), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template",
143
+ },
144
+ ];
145
+
146
+ export function repairUnboundedFirstPartyPanels(
147
+ config: Record<string, unknown>,
148
+ dialect: Dialect = "postgres",
149
+ ): { config: Record<string, unknown>; changed: boolean } {
150
+ if (!Array.isArray(config.panels)) return { config, changed: false };
151
+
152
+ const fixBySql = new Map(
153
+ UNBOUNDED_FIRST_PARTY_PANEL_FIXES.map((fix) => [fix.legacySql, fix.sql]),
154
+ );
155
+ let changed = false;
156
+ const panels = config.panels.map((rawPanel) => {
157
+ if (!rawPanel || typeof rawPanel !== "object") return rawPanel;
158
+ const panel = rawPanel as DashboardPanelLike & Record<string, unknown>;
159
+ if (panel.source !== "first-party" || typeof panel.sql !== "string") {
160
+ return rawPanel;
161
+ }
162
+ const fixedSql = fixBySql.get(panel.sql);
163
+ if (fixedSql === undefined) return rawPanel;
164
+ changed = true;
165
+ return { ...panel, sql: dialectBoundedSql(fixedSql, dialect) };
166
+ });
167
+ if (!changed) return { config, changed: false };
168
+ return { config: { ...config, panels }, changed: true };
169
+ }
@@ -8,7 +8,10 @@ import {
8
8
  // startup so the dashboard / analysis share actions know where to dispatch.
9
9
  import "../db/index.js";
10
10
  import * as schema from "../db/schema.js";
11
- import { repairPersistedFirstPartyDashboardQueries } from "../lib/first-party-dashboard-repair.js";
11
+ import {
12
+ repairPersistedFirstPartyDashboardQueries,
13
+ repairUnboundedFirstPartyPanelsAcrossDashboards,
14
+ } from "../lib/first-party-dashboard-repair.js";
12
15
 
13
16
  /**
14
17
  * Every Drizzle table exported from schema.ts. Filters out type-only and
@@ -1284,6 +1287,29 @@ const runAnalyticsMigrations = runMigrations(
1284
1287
  ALTER TABLE dashboard_report_subscriptions ADD COLUMN IF NOT EXISTS last_capture_error TEXT;
1285
1288
  `,
1286
1289
  },
1290
+ // First-party dashboard panel result cache. Same shape/pattern as
1291
+ // bigquery_cache above, short TTL (set in first-party-analytics-cache.ts)
1292
+ // since this is the app's own live data, not an immutable warehouse
1293
+ // result. See first-party-analytics-cache.ts for why this exists: panel
1294
+ // queries had no cache at all, so every dashboard render and every daily
1295
+ // report screenshot recomputed from scratch and stacked concurrent load
1296
+ // on the same rows, which is what was blowing report/panel timeouts.
1297
+ {
1298
+ version: 124,
1299
+ name: "first-party-analytics-cache-table",
1300
+ sql: `CREATE TABLE IF NOT EXISTS first_party_analytics_cache (
1301
+ key TEXT PRIMARY KEY,
1302
+ sql TEXT NOT NULL,
1303
+ result TEXT NOT NULL,
1304
+ created_at TEXT NOT NULL,
1305
+ expires_at TEXT NOT NULL
1306
+ )`,
1307
+ },
1308
+ {
1309
+ version: 125,
1310
+ name: "first-party-analytics-cache-expires-idx",
1311
+ sql: `CREATE INDEX IF NOT EXISTS first_party_analytics_cache_expires_at_idx ON first_party_analytics_cache (expires_at)`,
1312
+ },
1287
1313
  ],
1288
1314
  { table: "analytics_migrations" },
1289
1315
  );
@@ -1312,6 +1338,20 @@ export default async (nitroApp: any): Promise<void> => {
1312
1338
  err instanceof Error ? err.message : err,
1313
1339
  );
1314
1340
  }
1341
+ try {
1342
+ const repairedCount =
1343
+ await repairUnboundedFirstPartyPanelsAcrossDashboards();
1344
+ if (repairedCount > 0) {
1345
+ console.info(
1346
+ `[db] Repaired ${repairedCount} dashboard(s) with unbounded first-party panel SQL.`,
1347
+ );
1348
+ }
1349
+ } catch (err) {
1350
+ console.warn(
1351
+ "[db] Failed to repair unbounded first-party panels across dashboards (non-fatal):",
1352
+ err instanceof Error ? err.message : err,
1353
+ );
1354
+ }
1315
1355
  try {
1316
1356
  const summary = await ensureAdditiveColumns({
1317
1357
  db: getDbExec(),
@@ -1,3 +1,6 @@
1
- export const FIRST_PARTY_ANALYTICS_QUERY_TIMEOUT_MS = 30_000;
2
- export const DASHBOARD_REPORT_ACTION_TIMEOUT_MS = 38_000;
3
- export const DASHBOARD_REPORT_READY_TIMEOUT_MS = 60_000;
1
+ // These three must stay ordered with headroom (DB timeout < action timeout <
2
+ // page-ready timeout) so the innermost layer's own, more specific error
3
+ // surfaces before an outer layer gives up for an unrelated reason.
4
+ export const FIRST_PARTY_ANALYTICS_QUERY_TIMEOUT_MS = 45_000;
5
+ export const DASHBOARD_REPORT_ACTION_TIMEOUT_MS = 50_000;
6
+ export const DASHBOARD_REPORT_READY_TIMEOUT_MS = 75_000;
@@ -18,6 +18,7 @@ import { z } from "zod";
18
18
  import { getDb, schema } from "../server/db/index.js";
19
19
  import {
20
20
  readLiveSourceFile,
21
+ SourceWorkspaceEditConflictError,
21
22
  writeInlineSourceFile,
22
23
  type SourceWorkspaceFile,
23
24
  } from "../server/source-workspace.js";
@@ -303,36 +304,28 @@ export default defineAction({
303
304
  );
304
305
  }
305
306
 
306
- // Read the LIVE base (collab text when present, else the SQL row) right
307
- // before transforming, and carry its versionHash through to the write
308
- // below. writeInlineSourceFile re-reads the live text immediately before
309
- // its own applyText/DB write and rejects if it no longer matches this
310
- // hash — closing the race window where a concurrent editor/agent write
311
- // lands between this read and the persist (the same stale-diff-base bug
312
- // fixed for insert-design-native-asset.ts and insert-asset.ts: a diff
313
- // computed from a stale base, char-diffed into a collab doc that has
314
- // since moved on, corrupts or drops the other writer's change).
315
- const workspaceFile: SourceWorkspaceFile = {
316
- id: file.id,
317
- designId: file.designId,
318
- filename: file.filename ?? "",
319
- fileType: file.fileType ?? "html",
320
- content: file.content,
321
- createdAt: null,
322
- updatedAt: null,
323
- };
324
- const live = await readLiveSourceFile(workspaceFile);
325
- const base = live.content;
326
-
327
307
  const resolvedMode =
328
308
  mode ??
329
309
  (replacementContent !== undefined ? "replace-file" : "search-replace");
330
- const { content: nextContent, applied } =
331
- resolvedMode === "replace-file"
332
- ? { content: replacementContent ?? "", applied: 0 }
333
- : applySearchReplaceEdits(base, edits ?? []);
334
- const changed = nextContent !== base;
335
310
 
311
+ // A concurrent human/agent write can land between reading the live file
312
+ // and persisting this edit (writeInlineSourceFile throws
313
+ // SourceWorkspaceEditConflictError when that happens — see its own
314
+ // comment for the CAS/collab details). search-replace edits are anchored
315
+ // to specific text rather than a stale full snapshot, so on conflict we
316
+ // can just re-read the fresh content and reapply the SAME edits against
317
+ // it instead of forcing the agent to make a separate get-design-snapshot
318
+ // round trip and guess again. replace-file sends a full document computed
319
+ // from a point-in-time snapshot — retrying that blind could silently
320
+ // clobber whatever the concurrent writer did, so it still fails closed on
321
+ // the first conflict.
322
+ const MAX_EDIT_CONFLICT_RETRIES = 2;
323
+
324
+ let live: Awaited<ReturnType<typeof readLiveSourceFile>>;
325
+ let base = "";
326
+ let nextContent = "";
327
+ let applied = 0;
328
+ let changed = false;
336
329
  let creativeContext:
337
330
  | {
338
331
  contextMode: "off" | "auto" | "pinned";
@@ -352,7 +345,43 @@ export default defineAction({
352
345
  }
353
346
  | undefined;
354
347
 
355
- if (changed) {
348
+ for (let attempt = 0; ; attempt += 1) {
349
+ // Refetch the SQL content on retries — readLiveSourceFile only falls
350
+ // back to this when no collab doc exists yet, so a conflict caused by
351
+ // a plain SQL writer (no live collab session) needs a fresh row here
352
+ // or every retry would recompute the exact same stale versionHash.
353
+ const currentContent =
354
+ attempt === 0
355
+ ? file.content
356
+ : (
357
+ await db
358
+ .select({ content: schema.designFiles.content })
359
+ .from(schema.designFiles)
360
+ .where(eq(schema.designFiles.id, file.id))
361
+ .limit(1)
362
+ )[0]?.content;
363
+
364
+ const workspaceFile: SourceWorkspaceFile = {
365
+ id: file.id,
366
+ designId: file.designId,
367
+ filename: file.filename ?? "",
368
+ fileType: file.fileType ?? "html",
369
+ content: currentContent,
370
+ createdAt: null,
371
+ updatedAt: null,
372
+ };
373
+ live = await readLiveSourceFile(workspaceFile);
374
+ base = live.content;
375
+
376
+ ({ content: nextContent, applied } =
377
+ resolvedMode === "replace-file"
378
+ ? { content: replacementContent ?? "", applied: 0 }
379
+ : applySearchReplaceEdits(base, edits ?? []));
380
+ changed = nextContent !== base;
381
+ creativeContext = undefined;
382
+
383
+ if (!changed) break;
384
+
356
385
  const previous =
357
386
  contextModeOverride === "off"
358
387
  ? null
@@ -439,6 +468,15 @@ export default defineAction({
439
468
  content: nextContent,
440
469
  expectedVersionHash: live.versionHash,
441
470
  });
471
+ } catch (error) {
472
+ if (
473
+ error instanceof SourceWorkspaceEditConflictError &&
474
+ resolvedMode === "search-replace" &&
475
+ attempt < MAX_EDIT_CONFLICT_RETRIES
476
+ ) {
477
+ continue;
478
+ }
479
+ throw error;
442
480
  } finally {
443
481
  agentLeaveDocument(file.id);
444
482
  }
@@ -448,6 +486,7 @@ export default defineAction({
448
486
  artifactId: designId,
449
487
  ...creativeContext,
450
488
  });
489
+ break;
451
490
  }
452
491
 
453
492
  return {
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-25
4
+ ---
5
+
6
+ Design edits now retry safely when another collaborator changes the file.
@@ -331,7 +331,11 @@ export async function writeInlineSourceFile(args: {
331
331
  args.expectedVersionHash &&
332
332
  args.expectedVersionHash !== current.versionHash
333
333
  ) {
334
- throw new Error(
334
+ // Typed so callers can catch-and-retry the same way they already do for
335
+ // the other two conflict sites below (see apply-shader-fill.ts /
336
+ // apply-component-prop-edit.ts / edit-design.ts) instead of only
337
+ // matching on message text.
338
+ throw new SourceWorkspaceEditConflictError(
335
339
  "Source file changed since it was read. Re-read the file and retry.",
336
340
  );
337
341
  }
@@ -48,14 +48,16 @@ status, visibility, and an "Open editor" action.
48
48
 
49
49
  ## Exporting Responses
50
50
 
51
- Use `export-responses` to export to CSV or JSON:
51
+ Use `export-responses` to export to CSV or JSON. The export is uploaded to
52
+ configured file storage (never written to local disk — serverless hosts have
53
+ a read-only filesystem) and the action returns the resulting file URL:
52
54
 
53
55
  ```bash
54
56
  # CSV export (default)
55
- pnpm action export-responses --form <form-id> --output data/export.csv
57
+ pnpm action export-responses --form <form-id>
56
58
 
57
59
  # JSON export
58
- pnpm action export-responses --form <form-id> --output data/export.json --format json
60
+ pnpm action export-responses --form <form-id> --format json
59
61
  ```
60
62
 
61
63
  The CSV includes headers derived from field labels. Array values (multiselect) are joined with semicolons.
@@ -113,7 +115,7 @@ To analyze responses, the workflow is:
113
115
  | ---------------------- | ---------------------------------------------------------------------------- |
114
116
  | "@Form setup?" | `preview-form --formId <id>` and answer from the returned fields/settings |
115
117
  | "How many responses?" | `response-insights --formId <id>` and report `summary.responses` |
116
- | "Export to CSV" | `export-responses --form <id> --output data/export.csv` |
118
+ | "Export to CSV" | `export-responses --form <id>` |
117
119
  | "Submissions by day" | `response-insights --formId <id> --days 30` |
118
120
  | "Summarize feedback" | `response-insights`, then `list-responses` if more detail is needed |
119
121
  | "Average rating" | `list-responses`, compute from rating fields and state the sampled row count |
@@ -9,7 +9,10 @@ import { z } from "zod";
9
9
 
10
10
  import { getDb, schema } from "../server/db/index.js";
11
11
  import { assertIntegrationUrlsAllowed } from "../server/lib/integrations.js";
12
- import { assertValidFields } from "../server/lib/validate-fields.js";
12
+ import {
13
+ assertValidFields,
14
+ normalizeFieldIds,
15
+ } from "../server/lib/validate-fields.js";
13
16
  import type { FormField, FormSettings } from "../shared/types.js";
14
17
  import { assertPublishableForm } from "./lib/assert-publishable-form.js";
15
18
 
@@ -88,6 +91,7 @@ export default defineAction({
88
91
  fields = args.fields as unknown as FormField[];
89
92
  }
90
93
  }
94
+ fields = normalizeFieldIds(fields) as FormField[];
91
95
  assertValidFields(fields);
92
96
 
93
97
  const defaultSettings: FormSettings = {
@@ -1,6 +1,6 @@
1
- import fs from "fs";
2
-
3
1
  import { defineAction } from "@agent-native/core";
2
+ import { uploadFile } from "@agent-native/core/file-upload";
3
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
4
4
  import { assertAccess } from "@agent-native/core/sharing";
5
5
  import { eq, desc } from "drizzle-orm";
6
6
  import { z } from "zod";
@@ -12,7 +12,6 @@ export default defineAction({
12
12
  description: "Export form responses to CSV or JSON file.",
13
13
  schema: z.object({
14
14
  form: z.string().describe("Form ID (required)"),
15
- output: z.string().optional().describe("Output file path"),
16
15
  format: z.enum(["csv", "json"]).optional().describe("Export format"),
17
16
  }),
18
17
  http: false,
@@ -28,10 +27,10 @@ export default defineAction({
28
27
  .orderBy(desc(schema.responses.submittedAt));
29
28
 
30
29
  const fields = JSON.parse(form.fields);
31
- const fmt =
32
- args.format || (args.output?.endsWith(".json") ? "json" : "csv");
33
- const outputPath =
34
- args.output || `data/export-${formId}.${fmt === "json" ? "json" : "csv"}`;
30
+ const fmt = args.format || "csv";
31
+ const filename = `export-${formId}.${fmt === "json" ? "json" : "csv"}`;
32
+ const ownerEmail = getRequestUserEmail() ?? undefined;
33
+ let fileBody: string;
35
34
 
36
35
  if (fmt === "json") {
37
36
  const data = responses.map((r) => ({
@@ -42,7 +41,7 @@ export default defineAction({
42
41
  clientSurface: r.clientSurface ?? null,
43
42
  ...JSON.parse(r.data),
44
43
  }));
45
- fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
44
+ fileBody = JSON.stringify(data, null, 2);
46
45
  } else {
47
46
  const headers = [
48
47
  "ID",
@@ -74,17 +73,29 @@ export default defineAction({
74
73
  // with a single quote so spreadsheets treat it as literal text.
75
74
  const neutralize = (cell: string) =>
76
75
  /^[=+\-@\t\r]/.test(cell) ? `'${cell}` : cell;
77
- const csv = [headers, ...rows]
76
+ fileBody = [headers, ...rows]
78
77
  .map((row) =>
79
78
  row
80
79
  .map((cell: string) => `"${neutralize(cell).replace(/"/g, '""')}"`)
81
80
  .join(","),
82
81
  )
83
82
  .join("\n");
83
+ }
84
+
85
+ const uploaded = await uploadFile({
86
+ data: Buffer.from(fileBody, "utf8"),
87
+ mimeType: fmt === "json" ? "application/json" : "text/csv",
88
+ filename,
89
+ ownerEmail,
90
+ }).catch(() => null);
84
91
 
85
- fs.writeFileSync(outputPath, csv);
92
+ if (!uploaded) {
93
+ throw new Error(
94
+ "Export was generated but not saved because file storage is not configured. " +
95
+ "Connect or reconnect Builder.io in Settings → File uploads, or register a custom provider.",
96
+ );
86
97
  }
87
98
 
88
- return `Exported ${responses.length} responses to ${outputPath}`;
99
+ return `Exported ${responses.length} responses to ${uploaded.url}`;
89
100
  },
90
101
  });
@@ -6,7 +6,10 @@ import { z } from "zod";
6
6
  import { getDb, schema } from "../server/db/index.js";
7
7
  import { assertIntegrationUrlsAllowed } from "../server/lib/integrations.js";
8
8
  import { invalidatePublicFormCache } from "../server/lib/public-form-ssr.js";
9
- import { assertValidFields } from "../server/lib/validate-fields.js";
9
+ import {
10
+ assertValidFields,
11
+ normalizeFieldIds,
12
+ } from "../server/lib/validate-fields.js";
10
13
  import type { FormField, FormSettings } from "../shared/types.js";
11
14
  import { assertPublishableForm } from "./lib/assert-publishable-form.js";
12
15
 
@@ -81,6 +84,7 @@ export default defineAction({
81
84
  } else {
82
85
  parsedFields = args.fields;
83
86
  }
87
+ parsedFields = normalizeFieldIds(parsedFields);
84
88
  assertValidFields(parsedFields);
85
89
  updates.fields = JSON.stringify(parsedFields);
86
90
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-25
4
+ ---
5
+
6
+ Forms can create and update fields without manually supplied IDs.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-25
4
+ ---
5
+
6
+ Response exports now save to file storage and return a downloadable URL.
@@ -6,6 +6,49 @@
6
6
  export const FIELD_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7
7
  const CONDITIONAL_OPERATORS = new Set(["equals", "not_equals", "contains"]);
8
8
 
9
+ /**
10
+ * Generates a safe id for a whole-array field replacement (create-form,
11
+ * update-form) when the model omits one — the #1 create-form failure in
12
+ * the 2026-07-25 reliability sweep ("field #1 has an invalid id undefined").
13
+ * Never used by patch-form-fields: there a missing id on an upsert op is
14
+ * ambiguous (new field vs. a forgotten reference to an existing one), so it
15
+ * must keep failing loud rather than risk silently creating a duplicate.
16
+ * Ids are slugified from `label` through the same FIELD_ID_PATTERN charset
17
+ * `assertValidFields` enforces, so a generated id can never fail that check.
18
+ */
19
+ export function normalizeFieldIds(fields: unknown): unknown {
20
+ if (!Array.isArray(fields)) return fields;
21
+ const usedIds = new Set(
22
+ fields
23
+ .map((f) =>
24
+ f && typeof f === "object" ? (f as Record<string, unknown>).id : null,
25
+ )
26
+ .filter(
27
+ (id): id is string =>
28
+ typeof id === "string" && FIELD_ID_PATTERN.test(id),
29
+ ),
30
+ );
31
+ return fields.map((field) => {
32
+ if (field == null || typeof field !== "object") return field;
33
+ const f = field as Record<string, unknown>;
34
+ if (typeof f.id === "string" && FIELD_ID_PATTERN.test(f.id)) return field;
35
+ const label = typeof f.label === "string" ? f.label : "";
36
+ const base =
37
+ label
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9_-]+/g, "_")
40
+ .replace(/^_+|_+$/g, "")
41
+ .slice(0, 40) || "field";
42
+ let candidate = base;
43
+ let suffix = 1;
44
+ while (usedIds.has(candidate)) {
45
+ candidate = `${base}_${++suffix}`;
46
+ }
47
+ usedIds.add(candidate);
48
+ return { ...f, id: candidate };
49
+ });
50
+ }
51
+
9
52
  export function assertValidFields(fields: unknown): void {
10
53
  if (!Array.isArray(fields)) {
11
54
  throw new Error("fields must be an array");