@agent-native/core 0.131.9 → 0.132.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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +99 -0
- package/corpus/core/docs/content/durable-background-runs.mdx +15 -6
- package/corpus/core/docs/design/durable-agent-runs.md +15 -11
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/activity.ts +127 -10
- package/corpus/core/src/a2a/index.ts +3 -0
- package/corpus/core/src/agent/durable-background.ts +38 -28
- package/corpus/core/src/agent/harness/background.ts +12 -2
- package/corpus/core/src/agent/production-agent.ts +18 -15
- package/corpus/core/src/agent/run-store.ts +45 -3
- package/corpus/core/src/application-state/handlers.ts +45 -0
- package/corpus/core/src/application-state/index.ts +3 -0
- package/corpus/core/src/application-state/store.ts +28 -11
- package/corpus/core/src/audit/redact.ts +104 -18
- package/corpus/core/src/client/AgentPanel.tsx +2 -7
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +3 -0
- package/corpus/core/src/client/RunStuckBanner.tsx +29 -6
- package/corpus/core/src/client/agent-chat.ts +4 -9
- package/corpus/core/src/client/application-state.ts +115 -12
- package/corpus/core/src/client/dynamic-suggestions.ts +4 -12
- package/corpus/core/src/client/guided-questions.tsx +12 -23
- package/corpus/core/src/db/client.ts +148 -28
- package/corpus/core/src/db/create-get-db.ts +39 -6
- package/corpus/core/src/db/runtime-diagnostics.ts +41 -6
- package/corpus/core/src/deploy/build.ts +26 -33
- package/corpus/core/src/feature-flags/store.ts +10 -5
- package/corpus/core/src/integrations/a2a-continuations-store.ts +11 -0
- package/corpus/core/src/integrations/config-store.ts +13 -0
- package/corpus/core/src/integrations/google-docs-poller.ts +26 -2
- package/corpus/core/src/mcp-client/routes.ts +33 -2
- package/corpus/core/src/onboarding/plugin.ts +31 -25
- package/corpus/core/src/org/accept-pending.ts +2 -0
- package/corpus/core/src/org/auto-join-domain.ts +2 -0
- package/corpus/core/src/org/context.ts +35 -10
- package/corpus/core/src/org/handlers.ts +6 -0
- package/corpus/core/src/org/request-org-cache.ts +71 -0
- package/corpus/core/src/resources/store.ts +10 -9
- package/corpus/core/src/scripts/runner.ts +6 -3
- package/corpus/core/src/secrets/storage.ts +97 -5
- package/corpus/core/src/server/action-change.ts +33 -0
- package/corpus/core/src/server/action-routes.ts +11 -9
- package/corpus/core/src/server/agent-teams.ts +10 -2
- package/corpus/core/src/server/better-auth-instance.ts +39 -12
- package/corpus/core/src/server/core-routes-plugin.ts +19 -2
- package/corpus/core/src/server/credential-provider.ts +57 -14
- package/corpus/core/src/server/http-response-telemetry.ts +1 -0
- package/corpus/core/src/server/poll.ts +95 -57
- package/corpus/core/src/server/voice-providers-status.ts +19 -4
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -0
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1 -1
- package/corpus/templates/analytics/actions/ensure-native-v2-dashboards.ts +299 -0
- package/corpus/templates/analytics/actions/list-native-v2-dashboard-templates.ts +34 -0
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +82 -0
- package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +2 -2
- package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +40 -34
- package/corpus/templates/analytics/app/hooks/use-user-pref.ts +3 -3
- package/corpus/templates/analytics/app/i18n-data.ts +40 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +18 -10
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +14 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +1 -0
- package/corpus/templates/analytics/app/routes/chart.tsx +3 -0
- package/corpus/templates/analytics/changelog/2026-07-30-analytics-dashboards-now-expose-native-funnel-heatmap-callou.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-30-analytics-now-ships-reusable-native-v2-dashboard-manifests-w.md +6 -0
- package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +81 -1
- package/corpus/templates/analytics/server/lib/native-v2-dashboards.ts +457 -0
- package/corpus/templates/analytics/server/lib/report-chart-svg.ts +73 -2
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/analytics/shared/dashboard-funnel.ts +83 -0
- package/corpus/templates/content/changelog/2026-07-30-long-running-delegated-requests-now-continue-reliably.md +6 -0
- package/corpus/templates/content/netlify.toml +3 -0
- package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
- package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +2 -1
- package/corpus/templates/slides/actions/patch-deck.ts +25 -1
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +17 -4
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +9 -1
- package/corpus/templates/slides/app/components/layout/Layout.tsx +1 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +11 -3
- package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +39 -11
- package/corpus/templates/slides/app/pages/Index.tsx +2 -2
- package/corpus/templates/slides/changelog/2026-07-30-slide-previews-keep-their-aspect-ratio-isolate-embedded-styl.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-slides-no-longer-lose-decks-while-organization-access-is-loa.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +8 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/composer/runtime-adapters.tsx +16 -11
- package/dist/a2a/activity.d.ts +22 -4
- package/dist/a2a/activity.d.ts.map +1 -1
- package/dist/a2a/activity.js +97 -11
- package/dist/a2a/activity.js.map +1 -1
- package/dist/a2a/index.d.ts +1 -1
- package/dist/a2a/index.d.ts.map +1 -1
- package/dist/a2a/index.js +1 -1
- package/dist/a2a/index.js.map +1 -1
- package/dist/agent/durable-background.d.ts +13 -8
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +36 -28
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/harness/background.d.ts.map +1 -1
- package/dist/agent/harness/background.js +12 -2
- package/dist/agent/harness/background.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +18 -15
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-store.d.ts +2 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +44 -3
- package/dist/agent/run-store.js.map +1 -1
- package/dist/application-state/handlers.d.ts +15 -0
- package/dist/application-state/handlers.d.ts.map +1 -1
- package/dist/application-state/handlers.js +42 -2
- package/dist/application-state/handlers.js.map +1 -1
- package/dist/application-state/index.d.ts +2 -2
- package/dist/application-state/index.d.ts.map +1 -1
- package/dist/application-state/index.js +2 -2
- package/dist/application-state/index.js.map +1 -1
- package/dist/application-state/store.d.ts +11 -0
- package/dist/application-state/store.d.ts.map +1 -1
- package/dist/application-state/store.js +23 -10
- package/dist/application-state/store.js.map +1 -1
- package/dist/audit/redact.d.ts +25 -2
- package/dist/audit/redact.d.ts.map +1 -1
- package/dist/audit/redact.js +85 -21
- package/dist/audit/redact.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +2 -7
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +1 -1
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/RunStuckBanner.d.ts +11 -1
- package/dist/client/RunStuckBanner.d.ts.map +1 -1
- package/dist/client/RunStuckBanner.js +20 -7
- package/dist/client/RunStuckBanner.js.map +1 -1
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js +4 -6
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/application-state.d.ts +11 -0
- package/dist/client/application-state.d.ts.map +1 -1
- package/dist/client/application-state.js +87 -9
- package/dist/client/application-state.js.map +1 -1
- package/dist/client/dynamic-suggestions.d.ts.map +1 -1
- package/dist/client/dynamic-suggestions.js +4 -13
- package/dist/client/dynamic-suggestions.js.map +1 -1
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +6 -22
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/db/client.d.ts +57 -10
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +122 -31
- package/dist/db/client.js.map +1 -1
- package/dist/db/create-get-db.d.ts.map +1 -1
- package/dist/db/create-get-db.js +31 -7
- package/dist/db/create-get-db.js.map +1 -1
- package/dist/db/runtime-diagnostics.d.ts +1 -0
- package/dist/db/runtime-diagnostics.d.ts.map +1 -1
- package/dist/db/runtime-diagnostics.js +31 -6
- package/dist/db/runtime-diagnostics.js.map +1 -1
- package/dist/deploy/build.d.ts +5 -20
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +18 -30
- package/dist/deploy/build.js.map +1 -1
- package/dist/feature-flags/store.d.ts.map +1 -1
- package/dist/feature-flags/store.js +10 -5
- package/dist/feature-flags/store.js.map +1 -1
- package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
- package/dist/integrations/a2a-continuations-store.js +12 -0
- package/dist/integrations/a2a-continuations-store.js.map +1 -1
- package/dist/integrations/config-store.d.ts +1 -0
- package/dist/integrations/config-store.d.ts.map +1 -1
- package/dist/integrations/config-store.js +11 -0
- package/dist/integrations/config-store.js.map +1 -1
- package/dist/integrations/google-docs-poller.d.ts.map +1 -1
- package/dist/integrations/google-docs-poller.js +19 -2
- package/dist/integrations/google-docs-poller.js.map +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp-client/routes.d.ts.map +1 -1
- package/dist/mcp-client/routes.js +30 -2
- package/dist/mcp-client/routes.js.map +1 -1
- package/dist/notifications/routes.d.ts +6 -6
- package/dist/observability/routes.d.ts +3 -3
- package/dist/onboarding/plugin.d.ts.map +1 -1
- package/dist/onboarding/plugin.js +12 -8
- package/dist/onboarding/plugin.js.map +1 -1
- package/dist/org/accept-pending.d.ts.map +1 -1
- package/dist/org/accept-pending.js +2 -0
- package/dist/org/accept-pending.js.map +1 -1
- package/dist/org/auto-join-domain.d.ts.map +1 -1
- package/dist/org/auto-join-domain.js +2 -0
- package/dist/org/auto-join-domain.js.map +1 -1
- package/dist/org/context.d.ts.map +1 -1
- package/dist/org/context.js +29 -9
- package/dist/org/context.js.map +1 -1
- package/dist/org/handlers.d.ts.map +1 -1
- package/dist/org/handlers.js +5 -0
- package/dist/org/handlers.js.map +1 -1
- package/dist/org/request-org-cache.d.ts +16 -0
- package/dist/org/request-org-cache.d.ts.map +1 -0
- package/dist/org/request-org-cache.js +62 -0
- package/dist/org/request-org-cache.js.map +1 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/resources/store.d.ts.map +1 -1
- package/dist/resources/store.js +11 -9
- package/dist/resources/store.js.map +1 -1
- package/dist/scripts/runner.d.ts.map +1 -1
- package/dist/scripts/runner.js +3 -3
- package/dist/scripts/runner.js.map +1 -1
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +82 -5
- package/dist/secrets/storage.js.map +1 -1
- package/dist/server/action-change.d.ts +20 -0
- package/dist/server/action-change.d.ts.map +1 -1
- package/dist/server/action-change.js +29 -0
- package/dist/server/action-change.js.map +1 -1
- package/dist/server/action-routes.d.ts.map +1 -1
- package/dist/server/action-routes.js +7 -8
- package/dist/server/action-routes.js.map +1 -1
- package/dist/server/agent-teams.d.ts.map +1 -1
- package/dist/server/agent-teams.js +10 -2
- package/dist/server/agent-teams.js.map +1 -1
- package/dist/server/better-auth-instance.d.ts.map +1 -1
- package/dist/server/better-auth-instance.js +31 -14
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +1 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +18 -4
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/credential-provider.d.ts +16 -0
- package/dist/server/credential-provider.d.ts.map +1 -1
- package/dist/server/credential-provider.js +49 -13
- package/dist/server/credential-provider.js.map +1 -1
- package/dist/server/http-response-telemetry.d.ts.map +1 -1
- package/dist/server/http-response-telemetry.js +1 -0
- package/dist/server/http-response-telemetry.js.map +1 -1
- package/dist/server/poll.d.ts +6 -0
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +84 -47
- package/dist/server/poll.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/server/voice-providers-status.d.ts.map +1 -1
- package/dist/server/voice-providers-status.js +12 -3
- package/dist/server/voice-providers-status.js.map +1 -1
- package/docs/content/durable-background-runs.mdx +15 -6
- package/docs/design/durable-agent-runs.md +15 -11
- package/package.json +2 -2
- package/src/a2a/activity.ts +127 -10
- package/src/a2a/index.ts +3 -0
- package/src/agent/durable-background.ts +38 -28
- package/src/agent/harness/background.ts +12 -2
- package/src/agent/production-agent.ts +18 -15
- package/src/agent/run-store.ts +45 -3
- package/src/application-state/handlers.ts +45 -0
- package/src/application-state/index.ts +3 -0
- package/src/application-state/store.ts +28 -11
- package/src/audit/redact.ts +104 -18
- package/src/client/AgentPanel.tsx +2 -7
- package/src/client/MultiTabAssistantChat.tsx +3 -0
- package/src/client/RunStuckBanner.tsx +29 -6
- package/src/client/agent-chat.ts +4 -9
- package/src/client/application-state.ts +115 -12
- package/src/client/dynamic-suggestions.ts +4 -12
- package/src/client/guided-questions.tsx +12 -23
- package/src/db/client.ts +148 -28
- package/src/db/create-get-db.ts +39 -6
- package/src/db/runtime-diagnostics.ts +41 -6
- package/src/deploy/build.ts +26 -33
- package/src/feature-flags/store.ts +10 -5
- package/src/integrations/a2a-continuations-store.ts +11 -0
- package/src/integrations/config-store.ts +13 -0
- package/src/integrations/google-docs-poller.ts +26 -2
- package/src/mcp-client/routes.ts +33 -2
- package/src/onboarding/plugin.ts +31 -25
- package/src/org/accept-pending.ts +2 -0
- package/src/org/auto-join-domain.ts +2 -0
- package/src/org/context.ts +35 -10
- package/src/org/handlers.ts +6 -0
- package/src/org/request-org-cache.ts +71 -0
- package/src/resources/store.ts +10 -9
- package/src/scripts/runner.ts +6 -3
- package/src/secrets/storage.ts +97 -5
- package/src/server/action-change.ts +33 -0
- package/src/server/action-routes.ts +11 -9
- package/src/server/agent-teams.ts +10 -2
- package/src/server/better-auth-instance.ts +39 -12
- package/src/server/core-routes-plugin.ts +19 -2
- package/src/server/credential-provider.ts +57 -14
- package/src/server/http-response-telemetry.ts +1 -0
- package/src/server/poll.ts +95 -57
- package/src/server/voice-providers-status.ts +19 -4
|
@@ -93,19 +93,117 @@ function jsonBody(value: unknown): string {
|
|
|
93
93
|
return body;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Result of a batched read. `values` holds only the keys the server has a row
|
|
98
|
+
* for; every other requested key is in `missing`. A key stored with a `null`
|
|
99
|
+
* value lands in `values` with `null` and NOT in `missing` — that is how
|
|
100
|
+
* "never written" stays distinguishable from "written as null/empty".
|
|
101
|
+
*/
|
|
102
|
+
export interface ClientAppStateBatch {
|
|
103
|
+
values: Record<string, unknown>;
|
|
104
|
+
missing: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Server caps a batch at 100 keys; stay under it when splitting. */
|
|
108
|
+
const MAX_BATCH_KEYS = 100;
|
|
109
|
+
|
|
110
|
+
export async function readClientAppStateMany(
|
|
111
|
+
keys: readonly string[],
|
|
112
|
+
options: ClientAppStateReadOptions = {},
|
|
113
|
+
): Promise<ClientAppStateBatch> {
|
|
114
|
+
const unique = [...new Set(keys)];
|
|
115
|
+
for (const key of unique) appStateUrl(key); // validates the key shape
|
|
116
|
+
if (unique.length === 0) return { values: {}, missing: [] };
|
|
117
|
+
|
|
118
|
+
const merged: ClientAppStateBatch = { values: {}, missing: [] };
|
|
119
|
+
for (let i = 0; i < unique.length; i += MAX_BATCH_KEYS) {
|
|
120
|
+
const chunk = unique.slice(i, i + MAX_BATCH_KEYS);
|
|
121
|
+
const url = `${agentNativePath("/_agent-native/application-state")}?keys=${chunk
|
|
122
|
+
.map(encodeURIComponent)
|
|
123
|
+
.join(",")}`;
|
|
124
|
+
const response = await fetch(url, {
|
|
125
|
+
method: "GET",
|
|
126
|
+
cache: "no-store",
|
|
127
|
+
signal: options.signal,
|
|
128
|
+
});
|
|
129
|
+
const batch = await parseAppStateResponse<ClientAppStateBatch | null>(
|
|
130
|
+
response,
|
|
131
|
+
`Read application state [${chunk.join(", ")}]`,
|
|
132
|
+
);
|
|
133
|
+
if (!batch || typeof batch !== "object" || !batch.values) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Read application state [${chunk.join(", ")}] returned an unexpected payload.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
Object.assign(merged.values, batch.values);
|
|
139
|
+
merged.missing.push(...(batch.missing ?? []));
|
|
140
|
+
}
|
|
141
|
+
return merged;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Reads issued in the same tick are coalesced into one batched request. Every
|
|
145
|
+
// mounted composer, question card and suggestion hook reads its own key on
|
|
146
|
+
// mount, which used to be one HTTP request (and one full identity resolution)
|
|
147
|
+
// each — ~55 on an analytics dashboard load.
|
|
148
|
+
let pendingBatch:
|
|
149
|
+
| { keys: Set<string>; promise: Promise<ClientAppStateBatch> }
|
|
150
|
+
| undefined;
|
|
151
|
+
|
|
152
|
+
function scheduleBatchedRead(key: string): Promise<ClientAppStateBatch> {
|
|
153
|
+
if (pendingBatch) {
|
|
154
|
+
pendingBatch.keys.add(key);
|
|
155
|
+
return pendingBatch.promise;
|
|
156
|
+
}
|
|
157
|
+
const keys = new Set([key]);
|
|
158
|
+
const promise = new Promise<ClientAppStateBatch>((resolve, reject) => {
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
pendingBatch = undefined;
|
|
161
|
+
readClientAppStateMany([...keys]).then(resolve, reject);
|
|
162
|
+
}, 0);
|
|
163
|
+
});
|
|
164
|
+
pendingBatch = { keys, promise };
|
|
165
|
+
return promise;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function awaitWithAbort<T>(
|
|
169
|
+
promise: Promise<T>,
|
|
170
|
+
signal?: AbortSignal,
|
|
171
|
+
): Promise<T> {
|
|
172
|
+
if (!signal) return promise;
|
|
173
|
+
const abortReason = () =>
|
|
174
|
+
signal.reason ?? new Error("The operation was aborted");
|
|
175
|
+
if (signal.aborted) return Promise.reject(abortReason());
|
|
176
|
+
return new Promise<T>((resolve, reject) => {
|
|
177
|
+
const onAbort = () => {
|
|
178
|
+
cleanup();
|
|
179
|
+
reject(abortReason());
|
|
180
|
+
};
|
|
181
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
182
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
promise.then(
|
|
184
|
+
(value) => {
|
|
185
|
+
cleanup();
|
|
186
|
+
if (signal.aborted) reject(abortReason());
|
|
187
|
+
else resolve(value);
|
|
188
|
+
},
|
|
189
|
+
(error) => {
|
|
190
|
+
cleanup();
|
|
191
|
+
reject(error);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
96
197
|
export async function readClientAppState<T = unknown>(
|
|
97
198
|
key: string,
|
|
98
199
|
options: ClientAppStateReadOptions = {},
|
|
99
200
|
): Promise<T | null> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return
|
|
106
|
-
response,
|
|
107
|
-
`Read application state "${key}"`,
|
|
108
|
-
);
|
|
201
|
+
appStateUrl(key); // validates the key shape before it joins a batch
|
|
202
|
+
if (options.signal?.aborted) {
|
|
203
|
+
throw options.signal.reason ?? new Error("Aborted");
|
|
204
|
+
}
|
|
205
|
+
const batch = await awaitWithAbort(scheduleBatchedRead(key), options.signal);
|
|
206
|
+
return (batch.values[key] ?? null) as T | null;
|
|
109
207
|
}
|
|
110
208
|
|
|
111
209
|
export async function writeClientAppState<T = unknown>(
|
|
@@ -129,9 +227,14 @@ export async function deleteClientAppState(
|
|
|
129
227
|
): Promise<void> {
|
|
130
228
|
const response = await fetch(appStateUrl(key), {
|
|
131
229
|
method: "DELETE",
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
230
|
+
// DELETE carries no JSON body, so this custom header is the only
|
|
231
|
+
// same-origin marker the CSRF check can see from an embedded frame.
|
|
232
|
+
headers: {
|
|
233
|
+
"X-Agent-Native-CSRF": "1",
|
|
234
|
+
...(options.requestSource
|
|
235
|
+
? { "X-Request-Source": options.requestSource }
|
|
236
|
+
: {}),
|
|
237
|
+
},
|
|
135
238
|
keepalive: options.keepalive,
|
|
136
239
|
signal: options.signal,
|
|
137
240
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { readClientAppState } from "./application-state.js";
|
|
4
4
|
import { useChangeVersions } from "./use-change-version.js";
|
|
5
5
|
import type { ChatThreadScope } from "./use-chat-threads.js";
|
|
6
6
|
|
|
@@ -89,17 +89,9 @@ function appStateKeyForBrowserTab(key: string, browserTabId?: string): string {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
async function readAppState(key: string): Promise<unknown> {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
if (!res.ok || res.status === 204) return null;
|
|
96
|
-
const text = await res.text();
|
|
97
|
-
if (!text) return null;
|
|
98
|
-
try {
|
|
99
|
-
return JSON.parse(text);
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
92
|
+
// Reads issued in the same tick coalesce into one batched request, so the
|
|
93
|
+
// four keys below cost one round trip rather than four.
|
|
94
|
+
return readClientAppState(key).catch(() => null);
|
|
103
95
|
}
|
|
104
96
|
|
|
105
97
|
async function readScopedAppState(
|
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
13
13
|
|
|
14
14
|
import { sendToAgentChat } from "./agent-chat.js";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
deleteClientAppState,
|
|
17
|
+
readClientAppState,
|
|
18
|
+
setClientAppState,
|
|
19
|
+
} from "./application-state.js";
|
|
17
20
|
import { useChangeVersions } from "./use-change-version.js";
|
|
18
21
|
import { cn } from "./utils.js";
|
|
19
22
|
|
|
@@ -998,10 +1001,6 @@ export function useGuidedQuestionFlow({
|
|
|
998
1001
|
() => normalizeBrowserTabId(browserTabId),
|
|
999
1002
|
[browserTabId],
|
|
1000
1003
|
);
|
|
1001
|
-
const endpointFor = useCallback(
|
|
1002
|
-
(key: string) => agentNativePath(`/_agent-native/application-state/${key}`),
|
|
1003
|
-
[],
|
|
1004
|
-
);
|
|
1005
1004
|
const scopedKey = normalizedBrowserTabId
|
|
1006
1005
|
? `${stateKey}:${normalizedBrowserTabId}`
|
|
1007
1006
|
: stateKey;
|
|
@@ -1036,17 +1035,11 @@ export function useGuidedQuestionFlow({
|
|
|
1036
1035
|
enabled,
|
|
1037
1036
|
queryFn: async () => {
|
|
1038
1037
|
const read = async (key: string) => {
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
if (
|
|
1043
|
-
|
|
1044
|
-
const parsed = JSON.parse(text);
|
|
1045
|
-
if (Array.isArray(parsed?.questions) && parsed.questions.length > 0) {
|
|
1046
|
-
return parsed as GuidedQuestionPayload;
|
|
1047
|
-
}
|
|
1048
|
-
} catch {
|
|
1049
|
-
return null;
|
|
1038
|
+
const parsed = await readClientAppState<GuidedQuestionPayload>(
|
|
1039
|
+
key,
|
|
1040
|
+
).catch(() => null);
|
|
1041
|
+
if (Array.isArray(parsed?.questions) && parsed.questions.length > 0) {
|
|
1042
|
+
return parsed;
|
|
1050
1043
|
}
|
|
1051
1044
|
return null;
|
|
1052
1045
|
};
|
|
@@ -1086,16 +1079,12 @@ export function useGuidedQuestionFlow({
|
|
|
1086
1079
|
const clear = useCallback(() => {
|
|
1087
1080
|
setPayload(null);
|
|
1088
1081
|
queryClient.setQueryData(resolvedQueryKey, null);
|
|
1089
|
-
const del = (key: string) =>
|
|
1090
|
-
fetch(endpointFor(key), {
|
|
1091
|
-
method: "DELETE",
|
|
1092
|
-
headers: { "X-Agent-Native-CSRF": "1" },
|
|
1093
|
-
}).catch(() => {});
|
|
1082
|
+
const del = (key: string) => deleteClientAppState(key).catch(() => {});
|
|
1094
1083
|
// Clear whichever key actually held the payload (scoped or bare) so the
|
|
1095
1084
|
// card doesn't reappear on the next poll.
|
|
1096
1085
|
del(scopedKey);
|
|
1097
1086
|
if (scopedKey !== stateKey) del(stateKey);
|
|
1098
|
-
}, [
|
|
1087
|
+
}, [queryClient, resolvedQueryKey, scopedKey, stateKey]);
|
|
1099
1088
|
|
|
1100
1089
|
const handleSubmit = useCallback(
|
|
1101
1090
|
(answers: GuidedQuestionAnswers) => {
|
|
@@ -954,19 +954,19 @@ export function isServerlessRuntime(): boolean {
|
|
|
954
954
|
}
|
|
955
955
|
|
|
956
956
|
/**
|
|
957
|
-
* postgres.js pool options tuned per runtime.
|
|
958
|
-
* one request at a time, so a single connection is enough. Keeping the
|
|
959
|
-
* foreground pool at 1 is important because each instance can also open an
|
|
960
|
-
* app and Better Auth pool; a max of 2 on each pool still exhausted Neon
|
|
961
|
-
* under a burst of warm instances. idle_timeout is shortened on
|
|
957
|
+
* postgres.js pool options tuned per runtime. idle_timeout is shortened on
|
|
962
958
|
* serverless so a thawed-but-idle instance releases its connections quickly.
|
|
963
|
-
* Long-lived Node servers keep the
|
|
959
|
+
* Long-lived Node servers keep the larger pool for throughput.
|
|
960
|
+
*
|
|
961
|
+
* The cap assumes {@link sharedDbPool}: one pool per URL for the whole
|
|
962
|
+
* process, not one per consumer. See {@link neonPoolMax} for why the cap must
|
|
963
|
+
* leave room for concurrency.
|
|
964
964
|
*/
|
|
965
965
|
export function pgPoolOptions(url: string): Record<string, unknown> {
|
|
966
966
|
const serverless = isServerlessRuntime();
|
|
967
967
|
return {
|
|
968
968
|
onnotice: () => {},
|
|
969
|
-
max: serverless ?
|
|
969
|
+
max: serverless ? 4 : 20,
|
|
970
970
|
idle_timeout: serverless ? 20 : 240,
|
|
971
971
|
max_lifetime: 60 * 30,
|
|
972
972
|
connect_timeout: 10,
|
|
@@ -977,13 +977,21 @@ export function pgPoolOptions(url: string): Record<string, unknown> {
|
|
|
977
977
|
}
|
|
978
978
|
|
|
979
979
|
/**
|
|
980
|
-
* Connection cap for the @neondatabase/serverless `Pool`.
|
|
981
|
-
*
|
|
982
|
-
*
|
|
983
|
-
*
|
|
980
|
+
* Connection cap for the @neondatabase/serverless `Pool`.
|
|
981
|
+
*
|
|
982
|
+
* TRAP: this number is the ceiling on how many statements one request can have
|
|
983
|
+
* in flight at once. It used to be 1 on serverless, which made every
|
|
984
|
+
* `Promise.all([...reads])` in a request serialize behind the single slot —
|
|
985
|
+
* against a remote endpoint that is ~83ms of pure wait per query, so ten
|
|
986
|
+
* "concurrent" reads took ~880ms instead of ~86ms. The cap was 1 only because
|
|
987
|
+
* every consumer built its OWN pool (the DbExec singleton, Better Auth, and one
|
|
988
|
+
* per `createGetDb()` schema module), so the real per-instance connection count
|
|
989
|
+
* was that number multiplied by ~6. {@link sharedDbPool} collapses those into
|
|
990
|
+
* one pool per URL, so the same aggregate budget buys in-request concurrency.
|
|
991
|
+
* Keep pools shared if you raise this.
|
|
984
992
|
*/
|
|
985
993
|
export function neonPoolMax(): number {
|
|
986
|
-
if (!isServerlessRuntime()) return
|
|
994
|
+
if (!isServerlessRuntime()) return 20;
|
|
987
995
|
// The durable background-function worker is a SINGLE process per run (unlike
|
|
988
996
|
// the many warm request-instances the foreground serverless has), so it can
|
|
989
997
|
// safely hold a larger pool without risking Neon's connection cap. The agent's
|
|
@@ -996,7 +1004,7 @@ export function neonPoolMax(): number {
|
|
|
996
1004
|
// burst; keep the foreground serverless pool tiny to avoid "Max client
|
|
997
1005
|
// connections reached" across many warm instances.
|
|
998
1006
|
if (isBackgroundFunctionPoolContext()) return 8;
|
|
999
|
-
return
|
|
1007
|
+
return 4;
|
|
1000
1008
|
}
|
|
1001
1009
|
|
|
1002
1010
|
/**
|
|
@@ -1110,6 +1118,113 @@ export function attachNeonPoolErrorLogger(
|
|
|
1110
1118
|
});
|
|
1111
1119
|
}
|
|
1112
1120
|
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Shared connection pools
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
interface ClosablePool {
|
|
1126
|
+
end(): Promise<unknown>;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const _sharedDbPools = new Map<string, ClosablePool>();
|
|
1130
|
+
const _sharedDbPoolCloseHooks = new Set<() => void>();
|
|
1131
|
+
const _sharedDbPoolReplacementHooks = new Map<string, Set<() => void>>();
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* One connection pool per (driver, URL) for the whole process.
|
|
1135
|
+
*
|
|
1136
|
+
* Opening a connection to a remote database costs a full TCP + TLS + auth
|
|
1137
|
+
* round-trip chain (~300-800ms measured against Neon in-region), and it used
|
|
1138
|
+
* to be paid once per CONSUMER: the `getDbExec` singleton, Better Auth, and one
|
|
1139
|
+
* more for every `createGetDb()` schema module (four in core alone, plus each
|
|
1140
|
+
* app's). Their `max` values also multiplied against the provider's connection
|
|
1141
|
+
* cap, which forced each pool down to a size too small to run a request's reads
|
|
1142
|
+
* concurrently.
|
|
1143
|
+
*
|
|
1144
|
+
* TRAP: only consumers that live as long as the process may share. Anything
|
|
1145
|
+
* handed a `close()` — `createDbExec()`, the migration exec on the direct
|
|
1146
|
+
* endpoint — must keep a private pool, or closing it takes the whole process's
|
|
1147
|
+
* database access down with it.
|
|
1148
|
+
*/
|
|
1149
|
+
export function sharedDbPool<T extends ClosablePool>(
|
|
1150
|
+
driver: string,
|
|
1151
|
+
url: string,
|
|
1152
|
+
create: () => T,
|
|
1153
|
+
): T {
|
|
1154
|
+
const key = `${driver}${url}`;
|
|
1155
|
+
const existing = _sharedDbPools.get(key);
|
|
1156
|
+
if (existing) return existing as T;
|
|
1157
|
+
const created = create();
|
|
1158
|
+
_sharedDbPools.set(key, created);
|
|
1159
|
+
return created;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Swap the pool registered for a key, for the postgres.js recycle path that
|
|
1164
|
+
* replaces a pool whose query timed out. Without this the registry would keep
|
|
1165
|
+
* handing out the discarded pool.
|
|
1166
|
+
*/
|
|
1167
|
+
export function replaceSharedDbPool<T extends ClosablePool>(
|
|
1168
|
+
driver: string,
|
|
1169
|
+
url: string,
|
|
1170
|
+
previous: T,
|
|
1171
|
+
next: T,
|
|
1172
|
+
): void {
|
|
1173
|
+
const key = `${driver}${url}`;
|
|
1174
|
+
if (_sharedDbPools.get(key) !== previous) return;
|
|
1175
|
+
_sharedDbPools.set(key, next);
|
|
1176
|
+
for (const hook of _sharedDbPoolReplacementHooks.get(key) ?? []) {
|
|
1177
|
+
try {
|
|
1178
|
+
hook();
|
|
1179
|
+
} catch {
|
|
1180
|
+
// A consumer's reset must not block the replacement from being used.
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Run `hook` when one shared pool is replaced, so derived consumers rebuild
|
|
1187
|
+
* their handles instead of continuing to use the timed-out pool.
|
|
1188
|
+
*/
|
|
1189
|
+
export function onSharedDbPoolReplaced(
|
|
1190
|
+
driver: string,
|
|
1191
|
+
url: string,
|
|
1192
|
+
hook: () => void,
|
|
1193
|
+
): void {
|
|
1194
|
+
const key = `${driver}${String.fromCharCode(0)}${url}`;
|
|
1195
|
+
const hooks = _sharedDbPoolReplacementHooks.get(key) ?? new Set();
|
|
1196
|
+
hooks.add(hook);
|
|
1197
|
+
_sharedDbPoolReplacementHooks.set(key, hooks);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Run `hook` when the shared pools are closed, so consumers holding a derived
|
|
1202
|
+
* handle (a Drizzle instance bound to the pool, the Better Auth adapter) drop
|
|
1203
|
+
* it instead of issuing queries on a closed pool.
|
|
1204
|
+
*/
|
|
1205
|
+
export function onSharedDbPoolsClosed(hook: () => void): void {
|
|
1206
|
+
_sharedDbPoolCloseHooks.add(hook);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
export async function closeSharedDbPools(): Promise<void> {
|
|
1210
|
+
const pools = [..._sharedDbPools.values()];
|
|
1211
|
+
_sharedDbPools.clear();
|
|
1212
|
+
for (const hook of _sharedDbPoolCloseHooks) {
|
|
1213
|
+
try {
|
|
1214
|
+
hook();
|
|
1215
|
+
} catch {
|
|
1216
|
+
// A consumer's reset must not block the rest from being released.
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
await Promise.all(
|
|
1220
|
+
pools.map((pool) =>
|
|
1221
|
+
Promise.resolve(pool.end()).catch(() => {
|
|
1222
|
+
// Already closed (process exiting, provider hung up) — nothing to do.
|
|
1223
|
+
}),
|
|
1224
|
+
),
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1113
1228
|
function disposePostgresPoolEventually(
|
|
1114
1229
|
pool: { end: () => Promise<unknown> },
|
|
1115
1230
|
label: string,
|
|
@@ -1130,8 +1245,6 @@ function disposePostgresPoolEventually(
|
|
|
1130
1245
|
// ---------------------------------------------------------------------------
|
|
1131
1246
|
|
|
1132
1247
|
let _exec: DbExec | undefined;
|
|
1133
|
-
let _pgPool: any;
|
|
1134
|
-
let _neonPool: any;
|
|
1135
1248
|
let _sqlite: any;
|
|
1136
1249
|
let _initPromise: Promise<void> | undefined;
|
|
1137
1250
|
|
|
@@ -1231,9 +1344,14 @@ async function createDbExecInternal(
|
|
|
1231
1344
|
// connections, so its direct executions use stateless Neon HTTP instead.
|
|
1232
1345
|
// The foreground and transaction surface keep the WebSocket pool.
|
|
1233
1346
|
const bgHttp = isBackgroundFunctionPoolContext();
|
|
1234
|
-
const
|
|
1347
|
+
const makePool = () =>
|
|
1348
|
+
new Pool({ connectionString: url, max: neonPoolMax() });
|
|
1349
|
+
// The singleton exec shares the process pool; `createDbExec()` callers own
|
|
1350
|
+
// a `close()` and so must not be handed it.
|
|
1351
|
+
const pool = trackSingletonResources
|
|
1352
|
+
? sharedDbPool("neon", url, makePool)
|
|
1353
|
+
: makePool();
|
|
1235
1354
|
attachNeonPoolErrorLogger(pool);
|
|
1236
|
-
if (trackSingletonResources) _neonPool = pool;
|
|
1237
1355
|
const httpSql = bgHttp ? neon(url, { fullResults: true }) : null;
|
|
1238
1356
|
async function queryNeonClient(
|
|
1239
1357
|
client: any,
|
|
@@ -1461,6 +1579,7 @@ async function createDbExecInternal(
|
|
|
1461
1579
|
}, 1);
|
|
1462
1580
|
},
|
|
1463
1581
|
async close() {
|
|
1582
|
+
if (trackSingletonResources) return closeSharedDbPools();
|
|
1464
1583
|
await pool.end();
|
|
1465
1584
|
},
|
|
1466
1585
|
};
|
|
@@ -1566,12 +1685,17 @@ async function createDbExecInternal(
|
|
|
1566
1685
|
// server-side timeout, avoiding ECONNRESET when the server hangs up.
|
|
1567
1686
|
const createPool = () => postgres(url, pgPoolOptions(url));
|
|
1568
1687
|
type PostgresPool = ReturnType<typeof createPool>;
|
|
1569
|
-
|
|
1570
|
-
|
|
1688
|
+
// Same rule as the Neon path: the singleton exec shares the process pool,
|
|
1689
|
+
// `createDbExec()` callers own a `close()` and get a private one.
|
|
1690
|
+
let pool = trackSingletonResources
|
|
1691
|
+
? sharedDbPool("postgres-js", url, createPool)
|
|
1692
|
+
: createPool();
|
|
1571
1693
|
const recyclePool = (timedOutPool: PostgresPool) => {
|
|
1572
1694
|
if (pool === timedOutPool) {
|
|
1573
1695
|
pool = createPool();
|
|
1574
|
-
if (trackSingletonResources)
|
|
1696
|
+
if (trackSingletonResources) {
|
|
1697
|
+
replaceSharedDbPool("postgres-js", url, timedOutPool, pool);
|
|
1698
|
+
}
|
|
1575
1699
|
}
|
|
1576
1700
|
disposePostgresPoolEventually(timedOutPool, "timed-out pooled query");
|
|
1577
1701
|
};
|
|
@@ -1626,6 +1750,7 @@ async function createDbExecInternal(
|
|
|
1626
1750
|
return result as T;
|
|
1627
1751
|
},
|
|
1628
1752
|
async close() {
|
|
1753
|
+
if (trackSingletonResources) return closeSharedDbPools();
|
|
1629
1754
|
await pool.end();
|
|
1630
1755
|
},
|
|
1631
1756
|
};
|
|
@@ -1884,14 +2009,9 @@ export function getDbExec(): DbExec {
|
|
|
1884
2009
|
|
|
1885
2010
|
/** Close the database connection (for scripts that need cleanup). */
|
|
1886
2011
|
export async function closeDbExec(): Promise<void> {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
}
|
|
1891
|
-
if (_neonPool) {
|
|
1892
|
-
await _neonPool.end();
|
|
1893
|
-
_neonPool = undefined;
|
|
1894
|
-
}
|
|
2012
|
+
// Both Postgres pools live in the shared registry, which also notifies the
|
|
2013
|
+
// Drizzle / Better Auth consumers bound to them.
|
|
2014
|
+
await closeSharedDbPools();
|
|
1895
2015
|
if (_sqlite) {
|
|
1896
2016
|
_sqlite.close();
|
|
1897
2017
|
_sqlite = undefined;
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
withDbTimeout,
|
|
22
22
|
retryOnConnectionError,
|
|
23
23
|
dbOpTimeoutMs,
|
|
24
|
+
sharedDbPool,
|
|
25
|
+
onSharedDbPoolsClosed,
|
|
26
|
+
onSharedDbPoolReplaced,
|
|
24
27
|
} from "./client.js";
|
|
25
28
|
|
|
26
29
|
// Lazy driver loaders — cached promises so dynamic import only runs once.
|
|
@@ -444,6 +447,27 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
|
|
|
444
447
|
let _db: any;
|
|
445
448
|
let _dbReady: Promise<any> | undefined;
|
|
446
449
|
|
|
450
|
+
// The Drizzle instance is bound to a shared pool, so a `closeDbExec()` (test
|
|
451
|
+
// teardown, script cleanup) must invalidate it rather than leave this store
|
|
452
|
+
// issuing queries on a closed pool. Registered lazily from the pooled
|
|
453
|
+
// branches only — `createGetDb` is called at module scope by every store, and
|
|
454
|
+
// core's specs widely mock `db/client.js`.
|
|
455
|
+
let _closeHookRegistered = false;
|
|
456
|
+
function resetOnPoolClose(driver?: string, url?: string): void {
|
|
457
|
+
if (_closeHookRegistered) return;
|
|
458
|
+
_closeHookRegistered = true;
|
|
459
|
+
onSharedDbPoolsClosed(() => {
|
|
460
|
+
_db = undefined;
|
|
461
|
+
_dbReady = undefined;
|
|
462
|
+
});
|
|
463
|
+
if (driver && url) {
|
|
464
|
+
onSharedDbPoolReplaced(driver, url, () => {
|
|
465
|
+
_db = undefined;
|
|
466
|
+
_dbReady = undefined;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
447
471
|
function startInit(): Promise<any> {
|
|
448
472
|
if (_dbReady) return _dbReady;
|
|
449
473
|
|
|
@@ -473,10 +497,15 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
|
|
|
473
497
|
if (dialect === "postgres") {
|
|
474
498
|
if (isNeonUrl(url)) {
|
|
475
499
|
_dbReady = getNeonServerlessDrizzle().then(({ drizzle, Pool }) => {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
500
|
+
// Shared with the DbExec singleton, Better Auth, and every other
|
|
501
|
+
// `createGetDb` store: one connect per process instead of one per
|
|
502
|
+
// schema module. See `sharedDbPool` in client.ts.
|
|
503
|
+
resetOnPoolClose("neon", url);
|
|
504
|
+
const rawPool = sharedDbPool(
|
|
505
|
+
"neon",
|
|
506
|
+
url,
|
|
507
|
+
() => new Pool({ connectionString: url, max: neonPoolMax() }),
|
|
508
|
+
);
|
|
480
509
|
attachNeonPoolErrorLogger(rawPool);
|
|
481
510
|
// Wrap the pool with the resilience layer so Drizzle queries get the
|
|
482
511
|
// same withDbTimeout + retryOnConnectionError protection as the raw
|
|
@@ -489,8 +518,12 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
|
|
|
489
518
|
_dbReady = getPgDrizzle().then(({ drizzle, postgres }) => {
|
|
490
519
|
// pgPoolOptions caps the pool to a small size on serverless so
|
|
491
520
|
// concurrent frozen instances don't exhaust Neon/Postgres'
|
|
492
|
-
// connection limit ("Max client connections reached").
|
|
493
|
-
|
|
521
|
+
// connection limit ("Max client connections reached"). Shared across
|
|
522
|
+
// consumers — see `sharedDbPool` in client.ts.
|
|
523
|
+
resetOnPoolClose("postgres-js", url);
|
|
524
|
+
const client = sharedDbPool("postgres-js", url, () =>
|
|
525
|
+
postgres(url, pgPoolOptions(url)),
|
|
526
|
+
);
|
|
494
527
|
_db = drizzle(buildResilientPostgresJsClient(client), { schema });
|
|
495
528
|
});
|
|
496
529
|
}
|
|
@@ -51,7 +51,7 @@ export interface DatabaseSchemaHealthResult {
|
|
|
51
51
|
error?: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
|
|
54
|
+
export const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
|
|
55
55
|
{
|
|
56
56
|
table: "agent_runs",
|
|
57
57
|
columns: [
|
|
@@ -253,6 +253,24 @@ async function tableColumns(
|
|
|
253
253
|
: sqliteTableColumns(exec, table);
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Memo for the default probe, which several client surfaces re-run on every
|
|
258
|
+
* page load at one `information_schema` round trip per required table.
|
|
259
|
+
*
|
|
260
|
+
* Only a clean, completed result is memoized: a probe reporting a missing table
|
|
261
|
+
* or an unreadable database must never be served from cache, or the repair that
|
|
262
|
+
* follows stays invisible for the whole window. Schema changes are additive and
|
|
263
|
+
* applied by migrations, so a clean answer cannot silently go bad — but the
|
|
264
|
+
* window is still kept to seconds, short enough that anything a deploy changes
|
|
265
|
+
* shows up on the next page load rather than being pinned. Callers that
|
|
266
|
+
* override `exec`, `dialect`, or `required` bypass it entirely.
|
|
267
|
+
*/
|
|
268
|
+
const SCHEMA_HEALTH_MEMO_MS = 5_000;
|
|
269
|
+
let schemaHealthMemo: {
|
|
270
|
+
at: number;
|
|
271
|
+
result: DatabaseSchemaHealthResult;
|
|
272
|
+
} | null = null;
|
|
273
|
+
|
|
256
274
|
export async function runDatabaseSchemaHealthCheck(
|
|
257
275
|
options: {
|
|
258
276
|
exec?: DbExec;
|
|
@@ -260,6 +278,15 @@ export async function runDatabaseSchemaHealthCheck(
|
|
|
260
278
|
required?: RequiredSchemaTable[];
|
|
261
279
|
} = {},
|
|
262
280
|
): Promise<DatabaseSchemaHealthResult> {
|
|
281
|
+
const memoizable = !options.exec && !options.dialect && !options.required;
|
|
282
|
+
if (
|
|
283
|
+
memoizable &&
|
|
284
|
+
schemaHealthMemo &&
|
|
285
|
+
Date.now() - schemaHealthMemo.at < SCHEMA_HEALTH_MEMO_MS
|
|
286
|
+
) {
|
|
287
|
+
return schemaHealthMemo.result;
|
|
288
|
+
}
|
|
289
|
+
|
|
263
290
|
const dialect = options.dialect ?? getDialect();
|
|
264
291
|
const exec = options.exec ?? getDbExec();
|
|
265
292
|
const required = options.required ?? DEFAULT_REQUIRED_SCHEMA;
|
|
@@ -267,18 +294,24 @@ export async function runDatabaseSchemaHealthCheck(
|
|
|
267
294
|
const missingColumns: Array<{ table: string; column: string }> = [];
|
|
268
295
|
|
|
269
296
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
297
|
+
// Independent probes: serially they cost one network round trip each.
|
|
298
|
+
const found = await Promise.all(
|
|
299
|
+
required.map((requirement) =>
|
|
300
|
+
tableColumns(exec, dialect, requirement.table),
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
required.forEach((requirement, index) => {
|
|
304
|
+
const columns = found[index];
|
|
272
305
|
if (!columns) {
|
|
273
306
|
missingTables.push(requirement.table);
|
|
274
|
-
|
|
307
|
+
return;
|
|
275
308
|
}
|
|
276
309
|
for (const column of requirement.columns) {
|
|
277
310
|
if (!columns.has(column)) {
|
|
278
311
|
missingColumns.push({ table: requirement.table, column });
|
|
279
312
|
}
|
|
280
313
|
}
|
|
281
|
-
}
|
|
314
|
+
});
|
|
282
315
|
} catch (err) {
|
|
283
316
|
return {
|
|
284
317
|
ok: false,
|
|
@@ -290,13 +323,15 @@ export async function runDatabaseSchemaHealthCheck(
|
|
|
290
323
|
};
|
|
291
324
|
}
|
|
292
325
|
|
|
293
|
-
|
|
326
|
+
const result: DatabaseSchemaHealthResult = {
|
|
294
327
|
ok: missingTables.length === 0 && missingColumns.length === 0,
|
|
295
328
|
checked: true,
|
|
296
329
|
dialect,
|
|
297
330
|
missingTables,
|
|
298
331
|
missingColumns,
|
|
299
332
|
};
|
|
333
|
+
if (memoizable && result.ok) schemaHealthMemo = { at: Date.now(), result };
|
|
334
|
+
return result;
|
|
300
335
|
}
|
|
301
336
|
|
|
302
337
|
export function formatRuntimeDebugFingerprint(
|