@agent-native/core 0.109.2 → 0.109.3
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 +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/create.ts +18 -12
- package/corpus/core/src/client/require-session.tsx +19 -3
- package/corpus/core/src/client/use-action.ts +145 -2
- package/corpus/core/src/client/use-session.ts +79 -48
- package/corpus/core/src/db/client.ts +13 -7
- package/corpus/core/src/db/create-get-db.ts +20 -13
- package/corpus/core/src/db/request-telemetry.ts +154 -0
- package/corpus/core/src/integrations/index.ts +1 -0
- package/corpus/core/src/integrations/plugin.ts +9 -3
- package/corpus/core/src/integrations/remote-push-delivery-job.ts +48 -0
- package/corpus/core/src/integrations/remote-push-delivery.ts +370 -0
- package/corpus/core/src/integrations/remote-push-store.ts +275 -26
- package/corpus/core/src/server/better-auth-instance.ts +11 -3
- package/corpus/core/src/server/core-routes-plugin.ts +36 -14
- package/corpus/core/src/server/framework-request-handler.ts +11 -1
- package/corpus/core/src/server/http-response-telemetry.ts +233 -31
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +3 -1
- package/corpus/templates/clips/actions/set-mobile-capture-state.ts +4 -1
- package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -6
- package/corpus/templates/clips/changelog/2026-07-18-new-recording-is-now-emphasized-as-the-primary-action-in-the.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-recordings-now-remain-safely-recoverable-until-the-stored-me.md +6 -0
- package/corpus/templates/tasks/AGENTS.md +3 -0
- package/corpus/templates/tasks/actions/render-task-list-inline.ts +202 -0
- package/corpus/templates/tasks/changelog/2026-07-18-task-lists-can-now-appear-as-interactive-widgets-inline-in-c.md +6 -0
- package/corpus/templates/tasks/docs/features/f8-chat.mdx +2 -0
- package/corpus/templates/tasks/server/plugins/agent-chat.ts +2 -0
- package/dist/cli/create.d.ts +10 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +17 -13
- package/dist/cli/create.js.map +1 -1
- package/dist/client/require-session.d.ts.map +1 -1
- package/dist/client/require-session.js +6 -3
- package/dist/client/require-session.js.map +1 -1
- package/dist/client/use-action.d.ts.map +1 -1
- package/dist/client/use-action.js +117 -1
- package/dist/client/use-action.js.map +1 -1
- package/dist/client/use-session.d.ts.map +1 -1
- package/dist/client/use-session.js +72 -50
- package/dist/client/use-session.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +7 -1
- package/dist/db/client.js.map +1 -1
- package/dist/db/create-get-db.d.ts +3 -6
- package/dist/db/create-get-db.d.ts.map +1 -1
- package/dist/db/create-get-db.js +9 -2
- package/dist/db/create-get-db.js.map +1 -1
- package/dist/db/request-telemetry.d.ts +23 -0
- package/dist/db/request-telemetry.d.ts.map +1 -0
- package/dist/db/request-telemetry.js +95 -0
- package/dist/db/request-telemetry.js.map +1 -0
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/index.d.ts +1 -0
- package/dist/integrations/index.d.ts.map +1 -1
- package/dist/integrations/index.js +1 -0
- package/dist/integrations/index.js.map +1 -1
- package/dist/integrations/plugin.d.ts.map +1 -1
- package/dist/integrations/plugin.js +8 -3
- package/dist/integrations/plugin.js.map +1 -1
- package/dist/integrations/remote-push-delivery-job.d.ts +4 -0
- package/dist/integrations/remote-push-delivery-job.d.ts.map +1 -0
- package/dist/integrations/remote-push-delivery-job.js +44 -0
- package/dist/integrations/remote-push-delivery-job.js.map +1 -0
- package/dist/integrations/remote-push-delivery.d.ts +11 -0
- package/dist/integrations/remote-push-delivery.d.ts.map +1 -0
- package/dist/integrations/remote-push-delivery.js +265 -0
- package/dist/integrations/remote-push-delivery.js.map +1 -0
- package/dist/integrations/remote-push-store.d.ts +33 -0
- package/dist/integrations/remote-push-store.d.ts.map +1 -1
- package/dist/integrations/remote-push-store.js +195 -26
- package/dist/integrations/remote-push-store.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/better-auth-instance.js +7 -3
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +8 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +23 -8
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/framework-request-handler.d.ts.map +1 -1
- package/dist/server/framework-request-handler.js +9 -1
- package/dist/server/framework-request-handler.js.map +1 -1
- package/dist/server/http-response-telemetry.d.ts +3 -2
- package/dist/server/http-response-telemetry.d.ts.map +1 -1
- package/dist/server/http-response-telemetry.js +160 -27
- package/dist/server/http-response-telemetry.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.109.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5199e0b: Make Chat the first and default starting point in the new-app CLI picker.
|
|
8
|
+
- 5199e0b: Track browser, server, cold-start, framework-readiness, and database latency for app requests; retain action 4xx failures; parallelize legacy route bootstrap reads; and deduplicate session lookups without treating transient auth failures as signed-out sessions.
|
|
9
|
+
- 5199e0b: Deliver remote-session Expo push notifications through a retrying, batched outbox worker and verify provider receipts before marking delivery complete.
|
|
10
|
+
|
|
3
11
|
## 0.109.2
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.109.
|
|
3
|
+
"version": "0.109.3",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -200,31 +200,36 @@ export async function createApp(
|
|
|
200
200
|
async function promptStartShape(
|
|
201
201
|
clack: typeof import("@clack/prompts"),
|
|
202
202
|
): Promise<"template" | "chat" | "headless"> {
|
|
203
|
-
const choice = await clack.select(
|
|
203
|
+
const choice = await clack.select(startShapePromptOptions());
|
|
204
|
+
if (clack.isCancel(choice)) {
|
|
205
|
+
clack.cancel("Cancelled.");
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
return choice as "template" | "chat" | "headless";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function startShapePromptOptions() {
|
|
212
|
+
return {
|
|
204
213
|
message: "How do you want to start?",
|
|
214
|
+
initialValue: "chat",
|
|
205
215
|
options: [
|
|
206
|
-
{
|
|
207
|
-
value: "template",
|
|
208
|
-
label: "Full template(s)",
|
|
209
|
-
hint: "Clone complete apps (Mail, Calendar, Slides, ...) into a workspace",
|
|
210
|
-
},
|
|
211
216
|
{
|
|
212
217
|
value: "chat",
|
|
213
218
|
label: "Chat",
|
|
214
219
|
hint: "A single app with a minimal chat UI and the browser shell wired up",
|
|
215
220
|
},
|
|
221
|
+
{
|
|
222
|
+
value: "template",
|
|
223
|
+
label: "Full template(s)",
|
|
224
|
+
hint: "Clone complete apps (Mail, Calendar, Slides, ...) into a workspace",
|
|
225
|
+
},
|
|
216
226
|
{
|
|
217
227
|
value: "headless",
|
|
218
228
|
label: "Headless",
|
|
219
229
|
hint: "A single action-first app with one primitive and no UI shell",
|
|
220
230
|
},
|
|
221
231
|
],
|
|
222
|
-
}
|
|
223
|
-
if (clack.isCancel(choice)) {
|
|
224
|
-
clack.cancel("Cancelled.");
|
|
225
|
-
process.exit(0);
|
|
226
|
-
}
|
|
227
|
-
return choice as "template" | "chat" | "headless";
|
|
232
|
+
};
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
/**
|
|
@@ -1348,6 +1353,7 @@ export {
|
|
|
1348
1353
|
githubTarballUrl as _githubTarballUrl,
|
|
1349
1354
|
findLocalTemplateFrom as _findLocalTemplateFrom,
|
|
1350
1355
|
workspaceAppNameForTemplateSelection as _workspaceAppNameForTemplateSelection,
|
|
1356
|
+
startShapePromptOptions as _startShapePromptOptions,
|
|
1351
1357
|
shouldSkipScaffoldEntry as _shouldSkipScaffoldEntry,
|
|
1352
1358
|
tarExtractArgs as _tarExtractArgs,
|
|
1353
1359
|
};
|
|
@@ -95,14 +95,31 @@ export function RequireSession({
|
|
|
95
95
|
signedOut,
|
|
96
96
|
bypass = false,
|
|
97
97
|
}: RequireSessionProps) {
|
|
98
|
+
if (bypass) return <>{children}</>;
|
|
99
|
+
return (
|
|
100
|
+
<ResolvedSessionGate
|
|
101
|
+
fallback={fallback}
|
|
102
|
+
redirect={redirect}
|
|
103
|
+
signedOut={signedOut}
|
|
104
|
+
>
|
|
105
|
+
{children}
|
|
106
|
+
</ResolvedSessionGate>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ResolvedSessionGate({
|
|
111
|
+
children,
|
|
112
|
+
fallback,
|
|
113
|
+
redirect = true,
|
|
114
|
+
signedOut,
|
|
115
|
+
}: Omit<RequireSessionProps, "bypass">) {
|
|
98
116
|
const { session, isLoading } = useSession();
|
|
99
117
|
// Guard against firing the redirect more than once (effect re-runs, React
|
|
100
118
|
// StrictMode double-invoke) — a second navigation while the first is in
|
|
101
119
|
// flight is harmless but noisy.
|
|
102
120
|
const redirectedRef = useRef(false);
|
|
103
121
|
|
|
104
|
-
const mustRedirect =
|
|
105
|
-
!bypass && !isLoading && !session && redirect && !isOnSignInPage();
|
|
122
|
+
const mustRedirect = !isLoading && !session && redirect && !isOnSignInPage();
|
|
106
123
|
|
|
107
124
|
useEffect(() => {
|
|
108
125
|
if (!mustRedirect) return;
|
|
@@ -114,7 +131,6 @@ export function RequireSession({
|
|
|
114
131
|
window.location.replace(buildSignInReturnHref());
|
|
115
132
|
}, [mustRedirect]);
|
|
116
133
|
|
|
117
|
-
if (bypass) return <>{children}</>;
|
|
118
134
|
// Still resolving, or redirect already in flight: show the loading fallback
|
|
119
135
|
// rather than flashing app chrome the visitor can't use.
|
|
120
136
|
if (isLoading) return <>{fallback ?? <DefaultSpinner />}</>;
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
UseMutationOptions,
|
|
28
28
|
} from "@tanstack/react-query";
|
|
29
29
|
|
|
30
|
+
import { trackEvent } from "./analytics.js";
|
|
30
31
|
import { agentNativePath } from "./api-path.js";
|
|
31
32
|
import { getBrowserTabId } from "./browser-tab-id.js";
|
|
32
33
|
import { ensureEmbedAuthFetchInterceptor } from "./embed-auth.js";
|
|
@@ -221,6 +222,10 @@ export interface ActionFetchOptions {
|
|
|
221
222
|
includeRequestSource?: boolean;
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
type InternalActionFetchOptions = ActionFetchOptions & {
|
|
226
|
+
onResponse?: (response: Response) => void;
|
|
227
|
+
};
|
|
228
|
+
|
|
224
229
|
/**
|
|
225
230
|
* Conservative per-document keepalive body budget. Browsers commonly enforce
|
|
226
231
|
* an approximately 64 KiB aggregate limit across every in-flight keepalive
|
|
@@ -251,11 +256,11 @@ function utf8ByteLength(value: string): number {
|
|
|
251
256
|
return bytes;
|
|
252
257
|
}
|
|
253
258
|
|
|
254
|
-
async function
|
|
259
|
+
async function performActionFetch<T>(
|
|
255
260
|
name: string,
|
|
256
261
|
method: string,
|
|
257
262
|
params?: Record<string, any>,
|
|
258
|
-
options?:
|
|
263
|
+
options?: InternalActionFetchOptions,
|
|
259
264
|
): Promise<T> {
|
|
260
265
|
ensureEmbedAuthFetchInterceptor();
|
|
261
266
|
let url = `${ACTION_PREFIX}/${name}`;
|
|
@@ -332,6 +337,7 @@ async function actionFetch<T>(
|
|
|
332
337
|
try {
|
|
333
338
|
try {
|
|
334
339
|
res = await fetch(url, init);
|
|
340
|
+
options?.onResponse?.(res);
|
|
335
341
|
} catch (err) {
|
|
336
342
|
if (timedOut) throwTimeout();
|
|
337
343
|
// Caller-initiated cancellation — rethrow untouched so React Query
|
|
@@ -418,6 +424,143 @@ async function actionFetch<T>(
|
|
|
418
424
|
return (data ?? (null as unknown)) as T;
|
|
419
425
|
}
|
|
420
426
|
|
|
427
|
+
function actionTelemetryNow(): number {
|
|
428
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function parseServerTiming(
|
|
432
|
+
response: Response | undefined,
|
|
433
|
+
): Map<string, number> {
|
|
434
|
+
const timings = new Map<string, number>();
|
|
435
|
+
const value = response?.headers.get("server-timing");
|
|
436
|
+
if (!value) return timings;
|
|
437
|
+
|
|
438
|
+
for (const entry of value.split(",")) {
|
|
439
|
+
const [rawName, ...params] = entry.trim().split(";");
|
|
440
|
+
const name = rawName?.trim();
|
|
441
|
+
if (!name) continue;
|
|
442
|
+
const duration = params
|
|
443
|
+
.map((param) => /^dur=(.+)$/i.exec(param.trim())?.[1])
|
|
444
|
+
.find(Boolean);
|
|
445
|
+
const parsed = duration === undefined ? NaN : Number(duration);
|
|
446
|
+
if (Number.isFinite(parsed)) timings.set(name, parsed);
|
|
447
|
+
}
|
|
448
|
+
return timings;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function shouldTrackActionResponse(
|
|
452
|
+
error: unknown,
|
|
453
|
+
durationMs: number,
|
|
454
|
+
response: Response | undefined,
|
|
455
|
+
): boolean {
|
|
456
|
+
if (error || durationMs >= 1_000) return true;
|
|
457
|
+
if (response && response.status >= 400 && response.status < 500) return true;
|
|
458
|
+
if (
|
|
459
|
+
/\bstartup(?:-db)?\s*;/i.test(response?.headers.get("server-timing") ?? "")
|
|
460
|
+
) {
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
const raw = (import.meta.env as Record<string, string | undefined>)
|
|
464
|
+
?.VITE_AGENT_NATIVE_ACTION_TELEMETRY_SAMPLE_RATE;
|
|
465
|
+
const parsed = raw === undefined ? 0.1 : Number(raw);
|
|
466
|
+
const rate = Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : 0.1;
|
|
467
|
+
return Math.random() < rate;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function actionFetch<T>(
|
|
471
|
+
name: string,
|
|
472
|
+
method: string,
|
|
473
|
+
params?: Record<string, any>,
|
|
474
|
+
options?: ActionFetchOptions,
|
|
475
|
+
): Promise<T> {
|
|
476
|
+
const startedAt = actionTelemetryNow();
|
|
477
|
+
let response: Response | undefined;
|
|
478
|
+
let responseAt: number | undefined;
|
|
479
|
+
let error: unknown;
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
return await performActionFetch<T>(name, method, params, {
|
|
483
|
+
...options,
|
|
484
|
+
onResponse: (nextResponse) => {
|
|
485
|
+
response = nextResponse;
|
|
486
|
+
responseAt = actionTelemetryNow();
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
} catch (caught) {
|
|
490
|
+
error = caught;
|
|
491
|
+
throw caught;
|
|
492
|
+
} finally {
|
|
493
|
+
try {
|
|
494
|
+
const completedAt = actionTelemetryNow();
|
|
495
|
+
const durationMs = Math.max(0, completedAt - startedAt);
|
|
496
|
+
if (shouldTrackActionResponse(error, durationMs, response)) {
|
|
497
|
+
const ttfbMs =
|
|
498
|
+
responseAt === undefined
|
|
499
|
+
? undefined
|
|
500
|
+
: Math.max(0, responseAt - startedAt);
|
|
501
|
+
const serverTiming = parseServerTiming(response);
|
|
502
|
+
const serverDurationMs = serverTiming.get("app");
|
|
503
|
+
const errorStatus = Number(
|
|
504
|
+
(error as { status?: unknown } | undefined)?.status,
|
|
505
|
+
);
|
|
506
|
+
const statusCode =
|
|
507
|
+
response?.status ??
|
|
508
|
+
(Number.isFinite(errorStatus) ? errorStatus : undefined);
|
|
509
|
+
const timedOut =
|
|
510
|
+
(error as { timedOut?: unknown } | undefined)?.timedOut === true;
|
|
511
|
+
const cancelled = options?.signal?.aborted === true && !timedOut;
|
|
512
|
+
const contentLength = Number(response?.headers.get("content-length"));
|
|
513
|
+
|
|
514
|
+
trackEvent("action.response", {
|
|
515
|
+
request_id:
|
|
516
|
+
response?.headers.get("x-agent-native-request-id") ?? undefined,
|
|
517
|
+
action: name,
|
|
518
|
+
method,
|
|
519
|
+
status_code: statusCode,
|
|
520
|
+
status_class:
|
|
521
|
+
statusCode === undefined
|
|
522
|
+
? "network"
|
|
523
|
+
: `${Math.floor(statusCode / 100)}xx`,
|
|
524
|
+
success: !error,
|
|
525
|
+
outcome: !error
|
|
526
|
+
? "success"
|
|
527
|
+
: timedOut
|
|
528
|
+
? "timeout"
|
|
529
|
+
: cancelled
|
|
530
|
+
? "cancelled"
|
|
531
|
+
: response
|
|
532
|
+
? "http-error"
|
|
533
|
+
: "network-error",
|
|
534
|
+
duration_ms: Math.round(durationMs),
|
|
535
|
+
ttfb_ms: ttfbMs === undefined ? undefined : Math.round(ttfbMs),
|
|
536
|
+
body_ms:
|
|
537
|
+
ttfbMs === undefined ? undefined : Math.round(durationMs - ttfbMs),
|
|
538
|
+
server_duration_ms:
|
|
539
|
+
serverDurationMs === undefined
|
|
540
|
+
? undefined
|
|
541
|
+
: Math.round(serverDurationMs),
|
|
542
|
+
network_overhead_ms:
|
|
543
|
+
ttfbMs === undefined || serverDurationMs === undefined
|
|
544
|
+
? undefined
|
|
545
|
+
: Math.max(0, Math.round(ttfbMs - serverDurationMs)),
|
|
546
|
+
framework_ready_wait_ms: serverTiming.get("startup"),
|
|
547
|
+
db_operation_wall_ms: serverTiming.get("db"),
|
|
548
|
+
db_connect_total_ms: serverTiming.get("db-connect"),
|
|
549
|
+
db_slowest_operation_ms: serverTiming.get("db-slowest"),
|
|
550
|
+
startup_db_operation_wall_ms: serverTiming.get("startup-db"),
|
|
551
|
+
startup_db_connect_total_ms: serverTiming.get("startup-db-connect"),
|
|
552
|
+
response_bytes:
|
|
553
|
+
Number.isFinite(contentLength) && contentLength >= 0
|
|
554
|
+
? contentLength
|
|
555
|
+
: undefined,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
// Performance telemetry must never change the action result.
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
421
564
|
/**
|
|
422
565
|
* Imperatively call an action from browser/client code.
|
|
423
566
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import type { AuthSession } from "../server/auth.js";
|
|
4
4
|
import { setSentryUser, trackSessionStatus } from "./analytics.js";
|
|
@@ -11,6 +11,65 @@ interface UseSessionResult {
|
|
|
11
11
|
isLoading: boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const SESSION_CACHE_TTL_MS = 30_000;
|
|
15
|
+
const SESSION_RETRY_DELAY_MS = 1_000;
|
|
16
|
+
let cachedSession: AuthSession | null | undefined;
|
|
17
|
+
let cachedSessionAt = 0;
|
|
18
|
+
let sessionRequest: Promise<AuthSession | null | undefined> | undefined;
|
|
19
|
+
let trackedSessionIdentity: string | null | undefined;
|
|
20
|
+
|
|
21
|
+
function hasFreshSessionCache(): boolean {
|
|
22
|
+
return (
|
|
23
|
+
cachedSession !== undefined &&
|
|
24
|
+
Date.now() - cachedSessionAt < SESSION_CACHE_TTL_MS
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function publishSessionIdentity(session: AuthSession | null): void {
|
|
29
|
+
const identity = session?.userId ?? session?.email ?? null;
|
|
30
|
+
if (trackedSessionIdentity !== identity) {
|
|
31
|
+
trackedSessionIdentity = identity;
|
|
32
|
+
if (session) {
|
|
33
|
+
setSentryUser(
|
|
34
|
+
{
|
|
35
|
+
id: session.userId,
|
|
36
|
+
email: session.email,
|
|
37
|
+
username: session.name,
|
|
38
|
+
},
|
|
39
|
+
session.orgId ?? null,
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
setSentryUser(null, null);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
trackSessionStatus(Boolean(session));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fetchSharedSession(): Promise<AuthSession | null | undefined> {
|
|
49
|
+
if (hasFreshSessionCache()) return Promise.resolve(cachedSession ?? null);
|
|
50
|
+
if (sessionRequest) return sessionRequest;
|
|
51
|
+
|
|
52
|
+
sessionRequest = (async () => {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(agentNativePath("/_agent-native/auth/session"));
|
|
55
|
+
if (!res.ok) return undefined;
|
|
56
|
+
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
const session = data.error ? null : (data as AuthSession);
|
|
59
|
+
cachedSession = session;
|
|
60
|
+
cachedSessionAt = Date.now();
|
|
61
|
+
publishSessionIdentity(session);
|
|
62
|
+
return session;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
})().finally(() => {
|
|
67
|
+
sessionRequest = undefined;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return sessionRequest;
|
|
71
|
+
}
|
|
72
|
+
|
|
14
73
|
/**
|
|
15
74
|
* Client-side hook to get the current auth session.
|
|
16
75
|
*
|
|
@@ -21,61 +80,33 @@ interface UseSessionResult {
|
|
|
21
80
|
* Templates should use this instead of building their own auth context.
|
|
22
81
|
*/
|
|
23
82
|
export function useSession(): UseSessionResult {
|
|
24
|
-
const
|
|
25
|
-
const [
|
|
26
|
-
const
|
|
83
|
+
const cached = hasFreshSessionCache() ? (cachedSession ?? null) : null;
|
|
84
|
+
const [session, setSession] = useState<AuthSession | null>(cached);
|
|
85
|
+
const [isLoading, setIsLoading] = useState(!hasFreshSessionCache());
|
|
27
86
|
|
|
28
87
|
useEffect(() => {
|
|
29
88
|
let cancelled = false;
|
|
89
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
30
90
|
|
|
31
|
-
async
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const data = await res.json();
|
|
41
|
-
if (!cancelled) {
|
|
42
|
-
// The endpoint returns { error: "..." } when not authenticated
|
|
43
|
-
if (data.error) {
|
|
44
|
-
setSession(null);
|
|
45
|
-
} else {
|
|
46
|
-
resolved = data as AuthSession;
|
|
47
|
-
setSession(resolved);
|
|
48
|
-
signedIn = true;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} catch {
|
|
52
|
-
if (!cancelled) setSession(null);
|
|
53
|
-
} finally {
|
|
54
|
-
if (!cancelled) {
|
|
55
|
-
setIsLoading(false);
|
|
56
|
-
if (resolved) {
|
|
57
|
-
setSentryUser(
|
|
58
|
-
{
|
|
59
|
-
id: resolved.userId,
|
|
60
|
-
email: resolved.email,
|
|
61
|
-
username: resolved.name,
|
|
62
|
-
},
|
|
63
|
-
resolved.orgId ?? null,
|
|
64
|
-
);
|
|
65
|
-
} else {
|
|
66
|
-
setSentryUser(null, null);
|
|
67
|
-
}
|
|
68
|
-
if (!trackedRef.current) {
|
|
69
|
-
trackedRef.current = true;
|
|
70
|
-
trackSessionStatus(signedIn);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
91
|
+
const resolveSession = async () => {
|
|
92
|
+
const resolved = await fetchSharedSession();
|
|
93
|
+
if (cancelled) return;
|
|
94
|
+
|
|
95
|
+
if (resolved === undefined) {
|
|
96
|
+
retryTimer = setTimeout(() => {
|
|
97
|
+
void resolveSession();
|
|
98
|
+
}, SESSION_RETRY_DELAY_MS);
|
|
99
|
+
return;
|
|
73
100
|
}
|
|
74
|
-
}
|
|
75
101
|
|
|
76
|
-
|
|
102
|
+
setSession(resolved);
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
void resolveSession();
|
|
77
107
|
return () => {
|
|
78
108
|
cancelled = true;
|
|
109
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
79
110
|
};
|
|
80
111
|
}, []);
|
|
81
112
|
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import path from "path";
|
|
12
12
|
|
|
13
|
+
import {
|
|
14
|
+
beginDatabaseOperation,
|
|
15
|
+
recordDatabaseRetry,
|
|
16
|
+
} from "./request-telemetry.js";
|
|
17
|
+
|
|
13
18
|
const recyclingPostgresPools = new WeakSet<object>();
|
|
14
19
|
const loggedNeonPools = new WeakSet<object>();
|
|
15
20
|
|
|
@@ -735,6 +740,7 @@ export async function retryOnConnectionError<T>(
|
|
|
735
740
|
} catch (e) {
|
|
736
741
|
last = e;
|
|
737
742
|
if (!isConnectionError(e) || attempt === maxAttempts - 1) throw e;
|
|
743
|
+
recordDatabaseRetry();
|
|
738
744
|
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
|
|
739
745
|
}
|
|
740
746
|
}
|
|
@@ -790,6 +796,9 @@ export async function withDbTimeout<T>(
|
|
|
790
796
|
): Promise<T> {
|
|
791
797
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
792
798
|
let settled = false;
|
|
799
|
+
const finishTelemetry = beginDatabaseOperation(
|
|
800
|
+
op === "connect" ? "connect" : "query",
|
|
801
|
+
);
|
|
793
802
|
|
|
794
803
|
const runCleanup = async () => {
|
|
795
804
|
if (!onTimeout) return;
|
|
@@ -811,12 +820,14 @@ export async function withDbTimeout<T>(
|
|
|
811
820
|
if (settled) return;
|
|
812
821
|
settled = true;
|
|
813
822
|
if (timer) clearTimeout(timer);
|
|
823
|
+
finishTelemetry("success");
|
|
814
824
|
complete(value);
|
|
815
825
|
};
|
|
816
826
|
const fail = (err: unknown) => {
|
|
817
827
|
if (settled) return;
|
|
818
828
|
settled = true;
|
|
819
829
|
if (timer) clearTimeout(timer);
|
|
830
|
+
finishTelemetry("error");
|
|
820
831
|
reject(err);
|
|
821
832
|
};
|
|
822
833
|
|
|
@@ -825,6 +836,7 @@ export async function withDbTimeout<T>(
|
|
|
825
836
|
settled = true;
|
|
826
837
|
void (async () => {
|
|
827
838
|
await runCleanup();
|
|
839
|
+
finishTelemetry("timeout");
|
|
828
840
|
reject(new DbTimeoutError(op, ms));
|
|
829
841
|
})();
|
|
830
842
|
}, ms);
|
|
@@ -1171,13 +1183,7 @@ async function createDbExecInternal(
|
|
|
1171
1183
|
return retryOnConnectionError<{
|
|
1172
1184
|
rows: unknown[];
|
|
1173
1185
|
rowsAffected: number;
|
|
1174
|
-
}>(() =>
|
|
1175
|
-
withDbTimeout(
|
|
1176
|
-
"http-query",
|
|
1177
|
-
() => queryNeonClient(pool, sql),
|
|
1178
|
-
dbOpTimeoutMs(),
|
|
1179
|
-
),
|
|
1180
|
-
);
|
|
1186
|
+
}>(() => queryNeonClient(pool, sql));
|
|
1181
1187
|
}
|
|
1182
1188
|
const result = await retryOnConnectionError<{
|
|
1183
1189
|
rows: unknown[];
|
|
@@ -84,23 +84,28 @@ export function isSqlRead(sql: string): boolean {
|
|
|
84
84
|
* connection the transaction needs. The pool-level error logger still fires
|
|
85
85
|
* on idle-client drops inside transactions.
|
|
86
86
|
*/
|
|
87
|
-
export function buildResilientNeonPool
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}): typeof pool {
|
|
87
|
+
export function buildResilientNeonPool<
|
|
88
|
+
T extends {
|
|
89
|
+
connect(): Promise<any>;
|
|
90
|
+
query(...args: any[]): Promise<any>;
|
|
91
|
+
end(): Promise<void>;
|
|
92
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
93
|
+
},
|
|
94
|
+
>(pool: T): T {
|
|
96
95
|
// Preserve all original pool methods and properties; only override `connect`
|
|
97
96
|
// and `query` at the Pool level (used by drizzle's neon-serverless adapter
|
|
98
97
|
// when it calls pool.query() directly, e.g. outside a transaction).
|
|
99
98
|
const resilientQuery = async (
|
|
100
|
-
sql: string,
|
|
99
|
+
sql: string | { text?: unknown },
|
|
101
100
|
args?: any[],
|
|
102
101
|
): Promise<{ rows: unknown[]; rowCount?: number }> => {
|
|
103
|
-
const
|
|
102
|
+
const sqlText =
|
|
103
|
+
typeof sql === "string"
|
|
104
|
+
? sql
|
|
105
|
+
: typeof sql?.text === "string"
|
|
106
|
+
? sql.text
|
|
107
|
+
: "";
|
|
108
|
+
const isRead = isSqlRead(sqlText);
|
|
104
109
|
|
|
105
110
|
const runAttempt = async (): Promise<{
|
|
106
111
|
rows: unknown[];
|
|
@@ -135,7 +140,9 @@ export function buildResilientNeonPool(pool: {
|
|
|
135
140
|
const result = await withDbTimeout(
|
|
136
141
|
"query",
|
|
137
142
|
() =>
|
|
138
|
-
|
|
143
|
+
(args === undefined
|
|
144
|
+
? client.query(sql)
|
|
145
|
+
: client.query(sql, args)) as Promise<{
|
|
139
146
|
rows: unknown[];
|
|
140
147
|
rowCount?: number;
|
|
141
148
|
}>,
|
|
@@ -180,7 +187,7 @@ export function buildResilientNeonPool(pool: {
|
|
|
180
187
|
const val = (target as any)[prop];
|
|
181
188
|
return typeof val === "function" ? val.bind(target) : val;
|
|
182
189
|
},
|
|
183
|
-
});
|
|
190
|
+
}) as T;
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
/**
|