nuxt-convex-module 0.12.3 → 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 (45) hide show
  1. package/dist/devtools-client/_nuxt/C-J9qZjr.js +3 -0
  2. package/dist/devtools-client/_nuxt/CB1p6iYX.js +1 -0
  3. package/dist/devtools-client/_nuxt/{COXexnyN.js → CtC_CmOY.js} +2 -2
  4. package/dist/devtools-client/_nuxt/{entry.DsZySRVW.css → entry.jOWrhpYd.css} +1 -1
  5. package/dist/devtools-client/index.html +1 -1
  6. package/dist/module.d.mts +18 -0
  7. package/dist/module.json +1 -1
  8. package/dist/module.mjs +129 -38
  9. package/dist/runtime/better-auth/nuxt/middleware.js +8 -2
  10. package/dist/runtime/better-auth/nuxt/no-session-vary.d.ts +38 -0
  11. package/dist/runtime/better-auth/nuxt/no-session-vary.js +21 -0
  12. package/dist/runtime/better-auth/nuxt/server.js +4 -1
  13. package/dist/runtime/better-auth/nuxt/shared-render.d.ts +9 -0
  14. package/dist/runtime/better-auth/nuxt/shared-render.js +17 -0
  15. package/dist/runtime/better-auth/vue/auth-client-loader.d.ts +17 -0
  16. package/dist/runtime/better-auth/vue/auth-client-loader.js +186 -0
  17. package/dist/runtime/better-auth/vue/client.d.ts +3 -3
  18. package/dist/runtime/better-auth/vue/cross-domain.d.ts +26 -0
  19. package/dist/runtime/better-auth/vue/cross-domain.js +52 -35
  20. package/dist/runtime/better-auth/vue/index.d.ts +2 -1
  21. package/dist/runtime/better-auth/vue/index.js +1 -0
  22. package/dist/runtime/better-auth/vue/plugin.client.js +21 -2
  23. package/dist/runtime/better-auth/vue/plugin.server.js +23 -3
  24. package/dist/runtime/better-auth/vue/token-store.js +25 -1
  25. package/dist/runtime/better-auth/vue/use-better-auth.d.ts +11 -5
  26. package/dist/runtime/better-auth/vue/use-better-auth.js +135 -32
  27. package/dist/runtime/nuxt/composables/use-async-paginated-query.d.ts +1 -1
  28. package/dist/runtime/nuxt/composables/use-async-paginated-query.js +5 -3
  29. package/dist/runtime/nuxt/composables/use-async-query.d.ts +4 -1
  30. package/dist/runtime/nuxt/composables/use-async-query.js +6 -3
  31. package/dist/runtime/nuxt/csp.d.ts +2 -1
  32. package/dist/runtime/nuxt/csp.js +9 -3
  33. package/dist/runtime/nuxt/function-name.d.ts +9 -0
  34. package/dist/runtime/nuxt/function-name.js +38 -0
  35. package/dist/runtime/nuxt/index.js +4 -3
  36. package/dist/runtime/nuxt/testing.d.ts +21 -0
  37. package/dist/runtime/nuxt/testing.js +1 -0
  38. package/dist/runtime/vue/auth/index.d.ts +2 -1
  39. package/dist/runtime/vue/auth/index.js +62 -13
  40. package/dist/runtime/vue/composables/use-upload-queue.d.ts +70 -3
  41. package/dist/runtime/vue/composables/use-upload-queue.js +74 -19
  42. package/dist/runtime/vue/index.d.ts +1 -1
  43. package/package.json +19 -12
  44. package/dist/devtools-client/_nuxt/AqohFEu1.js +0 -1
  45. package/dist/devtools-client/_nuxt/BPtiIkWa.js +0 -3
