nuxt-convex-module 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/devtools-client/index.html +1 -1
  2. package/dist/module.d.mts +18 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +120 -36
  5. package/dist/runtime/better-auth/nuxt/middleware.js +8 -2
  6. package/dist/runtime/better-auth/nuxt/no-session-vary.d.ts +38 -0
  7. package/dist/runtime/better-auth/nuxt/no-session-vary.js +21 -0
  8. package/dist/runtime/better-auth/nuxt/server.js +2 -1
  9. package/dist/runtime/better-auth/nuxt/shared-render.d.ts +9 -0
  10. package/dist/runtime/better-auth/nuxt/shared-render.js +17 -0
  11. package/dist/runtime/better-auth/vue/auth-client-loader.d.ts +17 -0
  12. package/dist/runtime/better-auth/vue/auth-client-loader.js +186 -0
  13. package/dist/runtime/better-auth/vue/cross-domain.d.ts +2 -2
  14. package/dist/runtime/better-auth/vue/cross-domain.js +52 -35
  15. package/dist/runtime/better-auth/vue/index.d.ts +1 -0
  16. package/dist/runtime/better-auth/vue/index.js +1 -0
  17. package/dist/runtime/better-auth/vue/plugin.client.js +21 -2
  18. package/dist/runtime/better-auth/vue/plugin.server.js +23 -3
  19. package/dist/runtime/better-auth/vue/token-store.js +4 -0
  20. package/dist/runtime/better-auth/vue/use-better-auth.d.ts +7 -1
  21. package/dist/runtime/better-auth/vue/use-better-auth.js +78 -6
  22. package/dist/runtime/nuxt/function-name.d.ts +9 -0
  23. package/dist/runtime/nuxt/function-name.js +38 -0
  24. package/dist/runtime/nuxt/index.js +4 -3
  25. package/dist/runtime/nuxt/testing.d.ts +21 -0
  26. package/dist/runtime/nuxt/testing.js +1 -0
  27. package/dist/runtime/vue/composables/use-upload-queue.d.ts +70 -3
  28. package/dist/runtime/vue/composables/use-upload-queue.js +74 -19
  29. package/dist/runtime/vue/index.d.ts +1 -1
  30. package/package.json +8 -1
@@ -1,4 +1,4 @@
1
- import { authClient } from "#convex/auth-client";
1
+ import { loadAuthClient, onAuthClient } from "./auth-client-loader.js";
2
2
  const normalizePathname = (path) => {
3
3
  const slashed = path.startsWith("/") ? path : `/${path}`;
4
4
  let end = slashed.length;
@@ -12,41 +12,58 @@ export async function consumeCrossDomainOneTimeToken(options = {}) {
12
12
  }
13
13
  const url = new URL(window.location.href);
14
14
  const token = url.searchParams.get("ott");
15
- if (token) {
16
- const authClientWithCrossDomain = authClient;
17
- url.searchParams.delete("ott");
18
- window.history.replaceState({}, "", url);
19
- if (!authClientWithCrossDomain.crossDomain) {
20
- return;
21
- }
22
- if (options.callbackRoute && normalizePathname(url.pathname) !== normalizePathname(options.callbackRoute)) {
23
- console.warn(
24
- `[nuxt-convex-module] Ignoring cross-domain one-time token on "${url.pathname}" \u2014 consumption is restricted to "${options.callbackRoute}" (convex.betterAuth.crossDomainCallbackRoute).`
25
- );
26
- return;
27
- }
28
- if (import.meta.dev && !options.callbackRoute) {
29
- console.warn(
30
- `[nuxt-convex-module] Exchanged a cross-domain one-time token on "${url.pathname}" with no \`convex.betterAuth.crossDomainCallbackRoute\` set \u2014 any page can complete sign-in. Restrict it to your callback route.`
31
- );
32
- }
33
- try {
34
- const result = await authClientWithCrossDomain.crossDomain.oneTimeToken.verify({
35
- token
36
- });
37
- const session = result.data?.session;
38
- if (session) {
39
- await authClient.getSession({
40
- fetchOptions: {
41
- headers: {
42
- Authorization: `Bearer ${session.token}`
43
- }
15
+ if (!token) return;
16
+ url.searchParams.delete("ott");
17
+ window.history.replaceState({}, "", url);
18
+ const authClient = await loadAuthClient().catch(() => loadAuthClient()).catch((error) => {
19
+ console.warn("[nuxt-convex-module] Failed to load the Better Auth client:", error);
20
+ });
21
+ if (!authClient) {
22
+ onAuthClient((client) => void exchangeKeptToken(client, token, url, options));
23
+ return;
24
+ }
25
+ await exchangeOneTimeToken(authClient, token, url, options);
26
+ }
27
+ async function exchangeKeptToken(authClient, token, url, options) {
28
+ const current = await Promise.resolve().then(() => authClient.getSession()).catch(() => void 0);
29
+ if (!current || current.error || current.data?.session) {
30
+ if (!current?.data?.session) console.warn("[nuxt-convex-module] Failed to consume cross-domain one-time token:", current?.error ?? "the session lookup failed");
31
+ return;
32
+ }
33
+ await exchangeOneTimeToken(authClient, token, url, options);
34
+ }
35
+ async function exchangeOneTimeToken(authClient, token, url, options) {
36
+ const authClientWithCrossDomain = authClient;
37
+ if (!authClientWithCrossDomain?.crossDomain) {
38
+ return;
39
+ }
40
+ if (options.callbackRoute && normalizePathname(url.pathname) !== normalizePathname(options.callbackRoute)) {
41
+ console.warn(
42
+ `[nuxt-convex-module] Ignoring cross-domain one-time token on "${url.pathname}" \u2014 consumption is restricted to "${options.callbackRoute}" (convex.betterAuth.crossDomainCallbackRoute).`
43
+ );
44
+ return;
45
+ }
46
+ if (import.meta.dev && !options.callbackRoute) {
47
+ console.warn(
48
+ `[nuxt-convex-module] Exchanged a cross-domain one-time token on "${url.pathname}" with no \`convex.betterAuth.crossDomainCallbackRoute\` set \u2014 any page can complete sign-in. Restrict it to your callback route.`
49
+ );
50
+ }
51
+ try {
52
+ const result = await authClientWithCrossDomain.crossDomain.oneTimeToken.verify({
53
+ token
54
+ });
55
+ const session = result.data?.session;
56
+ if (session) {
57
+ await authClientWithCrossDomain.getSession({
58
+ fetchOptions: {
59
+ headers: {
60
+ Authorization: `Bearer ${session.token}`
44
61
  }
45
- });
46
- await authClientWithCrossDomain.updateSession();
47
- }
48
- } catch (error) {
49
- console.warn("[nuxt-convex-module] Failed to consume cross-domain one-time token:", error);
62
+ }
63
+ });
64
+ await authClientWithCrossDomain.updateSession();
50
65
  }
66
+ } catch (error) {
67
+ console.warn("[nuxt-convex-module] Failed to consume cross-domain one-time token:", error);
51
68
  }
52
69
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  export { useBetterAuth, type UseBetterAuthReturn, type BetterAuthSession, type BetterAuthUser } from './use-better-auth.js';
8
8
  export { authClient, type AuthClient } from '#convex/auth-client';
9
+ export { loadAuthClient } from './auth-client-loader.js';
9
10
  export { usePreloadedAuthQuery } from './hydration.js';
10
11
  export { consumeCrossDomainOneTimeToken, type AuthClientWithCrossDomain, type ConsumeCrossDomainOneTimeTokenOptions } from './cross-domain.js';
11
12
  export { resolveAuthRedirect } from './redirect.js';
@@ -1,5 +1,6 @@
1
1
  export { useBetterAuth } from "./use-better-auth.js";
2
2
  export { authClient } from "#convex/auth-client";
3
+ export { loadAuthClient } from "./auth-client-loader.js";
3
4
  export { usePreloadedAuthQuery } from "./hydration.js";
4
5
  export { consumeCrossDomainOneTimeToken } from "./cross-domain.js";
5
6
  export { resolveAuthRedirect } from "./redirect.js";
@@ -2,18 +2,37 @@ import { defineNuxtPlugin, useRuntimeConfig, useState } from "#app";
2
2
  import { ConvexVueClient, ConvexClientKey } from "../../vue/client.js";
3
3
  import { ConvexAuthStateKey, createScopedConvexAuthState } from "../../vue/auth/index.js";
4
4
  import { CONVEX_INITIAL_TOKEN_KEY } from "../../nuxt/config.js";
5
- import { useBetterAuth } from "./use-better-auth.js";
5
+ import { settleSessionWithoutClient, useBetterAuth } from "./use-better-auth.js";
6
+ import { deferAuthClient } from "./auth-client-loader.js";
7
+ import { NO_SESSION_KEY } from "./token-store.js";
6
8
  import { consumeCrossDomainOneTimeToken } from "./cross-domain.js";
9
+ function holdsCrossDomainSession() {
10
+ return [() => localStorage, () => sessionStorage].some((open) => {
11
+ try {
12
+ const storage = open();
13
+ for (let index = 0; index < storage.length; index++) {
14
+ const key = storage.key(index);
15
+ if (key?.endsWith("_cookie") && storage.getItem(key)?.includes("session_token")) return true;
16
+ }
17
+ } catch {
18
+ }
19
+ return false;
20
+ });
21
+ }
7
22
  export default defineNuxtPlugin({
8
23
  name: "nuxt-convex-module:better-auth:client",
9
24
  async setup(nuxtApp) {
10
- const { url, crossDomainCallbackRoute } = useRuntimeConfig().public.convex;
25
+ const { url, crossDomainCallbackRoute, deferAuthClient: mayDefer } = useRuntimeConfig().public.convex;
11
26
  if (!url) {
12
27
  console.warn("[nuxt-convex-module] No Convex deployment URL configured \u2014 client and auth not created. Set NUXT_PUBLIC_CONVEX_URL or `convex.url` in nuxt.config.");
13
28
  return;
14
29
  }
15
30
  const client = new ConvexVueClient(url);
16
31
  nuxtApp.vueApp.provide(ConvexClientKey, client);
32
+ if (mayDefer !== false && useState(NO_SESSION_KEY, () => false).value && !new URL(window.location.href).searchParams.has("ott") && !holdsCrossDomainSession()) {
33
+ deferAuthClient();
34
+ nuxtApp.hook("app:suspense:resolve", settleSessionWithoutClient);
35
+ }
17
36
  await consumeCrossDomainOneTimeToken({
18
37
  callbackRoute: crossDomainCallbackRoute || void 0
19
38
  });
@@ -1,10 +1,11 @@
1
1
  import { computed } from "vue";
2
- import { defineNuxtPlugin, useRuntimeConfig, useState, useRequestEvent, useResponseHeader } from "#app";
2
+ import { defineNuxtPlugin, useRuntimeConfig, useState, useRequestEvent, useRequestHeaders, useResponseHeader } from "#app";
3
3
  import { ConvexVueClient, ConvexClientKey } from "../../vue/client.js";
4
4
  import { CONVEX_INITIAL_TOKEN_KEY, closeAfterRender } from "../../nuxt/config.js";
5
5
  import { convexAuth } from "../nuxt/server.js";
6
6
  import { ConvexAuthStateKey } from "../../vue/auth/index.js";
7
- import { BetterAuthTokenStoreKey, createBetterAuthTokenStore, seedInitialToken } from "./token-store.js";
7
+ import { CACHING_HEADERS, isSharedRender, NO_SESSION_CONTEXT_KEY } from "../nuxt/shared-render.js";
8
+ import { BetterAuthTokenStoreKey, createBetterAuthTokenStore, hasSessionCookie, NO_SESSION_KEY, seedInitialToken } from "./token-store.js";
8
9
  export async function prefetchAuthToken(event, initialToken, cacheControl) {
9
10
  try {
10
11
  const token = await convexAuth(event).getToken();
@@ -12,10 +13,20 @@ export async function prefetchAuthToken(event, initialToken, cacheControl) {
12
13
  if (token && event) {
13
14
  cacheControl.value = "private, no-store";
14
15
  }
16
+ return true;
15
17
  } catch (error) {
16
18
  console.warn("[nuxt-convex-module] Failed to prefetch auth token for SSR:", error);
19
+ return false;
17
20
  }
18
21
  }
22
+ function renderContext(event) {
23
+ const context = event.context;
24
+ return {
25
+ routeRules: context._nitro?.routeRules,
26
+ cache: context.cache,
27
+ headers: Object.fromEntries(CACHING_HEADERS.map((name) => [name, event.node?.res?.getHeader?.(name)]))
28
+ };
29
+ }
19
30
  export default defineNuxtPlugin({
20
31
  name: "nuxt-convex-module:better-auth:server",
21
32
  async setup(nuxtApp) {
@@ -36,8 +47,17 @@ export default defineNuxtPlugin({
36
47
  nuxtApp.vueApp.provide(ConvexAuthStateKey, ssrAuthState);
37
48
  const event = useRequestEvent();
38
49
  if (event && !import.meta.prerender) {
39
- await prefetchAuthToken(event, initialToken, useResponseHeader("Cache-Control"));
50
+ const cacheControl = useResponseHeader("Cache-Control");
51
+ const noSession = useState(NO_SESSION_KEY, () => false);
52
+ const { cookie } = useRequestHeaders(["cookie"]);
53
+ const answered = await prefetchAuthToken(event, initialToken, cacheControl);
40
54
  seedInitialToken(tokenStore, initialToken.value);
55
+ noSession.value = answered && !initialToken.value && !hasSessionCookie(cookie) && !isSharedRender(renderContext(event));
56
+ nuxtApp.hooks.hook("app:rendered", () => {
57
+ if (noSession.value && isSharedRender(renderContext(event))) noSession.value = false;
58
+ const context = event.context;
59
+ context[NO_SESSION_CONTEXT_KEY] = noSession.value;
60
+ });
41
61
  }
42
62
  }
43
63
  });
@@ -10,6 +10,10 @@ export function createBetterAuthTokenStore() {
10
10
  };
11
11
  }
12
12
  export const BetterAuthTokenStoreKey = Symbol("BetterAuthTokenStore");
13
+ export const NO_SESSION_KEY = "convex:betterAuth:noSession";
14
+ export function hasSessionCookie(cookie) {
15
+ return !!cookie && cookie.split(";").some((pair) => pair.split("=", 1)[0].trim().endsWith("session_token"));
16
+ }
13
17
  export const PENDING_AUTH_VERSION = "__pending__";
14
18
  export function readTokenClaims(token) {
15
19
  try {
@@ -1,6 +1,6 @@
1
1
  import type { AuthTokenFetcher } from 'convex/browser';
2
2
  import { type ComputedRef } from 'vue';
3
- import { type AuthClient } from '#convex/auth-client';
3
+ import type { AuthClient } from '#convex/auth-client';
4
4
  export type BetterAuthSession = AuthClient['useSession'] extends {
5
5
  (): infer R;
6
6
  (useFetch: any): any;
@@ -49,3 +49,9 @@ export interface UseBetterAuthReturn {
49
49
  * @public
50
50
  */
51
51
  export declare function useBetterAuth(initialToken?: string | null): UseBetterAuthReturn;
52
+ /**
53
+ * Forget what the Better Auth integration keeps for the app's lifetime: the
54
+ * browser's token cache and its session subscription. Published from
55
+ * `nuxt-convex-module/testing`; call it between tests that sign in.
56
+ */
57
+ export declare function resetBetterAuthState(): void;
@@ -1,5 +1,5 @@
1
1
  import { computed, effectScope, hasInjectionContext, inject, shallowRef, triggerRef, watch } from "vue";
2
- import { authClient } from "#convex/auth-client";
2
+ import { __resetAuthClientLoaderForTests, isAuthClientDeferred, lazyAuthClient, loadAuthClient, loadedAuthClient, onAuthClient, retryAuthClientLoad } from "./auth-client-loader.js";
3
3
  import { BetterAuthTokenStoreKey, clearToken, createBetterAuthTokenStore, PENDING_AUTH_VERSION, readTokenClaims, seedInitialToken } from "./token-store.js";
4
4
  const browserStore = createBetterAuthTokenStore();
5
5
  function useTokenStore() {
@@ -16,23 +16,85 @@ const serverSession = () => shallowRef({
16
16
  });
17
17
  let browserSession;
18
18
  let browserSessionScope;
19
+ let settledWithoutClient = false;
20
+ const clientAnswered = shallowRef(false);
21
+ const pendingSessionState = () => {
22
+ const refetch = async (...args) => {
23
+ await loadAuthClient();
24
+ const next = browserSession.value.refetch;
25
+ if (next === refetch) return;
26
+ return next(...args);
27
+ };
28
+ return { data: null, error: null, isPending: true, isRefetching: false, refetch };
29
+ };
19
30
  const useClientSession = () => {
20
31
  if (import.meta.server) return serverSession();
21
32
  browserSessionScope ??= effectScope(true);
22
- return browserSession ??= browserSessionScope.run(() => {
23
- const session = authClient.useSession();
33
+ const scope = browserSessionScope;
34
+ return browserSession ??= scope.run(() => {
35
+ const session = shallowRef(pendingSessionState());
36
+ onAuthClient((client) => scope.active && scope.run(() => {
37
+ watch(client.useSession(), (state) => {
38
+ if (settledWithoutClient && state.isPending) {
39
+ if (session.value.refetch !== state.refetch) session.value = { ...session.value, refetch: state.refetch };
40
+ return;
41
+ }
42
+ settledWithoutClient = false;
43
+ session.value = state;
44
+ if (!state.isPending) clientAnswered.value = true;
45
+ }, { immediate: true, flush: "sync" });
46
+ }));
47
+ if (!isAuthClientDeferred()) void loadAtStartup();
24
48
  watch(session, ({ data, isPending }) => {
25
49
  if (!data && !isPending) clearToken(browserStore);
26
50
  }, { flush: "sync" });
27
51
  return session;
28
52
  });
29
53
  };
54
+ async function loadAtStartup() {
55
+ try {
56
+ await loadAuthClient().catch(() => loadAuthClient());
57
+ } catch (error) {
58
+ console.warn("[nuxt-convex-module] Failed to load the Better Auth client:", error);
59
+ if (!coveredByServerToken()) settle(error);
60
+ retryAuthClientLoad();
61
+ }
62
+ }
63
+ function coveredByServerToken() {
64
+ return Boolean(browserStore.cachedToken.value) && browserStore.cachedTokenVersion === PENDING_AUTH_VERSION;
65
+ }
66
+ export async function clientSessionSettled() {
67
+ useClientSession();
68
+ try {
69
+ await loadAuthClient().catch(() => loadAuthClient());
70
+ } catch {
71
+ if (!coveredByServerToken()) settle();
72
+ return;
73
+ }
74
+ if (clientAnswered.value) return;
75
+ await new Promise((resolve) => {
76
+ const stop = watch(clientAnswered, (answered) => {
77
+ if (!answered) return;
78
+ stop();
79
+ resolve();
80
+ });
81
+ });
82
+ }
83
+ function settle(error = null) {
84
+ const session = useClientSession();
85
+ if (loadedAuthClient() || !session.value.isPending) return;
86
+ settledWithoutClient = true;
87
+ session.value = { ...session.value, isPending: false, error };
88
+ }
89
+ export function settleSessionWithoutClient() {
90
+ settle();
91
+ }
30
92
  export function useBetterAuth(initialToken) {
31
93
  const store = useTokenStore();
32
94
  seedInitialToken(store, initialToken);
33
95
  const { cachedToken } = store;
34
96
  const session = useClientSession();
35
- const client = authClient;
97
+ const client = loadedAuthClient() ?? lazyAuthClient;
36
98
  const authVersion = computed(() => {
37
99
  const data = session.value.data;
38
100
  const id = data?.session?.id ?? data?.user?.id ?? null;
@@ -47,11 +109,17 @@ export function useBetterAuth(initialToken) {
47
109
  return !!data.user?.id && subject !== void 0 && subject !== data.user.id;
48
110
  });
49
111
  const isServerTokenActive = () => session.value.isPending && cachedToken.value !== null && store.cachedTokenVersion === PENDING_AUTH_VERSION;
112
+ const signedInSince = async (forceRefreshToken) => {
113
+ if (!forceRefreshToken || import.meta.server || clientAnswered.value) return false;
114
+ await clientSessionSettled();
115
+ return currentAuthVersion() !== null;
116
+ };
50
117
  const fetchAccessToken = async ({
51
118
  forceRefreshToken = false
52
119
  } = {}) => {
53
120
  const version = currentAuthVersion();
54
121
  if (version === null) {
122
+ if (await signedInSince(forceRefreshToken)) return fetchAccessToken({ forceRefreshToken });
55
123
  clearToken(store);
56
124
  return null;
57
125
  }
@@ -78,7 +146,7 @@ export function useBetterAuth(initialToken) {
78
146
  };
79
147
  store.pendingTokenVersion = version;
80
148
  const isLatest = () => store.pendingToken === pending;
81
- const pending = store.pendingToken = client.convex.token({ fetchOptions: { throw: false } }).then(({ data, error }) => {
149
+ const pending = store.pendingToken = loadAuthClient().then((client2) => client2.convex.token({ fetchOptions: { throw: false } })).then(({ data, error }) => {
82
150
  if (error && error.status !== 401) {
83
151
  return afterFailure();
84
152
  }
@@ -117,10 +185,14 @@ export function useBetterAuth(initialToken) {
117
185
  authVersion
118
186
  };
119
187
  }
120
- export function __resetUseBetterAuthForTests() {
188
+ export function resetBetterAuthState() {
121
189
  Object.assign(browserStore, createBetterAuthTokenStore(), { cachedToken: browserStore.cachedToken });
122
190
  browserStore.cachedToken.value = null;
123
191
  browserSessionScope?.stop();
124
192
  browserSessionScope = void 0;
125
193
  browserSession = void 0;
194
+ settledWithoutClient = false;
195
+ clientAnswered.value = false;
196
+ __resetAuthClientLoaderForTests();
126
197
  }
198
+ export const __resetUseBetterAuthForTests = resetBetterAuthState;
@@ -0,0 +1,9 @@
1
+ import { getFunctionName } from 'convex/server';
2
+ declare const SOURCE_PREFIX: {
3
+ readonly query: "Q";
4
+ readonly mutation: "M";
5
+ readonly action: "A";
6
+ };
7
+ /** `call`, with the function's name put in front of the message of the error it rejects with. */
8
+ export declare function withFunctionName<T>(source: keyof typeof SOURCE_PREFIX, reference: Parameters<typeof getFunctionName>[0], call: Promise<T>): Promise<T>;
9
+ export {};
@@ -0,0 +1,38 @@
1
+ import { getFunctionName } from "convex/server";
2
+ const SOURCE_PREFIX = { query: "Q", mutation: "M", action: "A" };
3
+ const unnamed = /* @__PURE__ */ new WeakMap();
4
+ export async function withFunctionName(source, reference, call) {
5
+ try {
6
+ return await call;
7
+ } catch (error) {
8
+ if (error instanceof Error && !error.message.startsWith("[CONVEX ")) name(error, `[CONVEX ${SOURCE_PREFIX[source]}(${getFunctionName(reference)})] `);
9
+ throw error;
10
+ }
11
+ }
12
+ function name(error, prefix) {
13
+ const original = error.message;
14
+ const stack = error.stack;
15
+ try {
16
+ error.message = `${prefix}${original}`;
17
+ } catch {
18
+ return;
19
+ }
20
+ if (error.message !== `${prefix}${original}`) return;
21
+ unnamed.set(error, { message: original, stack });
22
+ const header = `${error.name}: ${original}`;
23
+ if (original && stack?.startsWith(header)) error.stack = `${error.name}: ${error.message}${stack.slice(header.length)}`;
24
+ }
25
+ export function withoutFunctionName(error, read) {
26
+ const before = error instanceof Error ? unnamed.get(error) : void 0;
27
+ if (!before) return read(error);
28
+ const named = error;
29
+ const { message, stack } = named;
30
+ named.message = before.message;
31
+ named.stack = before.stack;
32
+ try {
33
+ return read(error);
34
+ } finally {
35
+ named.message = message;
36
+ named.stack = stack;
37
+ }
38
+ }
@@ -2,6 +2,7 @@ import { ConvexHttpClient } from "convex/browser";
2
2
  import { getFunctionName } from "convex/server";
3
3
  import { convexToJson, jsonToConvex } from "convex/values";
4
4
  import { getConvexRuntimeConfig } from "./config.js";
5
+ import { withFunctionName } from "./function-name.js";
5
6
  export async function preloadQuery(query, ...args) {
6
7
  const value = await fetchQuery(query, ...args);
7
8
  const preloaded = {
@@ -17,17 +18,17 @@ export function preloadedQueryResult(preloaded) {
17
18
  export async function fetchQuery(query, ...args) {
18
19
  const [fnArgs, options] = args;
19
20
  const client = setupClient(options ?? {});
20
- return client.query(query, fnArgs || {});
21
+ return withFunctionName("query", query, client.query(query, fnArgs || {}));
21
22
  }
22
23
  export async function fetchMutation(mutation, ...args) {
23
24
  const [fnArgs, options] = args;
24
25
  const client = setupClient(options ?? {});
25
- return client.mutation(mutation, fnArgs || {});
26
+ return withFunctionName("mutation", mutation, client.mutation(mutation, fnArgs || {}));
26
27
  }
27
28
  export async function fetchAction(action, ...args) {
28
29
  const [fnArgs, options] = args;
29
30
  const client = setupClient(options ?? {});
30
- return client.action(action, fnArgs || {});
31
+ return withFunctionName("action", action, client.action(action, fnArgs || {}));
31
32
  }
32
33
  function setupClient(options) {
33
34
  if ("url" in options && options.url === void 0) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Helpers for an app's own tests, kept off the runtime entries.
3
+ *
4
+ * The module keeps some state for the app's lifetime, as upstream's provider
5
+ * keeps it for the provider's: a test that signs in leaves it behind for the
6
+ * next. Reset it here instead of reaching into the module's files by path.
7
+ *
8
+ * Load it where the app's runtime loads, in `@nuxt/test-utils`' `nuxt`
9
+ * environment: it imports `#convex/auth-client`, the alias the module (or
10
+ * your test config) registers.
11
+ *
12
+ * ```ts
13
+ * import { afterEach } from 'vitest'
14
+ * import { resetBetterAuthState } from 'nuxt-convex-module/testing'
15
+ *
16
+ * afterEach(() => resetBetterAuthState())
17
+ * ```
18
+ *
19
+ * @module testing
20
+ */
21
+ export { resetBetterAuthState } from '../better-auth/vue/use-better-auth.js';
@@ -0,0 +1 @@
1
+ export { resetBetterAuthState } from "../better-auth/vue/use-better-auth.js";
@@ -6,6 +6,13 @@ import { type GenerateUploadUrl, type StorageId } from './use-upload.js';
6
6
  * @public
7
7
  */
8
8
  export type UploadItemStatus = 'pending' | 'uploading' | 'success' | 'error';
9
+ /**
10
+ * Which step an `uploading` item is on: its `prepare` hook, the transfer, or
11
+ * its `after` hook. `null` outside `uploading`.
12
+ *
13
+ * @public
14
+ */
15
+ export type UploadItemStage = 'prepare' | 'upload' | 'after';
9
16
  /**
10
17
  * A single entry in the queue managed by {@link useUploadQueue}.
11
18
  *
@@ -20,10 +27,27 @@ export interface UploadQueueItem {
20
27
  status: UploadItemStatus;
21
28
  /** Upload progress for this item, from `0` to `1`. */
22
29
  progress: number;
23
- /** The storage id once the upload succeeds, or `null`. */
30
+ /**
31
+ * The storage id once the file is stored, or `null`. An item that failed
32
+ * after that keeps it (see `after` and `onItemSuccess`).
33
+ */
24
34
  storageId: StorageId | null;
25
35
  /** The error if this item failed, or `null`. */
26
36
  error: Error | null;
37
+ /**
38
+ * Whether the error is a refusal rather than a failure: the app said no
39
+ * (`isRefusal`; by default a `ConvexError`), so the same file would be
40
+ * turned away again. `false` while the item has no error.
41
+ */
42
+ refused: boolean;
43
+ /** The step an `uploading` item is on, or `null`. */
44
+ stage: UploadItemStage | null;
45
+ /**
46
+ * What `prepare` returned, which is what gets sent; `null` until it runs,
47
+ * or without a `prepare` hook (the file itself is sent). Kept for a retry
48
+ * and for a preview.
49
+ */
50
+ prepared: Blob | null;
27
51
  }
28
52
  /**
29
53
  * Options for {@link useUploadQueue}.
@@ -33,9 +57,39 @@ export interface UploadQueueItem {
33
57
  export interface UseUploadQueueOptions {
34
58
  /** Maximum number of uploads to run at once. Defaults to `3`. */
35
59
  concurrency?: number;
36
- /** Called when an individual item finishes uploading. */
60
+ /**
61
+ * Turn a queued file into what is sent, before an upload URL is asked
62
+ * for: resize, re-encode, strip metadata. Throw to turn the file away; the
63
+ * item ends `error`, refused when `isRefusal` says so. Runs once per
64
+ * item: a retry reuses what it returned.
65
+ */
66
+ prepare?: (file: Blob, item: UploadQueueItem, signal: AbortSignal) => Blob | Promise<Blob>;
67
+ /**
68
+ * Runs once the file is stored, before the item counts as a success:
69
+ * attach the storage id to a document, run a check on it. The item stays
70
+ * `uploading` (stage `'after'`) until it settles. A throw ends the item
71
+ * `error`; a retry then runs only this hook again, with the same storage
72
+ * id, unless the throw was a refusal, which sends the file again. After a
73
+ * refusal `item.storageId` still holds the stored file while the item is
74
+ * in error, so `onItemError` can delete it; the queue never deletes files,
75
+ * and a retry forgets it before uploading again.
76
+ */
77
+ after?: (storageId: StorageId, item: UploadQueueItem, signal: AbortSignal) => void | Promise<void>;
78
+ /**
79
+ * Whether an error is a refusal (the app turned the file away) rather than
80
+ * a failure (the network, the deployment, an abort). Defaults to: the
81
+ * error is a `ConvexError`, which a Convex function throws on purpose.
82
+ * An aborted item is never refused, and neither is an error this check
83
+ * throws on.
84
+ */
85
+ isRefusal?: (error: Error, item: UploadQueueItem) => boolean;
86
+ /**
87
+ * Called when an individual item finishes uploading (after `after`). A
88
+ * throw ends the item `error` with its storage id kept; a retry then runs
89
+ * only this callback again, never `after` a second time.
90
+ */
37
91
  onItemSuccess?: (storageId: StorageId, item: UploadQueueItem) => void | Promise<void>;
38
- /** Called when an individual item fails. */
92
+ /** Called when an individual item fails or is refused (`item.refused` says which). */
39
93
  onItemError?: (error: Error, item: UploadQueueItem) => void;
40
94
  /** Called once every queued item has settled (succeeded or failed). */
41
95
  onComplete?: (items: UploadQueueItem[]) => void;
@@ -66,6 +120,14 @@ export interface VueUploadQueue {
66
120
  remove: (id: string) => void;
67
121
  /** Abort everything and empty the queue. */
68
122
  clear: () => void;
123
+ /**
124
+ * Queue an errored item again, or every errored item without an id. It
125
+ * picks up where it stopped: `onItemSuccess` alone when `after` already
126
+ * finished, `after` alone when the file is stored, the transfer when
127
+ * `prepare` already ran. Whether a refused item is worth
128
+ * retrying is the app's call; nothing here stops it.
129
+ */
130
+ retry: (id?: string) => void;
69
131
  }
70
132
  /**
71
133
  * Upload many files to Convex file storage with bounded concurrency and
@@ -76,6 +138,11 @@ export interface VueUploadQueue {
76
138
  * a multi-file `<input>`) and the queue uploads up to `concurrency` at a time,
77
139
  * exposing per-item and aggregate progress.
78
140
  *
141
+ * Options `prepare` and `after` add the app's own steps on either side of
142
+ * the transfer (shrink a photo first; attach it once stored), and a failed
143
+ * item says whether the app `refused` it or it failed, so the UI can show a
144
+ * reason for one and {@link VueUploadQueue.retry} the other.
145
+ *
79
146
  * @example
80
147
  * ```vue
81
148
  * <script setup lang="ts">