@@ -1,6 +1,6 @@
1
- import { computed, effectScope, hasInjectionContext, inject, shallowRef } from "vue";
2
- import { authClient } from "#convex/auth-client";
3
- import { BetterAuthTokenStoreKey, createBetterAuthTokenStore, PENDING_AUTH_VERSION, seedInitialToken } from "./token-store.js";
1
+ import { computed, effectScope, hasInjectionContext, inject, shallowRef, triggerRef, watch } from "vue";
2
+ import { __resetAuthClientLoaderForTests, isAuthClientDeferred, lazyAuthClient, loadAuthClient, loadedAuthClient, onAuthClient, retryAuthClientLoad } from "./auth-client-loader.js";
3
+ import { BetterAuthTokenStoreKey, clearToken, createBetterAuthTokenStore, PENDING_AUTH_VERSION, readTokenClaims, seedInitialToken } from "./token-store.js";
4
4
  const browserStore = createBetterAuthTokenStore();
5
5
  function useTokenStore() {
6
6
  if (!import.meta.server) return browserStore;
@@ -16,64 +16,163 @@ 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(() => 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();
48
+ watch(session, ({ data, isPending }) => {
49
+ if (!data && !isPending) clearToken(browserStore);
50
+ }, { flush: "sync" });
51
+ return session;
52
+ });
23
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
+ }
24
92
  export function useBetterAuth(initialToken) {
25
93
  const store = useTokenStore();
26
94
  seedInitialToken(store, initialToken);
27
95
  const { cachedToken } = store;
28
96
  const session = useClientSession();
29
- const client = authClient;
97
+ const client = loadedAuthClient() ?? lazyAuthClient;
30
98
  const authVersion = computed(() => {
31
99
  const data = session.value.data;
32
- return data?.session?.id ?? data?.user?.id ?? null;
100
+ const id = data?.session?.id ?? data?.user?.id ?? null;
101
+ return id === null && session.value.isPending ? void 0 : id;
33
102
  });
34
- const currentAuthVersion = () => authVersion.value ?? (session.value.isPending ? PENDING_AUTH_VERSION : null);
103
+ const currentAuthVersion = () => authVersion.value === void 0 ? PENDING_AUTH_VERSION : authVersion.value;
104
+ const holdsAnotherSessionsToken = computed(() => {
105
+ const data = session.value.data;
106
+ if (!data || !cachedToken.value || store.cachedTokenVersion !== PENDING_AUTH_VERSION) return false;
107
+ if (store.initialTokenSessionId && data.session?.id) return store.initialTokenSessionId !== data.session.id;
108
+ const subject = readTokenClaims(cachedToken.value)?.sub;
109
+ return !!data.user?.id && subject !== void 0 && subject !== data.user.id;
110
+ });
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
+ };
35
117
  const fetchAccessToken = async ({
36
118
  forceRefreshToken = false
37
119
  } = {}) => {
38
120
  const version = currentAuthVersion();
39
121
  if (version === null) {
40
- cachedToken.value = null;
41
- store.cachedTokenVersion = null;
42
- store.pendingToken = null;
43
- store.pendingTokenVersion = null;
122
+ if (await signedInSince(forceRefreshToken)) return fetchAccessToken({ forceRefreshToken });
123
+ clearToken(store);
44
124
  return null;
45
125
  }
46
- if (cachedToken.value && !forceRefreshToken && store.cachedTokenVersion === version) {
47
- return cachedToken.value;
126
+ if (store.cachedTokenVersion === PENDING_AUTH_VERSION && store.initialTokenSessionId === version) {
127
+ store.cachedTokenVersion = version;
48
128
  }
49
- if (!forceRefreshToken && store.pendingToken && store.pendingTokenVersion === version) {
50
- return store.pendingToken;
129
+ if (!forceRefreshToken) {
130
+ if (store.pendingToken && store.pendingTokenVersion === version) {
131
+ return store.pendingToken;
132
+ }
133
+ if (cachedToken.value && store.cachedTokenVersion === version) {
134
+ return cachedToken.value;
135
+ }
51
136
  }
137
+ const afterFailure = () => {
138
+ if (cachedToken.value && !holdsAnotherSessionsToken.value && (store.cachedTokenVersion === version || store.cachedTokenVersion === PENDING_AUTH_VERSION)) {
139
+ return cachedToken.value;
140
+ }
141
+ if (isLatest()) {
142
+ cachedToken.value = null;
143
+ store.cachedTokenVersion = version;
144
+ }
145
+ return null;
146
+ };
52
147
  store.pendingTokenVersion = version;
53
- const pending = store.pendingToken = client.convex.token({ fetchOptions: { throw: false } }).then(({ data }) => {
148
+ const isLatest = () => store.pendingToken === pending;
149
+ const pending = store.pendingToken = loadAuthClient().then((client2) => client2.convex.token({ fetchOptions: { throw: false } })).then(({ data, error }) => {
150
+ if (error && error.status !== 401) {
151
+ return afterFailure();
152
+ }
54
153
  const token = data?.token || null;
55
- cachedToken.value = token;
56
- store.cachedTokenVersion = version;
154
+ if (isLatest()) {
155
+ store.cachedTokenVersion = version;
156
+ cachedToken.value = token;
157
+ triggerRef(cachedToken);
158
+ }
57
159
  return token;
58
- }).catch(() => {
59
- cachedToken.value = null;
60
- store.cachedTokenVersion = version;
61
- return null;
62
- }).finally(() => {
63
- store.pendingToken = null;
64
- store.pendingTokenVersion = null;
160
+ }).catch(afterFailure).finally(() => {
161
+ if (isLatest()) {
162
+ store.pendingToken = null;
163
+ store.pendingTokenVersion = null;
164
+ }
65
165
  });
66
166
  return pending;
67
167
  };
68
168
  return {
69
- isLoading: computed(() => session.value.isPending && !cachedToken.value),
169
+ isLoading: computed(() => session.value.isPending && !isServerTokenActive() || holdsAnotherSessionsToken.value),
70
170
  // PARITY: D-03
71
- // Diverges from upstream's `Boolean(session?.session) || cachedToken !== null`:
72
- // a settled signed-out session must read unauthenticated immediately (Vue has
73
- // no re-render to run upstream's cache-clearing effect first), and Better Auth
74
- // session data without a nested `session` still counts as signed in.
171
+ // Diverges from upstream's `Boolean(session?.session) || isSsrTokenActive`:
172
+ // Better Auth session data without a nested `session` still counts as
173
+ // signed in.
75
174
  isAuthenticated: computed(
76
- () => !!session.value.data || session.value.isPending && cachedToken.value !== null
175
+ () => !!session.value.data || isServerTokenActive()
77
176
  ),
78
177
  fetchAccessToken,
79
178
  // PARITY: A-06 — Vue-only service extensions.
@@ -86,10 +185,14 @@ export function useBetterAuth(initialToken) {
86
185
  authVersion
87
186
  };
88
187
  }
89
- export function __resetUseBetterAuthForTests() {
188
+ export function resetBetterAuthState() {
90
189
  Object.assign(browserStore, createBetterAuthTokenStore(), { cachedToken: browserStore.cachedToken });
91
190
  browserStore.cachedToken.value = null;
92
191
  browserSessionScope?.stop();
93
192
  browserSessionScope = void 0;
94
193
  browserSession = void 0;
194
+ settledWithoutClient = false;
195
+ clientAnswered.value = false;
196
+ __resetAuthClientLoaderForTests();
95
197
  }
198
+ export const __resetUseBetterAuthForTests = resetBetterAuthState;
@@ -32,7 +32,7 @@ export interface AsyncPaginatedQueryData<Item> {
32
32
  status: ComputedRef<PaginationStatus>;
33
33
  /** `true` while no page — server or live — is available yet, or more rows are loading. */
34
34
  isLoading: ComputedRef<boolean>;
35
- /** The SSR fetch error or the live subscription's, `null` when none. Never thrown. */
35
+ /** The live subscription's error, or the SSR fetch error until the live query has a page; `null` when none. Never thrown. */
36
36
  error: ComputedRef<Error | null>;
37
37
  /**
38
38
  * Fetch `numItems` more rows. Takes effect once the live subscription has
@@ -70,9 +70,11 @@ export function useAsyncPaginatedQuery(query, args, options) {
70
70
  return "LoadingFirstPage";
71
71
  });
72
72
  const isLoading = computed(() => status.value === "LoadingFirstPage" || status.value === "LoadingMore");
73
- const error = computed(
74
- () => liveState.value?.error ?? clientError.value ?? asyncData.error.value ?? null
75
- );
73
+ const error = computed(() => {
74
+ const liveError = liveState.value?.error ?? clientError.value;
75
+ if (liveError) return liveError;
76
+ return liveHasPage.value ? null : asyncData.error.value ?? null;
77
+ });
76
78
  const loadMore = (numItems) => {
77
79
  live?.loadMore(numItems);
78
80
  };
@@ -66,7 +66,10 @@ export interface AsyncQueryReturn<T> extends PromiseLike<AsyncQueryData<T>> {
66
66
  * nothing has loaded (or while skipped).
67
67
  */
68
68
  data: ComputedRef<T | undefined>;
69
- /** The initial-fetch or live-subscription error, `null` when none. */
69
+ /**
70
+ * The initial-fetch or live-subscription error, `null` when none. A failed
71
+ * initial fetch stops counting once the live subscription has a value.
72
+ */
70
73
  error: ComputedRef<Error | null>;
71
74
  /**
72
75
  * Initial-fetch status. Live pushes don't churn it: once data exists it
@@ -75,9 +75,12 @@ export function useAsyncQuery(query, args, options = {}) {
75
75
  const data = computed(
76
76
  () => liveValue.value !== void 0 ? liveValue.value : payloadValue.value
77
77
  );
78
- const error = computed(
79
- () => liveError.value ?? asyncData.error.value ?? null
80
- );
78
+ const error = computed(() => {
79
+ if (liveError.value) {
80
+ return liveError.value;
81
+ }
82
+ return liveValue.value !== void 0 ? null : asyncData.error.value ?? null;
83
+ });
81
84
  const status = computed(() => {
82
85
  if (liveError.value) {
83
86
  return "error";
@@ -37,7 +37,8 @@ export declare function convexResourceSrc(url?: string, siteUrl?: string): strin
37
37
  *
38
38
  * For each directive we keep any value the user already set and *append* the
39
39
  * Convex origins, so consumers extend (add their own third parties) rather than
40
- * fight the defaults, and the Convex origins are always present. A user who sets
40
+ * fight the defaults, and the Convex origins are always present. That covers a
41
+ * directive the user left to `default-src`, whose places carry over. A user who sets
41
42
  * `contentSecurityPolicy: false` (CSP disabled) is left untouched, as is a rule
42
43
  * set with nothing to add (no Convex URL).
43
44
  */
@@ -39,8 +39,14 @@ export function applyConvexSecurityDefaults(rules, connectSrc, resourceSrc) {
39
39
  tightenDirective(csp, "media-src", ["'self'"], resourceSrc);
40
40
  }
41
41
  function tightenDirective(csp, name, baseline, additions) {
42
- if (additions.length === 0) return;
43
- const current = csp[name];
44
- const existing = Array.isArray(current) ? current : typeof current === "string" ? current.split(/\s+/).filter(Boolean) : baseline;
42
+ if (additions.length === 0 || csp[name] === false) return;
43
+ const existing = sourcesOf(csp[name]) ?? [...baseline, ...inheritedSources(csp)];
45
44
  csp[name] = uniq([...existing, ...additions]);
46
45
  }
46
+ function sourcesOf(value) {
47
+ if (Array.isArray(value)) return value;
48
+ return typeof value === "string" ? value.split(/\s+/).filter(Boolean) : void 0;
49
+ }
50
+ function inheritedSources(csp) {
51
+ return (sourcesOf(csp["default-src"]) ?? []).filter((source) => source === "'self'" || !source.startsWith("'"));
52
+ }
@@ -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";
@@ -66,7 +66,8 @@ export interface ConvexAuthProviderOptions {
66
66
  * Alternative re-authentication trigger for providers that hand out a
67
67
  * stable fetcher function: when this value changes, auth state transitions
68
68
  * back to loading, `clearAuth` runs, and `fetchAccessToken` is called
69
- * again against the new auth context.
69
+ * again against the new auth context. `undefined` means the provider does
70
+ * not know the context yet, so the first value it learns is no change.
70
71
  */
71
72
  authVersion?: MaybeRefOrGetter<unknown>;
72
73
  };
@@ -1,4 +1,4 @@
1
- import { computed, effectScope, inject, provide, ref, toValue, unref, watch, watchEffect } from "vue";
1
+ import { computed, effectScope, inject, nextTick, provide, ref, toValue, unref, watch } from "vue";
2
2
  export const ConvexAuthStateKey = Symbol("ConvexAuthState");
3
3
  export function useConvexAuth() {
4
4
  const authContext = inject(ConvexAuthStateKey);
@@ -9,6 +9,27 @@ export function useConvexAuth() {
9
9
  }
10
10
  return authContext;
11
11
  }
12
+ const pendingClears = /* @__PURE__ */ new WeakMap();
13
+ function clearAuthLast(client, ended) {
14
+ const pending = pendingClears.get(client);
15
+ if (pending) {
16
+ if (pending.start === ended) {
17
+ pending.start = null;
18
+ }
19
+ return;
20
+ }
21
+ const clear = { start: null };
22
+ pendingClears.set(client, clear);
23
+ void nextTick(() => {
24
+ pendingClears.delete(client);
25
+ try {
26
+ client.clearAuth();
27
+ } catch {
28
+ return;
29
+ }
30
+ clear.start?.();
31
+ });
32
+ }
12
33
  export function provideConvexAuth(options) {
13
34
  const authState = createConvexAuthState(options);
14
35
  provide(ConvexAuthStateKey, authState);
@@ -43,16 +64,37 @@ export function createConvexAuthState(options, scope) {
43
64
  },
44
65
  { immediate: true }
45
66
  );
46
- watchEffect((onCleanup) => {
47
- if (import.meta.server) {
48
- return;
67
+ const authContext = ref(0);
68
+ watch(
69
+ () => toValue(authVersion),
70
+ (_version, previous) => {
71
+ if (previous !== void 0) authContext.value++;
49
72
  }
50
- let isThisEffectRelevant = true;
51
- void toValue(authProviderLoading);
52
- void toValue(authVersion);
53
- const fetchToken = unref(fetchAccessToken);
54
- if (toValue(authProviderAuthenticated)) {
55
- client.setAuth(
73
+ );
74
+ watch(
75
+ [
76
+ // The loading flag is here only so auth re-registers when it flips,
77
+ // mirroring upstream's effect deps. It must not gate `setAuth`: a
78
+ // provider may report a cached session (authenticated) while still
79
+ // revalidating (loading), and upstream authenticates the client
80
+ // immediately in that window.
81
+ () => toValue(authProviderLoading),
82
+ authContext,
83
+ // Unwrapping here tracks the ref, so a new fetcher identity
84
+ // re-registers auth: upstream's effect re-running when
85
+ // `fetchAccessToken` changes between renders.
86
+ () => unref(fetchAccessToken),
87
+ () => toValue(authProviderAuthenticated)
88
+ ],
89
+ ([, , fetchToken, authenticated], _previous, onCleanup) => {
90
+ if (import.meta.server) {
91
+ return;
92
+ }
93
+ if (!authenticated) {
94
+ return;
95
+ }
96
+ let isThisEffectRelevant = true;
97
+ const start = () => client.setAuth(
56
98
  fetchToken,
57
99
  (convexReportsIsAuthenticated) => {
58
100
  if (isThisEffectRelevant) {
@@ -67,14 +109,21 @@ export function createConvexAuthState(options, scope) {
67
109
  }
68
110
  }
69
111
  );
112
+ const pending = pendingClears.get(client);
113
+ if (pending) {
114
+ pending.start = start;
115
+ } else {
116
+ start();
117
+ }
70
118
  onCleanup(() => {
71
119
  isThisEffectRelevant = false;
72
- client.clearAuth();
73
120
  isConvexAuthenticated.value = null;
74
121
  isRefreshing.value = false;
122
+ clearAuthLast(client, start);
75
123
  });
76
- }
77
- });
124
+ },
125
+ { immediate: true }
126
+ );
78
127
  };
79
128
  if (scope) {
80
129
  scope.run(register);
@@ -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">