@razakalpha/convngx 0.2.0 β†’ 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # convNGX
1
+ # @razakalpha/convngx
2
2
 
3
3
  Angular-first utilities for Convex with Better Auth:
4
4
  - DI-wrapped Convex client with Better Auth token refresh
@@ -8,13 +8,17 @@ Angular-first utilities for Convex with Better Auth:
8
8
 
9
9
  Demo app: projects/example-chat (Angular + Convex + Better Auth)
10
10
 
11
- ## Install peer deps
11
+ ## Installation
12
12
 
13
- Use your app's package.json; this library provides Angular wrappers and expects Convex + Better Auth to be available.
13
+ Install the package and its peer dependencies:
14
14
 
15
15
  ```bash
16
- npm i convex @convex-dev/better-auth better-auth
16
+ npm install @razakalpha/convngx convex @convex-dev/better-auth better-auth
17
17
  ```
18
+
19
+ ### Peer Dependencies
20
+
21
+ This library provides Angular wrappers and expects Convex + Better Auth to be available in your project.
18
22
  ## Assumptions
19
23
 
20
24
  This library assumes your Convex backend uses Better Auth (via `@convex-dev/better-auth`) and exposes the Better Auth HTTP endpoints on your Convex site (e.g. `https://YOUR.convex.site`). The Angular providers wire the Convex client to Better Auth, handle proactive token refresh, and optionally support cross‑domain OTT handoff.
@@ -25,7 +29,7 @@ Register the provider once in your bootstrap:
25
29
 
26
30
  ```ts
27
31
  import { bootstrapApplication } from '@angular/platform-browser';
28
- import { provideConvexAngular } from 'convngx';
32
+ import { provideConvexAngular } from '@razakalpha/convngx';
29
33
  import { AppComponent } from './app';
30
34
 
31
35
  bootstrapApplication(AppComponent, {
@@ -45,7 +49,7 @@ bootstrapApplication(AppComponent, {
45
49
  Inject the Convex client anywhere:
46
50
 
47
51
  ```ts
48
- import { injectConvex } from 'convngx';
52
+ import { injectConvex } from '@razakalpha/convngx';
49
53
 
50
54
  const convex = injectConvex();
51
55
  // convex.query(...), convex.watchQuery(...), convex.mutation(...), convex.action(...)
@@ -58,7 +62,7 @@ Angular Resource wrapper around Convex watchQuery with smart gating, keep-last,
58
62
  Core usage:
59
63
 
60
64
  ```ts
61
- import { convexLiveResource } from 'convngx';
65
+ import { convexLiveResource } from '@razakalpha/convngx';
62
66
  import { api } from '@/convex/_generated/api';
63
67
 
64
68
  // Live updated with angular resource api
@@ -110,7 +114,7 @@ function convexLiveResource<Q extends FunctionReference<'query'>>(
110
114
 
111
115
  Global default for keep mode (optional DI)
112
116
  ```ts
113
- import { provideConvexResourceOptions } from 'convngx';
117
+ import { provideConvexResourceOptions } from '@razakalpha/convngx';
114
118
 
115
119
  bootstrapApplication(App, {
116
120
  providers: [
@@ -126,7 +130,7 @@ Implementation: src/lib/resources/live.resource.ts
126
130
  A mutation helper that returns an imperative `run()` plus resource-shaped state and derived signals. Supports optimistic updates, callbacks, basic concurrency controls, and retries.
127
131
 
128
132
  ```ts
129
- import { convexMutationResource } from 'convngx';
133
+ import { convexMutationResource } from '@razakalpha/convngx';
130
134
  import { api } from '@/convex/_generated/api';
131
135
 
132
136
  // Minimal
@@ -182,7 +186,7 @@ Implementation: src/lib/resources/mutation.resource.ts
182
186
  Identical ergonomics to mutations but calls `convex.action`. Useful for long-running or external API calls.
183
187
 
184
188
  ```ts
185
- import { convexActionResource } from 'convngx';
189
+ import { convexActionResource } from '@razakalpha/convngx';
186
190
  import { api } from '@/convex/_generated/api';
187
191
 
188
192
  const exportData = convexActionResource(api.reports.export, {
@@ -238,7 +242,7 @@ Example service (from the chat app) that derives a reactive `isAuthenticated`:
238
242
  ```ts
239
243
  // projects/example-chat/src/app/state/convex-auth.state.ts
240
244
  import { Injectable, computed, inject, signal } from '@angular/core';
241
- import { CONVEX, type ConvexAngularClient } from 'convngx';
245
+ import { CONVEX, type ConvexAngularClient } from '@razakalpha/convngx';
242
246
 
243
247
  @Injectable({ providedIn: 'root' })
244
248
  export class ConvexAuthState {
@@ -271,7 +275,7 @@ Snippet from the chat component:
271
275
 
272
276
  ```ts
273
277
  import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
274
- import { convexLiveResource, convexMutationResource } from 'convngx';
278
+ import { convexLiveResource, convexMutationResource } from '@razakalpha/convngx';
275
279
  import { api } from 'convex/_generated/api';
276
280
 
277
281
  @Component({ /* ... */ })
@@ -307,7 +311,7 @@ export class ChatComponent {
307
311
  ## Build
308
312
 
309
313
  ```bash
310
- ng build convngx-angular
314
+ ng build alpha-convngx
311
315
  ```
312
316
 
313
317
  Outputs to dist/convngx.
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/convngx",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,26 +1,26 @@
1
- {
2
- "name": "@razakalpha/convngx",
3
- "version": "0.2.0",
4
- "peerDependencies": {
5
- "@angular/common": "^20.1.0",
6
- "@angular/core": "^20.1.0",
7
- "convex": "^1.25.0",
8
- "@convex-dev/better-auth": "^0.7.0",
9
- "better-auth": "^1.3.0"
10
- },
11
- "dependencies": {
12
- "tslib": "^2.3.0"
13
- },
14
- "sideEffects": false,
15
- "module": "fesm2022/razakalpha-convngx.mjs",
16
- "typings": "index.d.ts",
17
- "exports": {
18
- "./package.json": {
19
- "default": "./package.json"
20
- },
21
- ".": {
22
- "types": "./index.d.ts",
23
- "default": "./fesm2022/razakalpha-convngx.mjs"
24
- }
25
- }
26
- }
1
+ {
2
+ "name": "@razakalpha/convngx",
3
+ "version": "0.2.2",
4
+ "peerDependencies": {
5
+ "@angular/common": "^20.1.0",
6
+ "@angular/core": "^20.1.0",
7
+ "convex": "^1.25.0",
8
+ "@convex-dev/better-auth": "^0.7.0",
9
+ "better-auth": "^1.3.0"
10
+ },
11
+ "dependencies": {
12
+ "tslib": "^2.3.0"
13
+ },
14
+ "sideEffects": false,
15
+ "module": "fesm2022/RazakAlpha-convngx.mjs",
16
+ "typings": "index.d.ts",
17
+ "exports": {
18
+ "./package.json": {
19
+ "default": "./package.json"
20
+ },
21
+ ".": {
22
+ "types": "./index.d.ts",
23
+ "default": "./fesm2022/RazakAlpha-convngx.mjs"
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,35 @@
1
+ import { InjectionToken, Provider } from '@angular/core';
2
+ import { createAuthClient } from 'better-auth/client';
3
+ import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins';
4
+
5
+ type AuthConfig = {
6
+ baseURL: string;
7
+ plugins: [ReturnType<typeof convexClient>, ReturnType<typeof crossDomainClient>];
8
+ fetchOptions: { credentials: 'include' };
9
+ };
10
+
11
+ export type AuthClient = ReturnType<typeof createAuthClient<AuthConfig>>;
12
+
13
+ export interface ProvideAuthClientOptions {
14
+ baseURL: string;
15
+ fetchOptions?: RequestInit;
16
+ }
17
+
18
+ /**
19
+ * Un-typed DI token. We don't re-export types; callers who create the client
20
+ * get full type safety from better-auth directly.
21
+ */
22
+ export const AUTH_CLIENT = new InjectionToken<AuthClient>('AUTH_CLIENT');
23
+
24
+ /** Provide a configured Better Auth client via DI (default convex+crossDomain plugins) */
25
+ export function provideAuthClient(opts: ProvideAuthClientOptions): Provider {
26
+ return {
27
+ provide: AUTH_CLIENT,
28
+ useFactory: () =>
29
+ createAuthClient({
30
+ baseURL: opts.baseURL,
31
+ plugins: [convexClient(), crossDomainClient()],
32
+ fetchOptions: { credentials: 'include', ...(opts.fetchOptions ?? {}) },
33
+ }),
34
+ };
35
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Convex + Better Auth providers.
3
+ * - provideConvexBetterAuth: Registers a ConvexAngularClient in DI and wires Better Auth token fetching.
4
+ * - provideBetterAuthOttBootstrap: Optional environment initializer to handle cross-domain one-time-token.
5
+ *
6
+ * No functional changesβ€”cleaned comments and added docs.
7
+ */
8
+ import { inject, provideEnvironmentInitializer, Provider } from '@angular/core';
9
+ import { AUTH_CLIENT } from './auth-client.provider';
10
+ import { CONVEX } from '../core/inject-convex.token';
11
+ import { ConvexAngularClient, FetchAccessToken } from '../core/convex-angular-client';
12
+
13
+ /** Minimal surface this library relies on from Better Auth client */
14
+ interface AuthClientRequired {
15
+ convex: { token: () => Promise<{ data?: { token?: string } | null }> };
16
+ crossDomain: {
17
+ oneTimeToken: {
18
+ verify: (args: { token: string }) => Promise<{ data?: { session?: { token?: string } } }>;
19
+ };
20
+ };
21
+ getSession: (o?: { fetchOptions?: RequestInit }) => Promise<unknown>;
22
+ updateSession: () => void;
23
+ }
24
+ export interface ConvexBetterAuthOptions {
25
+ /** Convex deployment URL, e.g. https://xxx.convex.cloud */
26
+ convexUrl: string;
27
+ /** Milliseconds before JWT expiry to refresh (default 45s in this provider) */
28
+ authSkewMs?: number;
29
+
30
+ authClient?: AuthClientRequired; // Optional user-provided Better Auth client
31
+ }
32
+
33
+ /**
34
+ * Registers the Convex client in DI and connects it to Better Auth for JWT retrieval.
35
+ * Consumers still need to provide the Better Auth HTTP client via provideAuthClient().
36
+ */
37
+ export function provideConvexBetterAuth(opts: ConvexBetterAuthOptions): Provider[] {
38
+ return [
39
+ {
40
+ provide: CONVEX,
41
+ useFactory: () => {
42
+ const client = new ConvexAngularClient(opts.convexUrl, {
43
+ authSkewMs: opts.authSkewMs ?? 45_000,
44
+ });
45
+
46
+ const auth = inject(AUTH_CLIENT) as AuthClientRequired;
47
+
48
+ const fetchAccessToken: FetchAccessToken = async ({ forceRefreshToken }) => {
49
+ if (!forceRefreshToken) return null;
50
+ const { data } = await auth.convex.token();
51
+ return data?.token ?? null;
52
+ };
53
+
54
+ client.setAuth(fetchAccessToken);
55
+ void client.warmAuth();
56
+ return client;
57
+ },
58
+ },
59
+ ];
60
+ }
61
+
62
+ /**
63
+ * Optional environment initializer to handle OTT (?ott=...) once and upgrade to a cookie session.
64
+ * Keep separate from main provider for explicit opt-in.
65
+ */
66
+ export function provideBetterAuthOttBootstrap() {
67
+ return provideEnvironmentInitializer(() => {
68
+ (async () => {
69
+ const auth = inject(AUTH_CLIENT) as AuthClientRequired;
70
+ const url = new URL(window.location.href);
71
+ const ott = url.searchParams.get('ott');
72
+ if (!ott) return;
73
+
74
+ const result = await auth.crossDomain.oneTimeToken.verify({ token: ott });
75
+ const session = result?.data?.session;
76
+ if (session?.token) {
77
+ await auth.getSession({
78
+ fetchOptions: {
79
+ credentials: 'include',
80
+ headers: { Authorization: `Bearer ${session.token}` },
81
+ },
82
+ });
83
+ auth.updateSession();
84
+ }
85
+ url.searchParams.delete('ott');
86
+ window.history.replaceState({}, '', url.toString());
87
+ })();
88
+ });
89
+ }
@@ -0,0 +1,23 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { ConvexAngular } from './convex-angular';
4
+
5
+ describe('ConvexAngular', () => {
6
+ let component: ConvexAngular;
7
+ let fixture: ComponentFixture<ConvexAngular>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [ConvexAngular]
12
+ })
13
+ .compileComponents();
14
+
15
+ fixture = TestBed.createComponent(ConvexAngular);
16
+ component = fixture.componentInstance;
17
+ fixture.detectChanges();
18
+ });
19
+
20
+ it('should create', () => {
21
+ expect(component).toBeTruthy();
22
+ });
23
+ });
@@ -0,0 +1,15 @@
1
+ import { Component } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'lib-convex-angular',
5
+ imports: [],
6
+ template: `
7
+ <p>
8
+ convNGX works!
9
+ </p>
10
+ `,
11
+ styles: ``
12
+ })
13
+ export class ConvexAngular {
14
+
15
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * ConvexAngularClient
3
+ * - Wraps Convex BaseConvexClient + ConvexHttpClient
4
+ * - Integrates Better Auth via pluggable fetcher (setAuth)
5
+ * - Caches JWT in sessionStorage with BroadcastChannel sync
6
+ * - Auto-refreshes auth token ahead of expiry with jitter
7
+ * - Provides:
8
+ * - watchQuery: live subscription with localQueryResult + onUpdate
9
+ * - query: HTTP one-shot with in-flight de-dupe + 401 retry
10
+ * - mutation: supports optimisticUpdate passthrough
11
+ * - action: HTTP call with 401 retry
12
+ * - Does not change any runtime behavior – documentation and structure only.
13
+ */
14
+ // src/app/convex-angular-client.ts
15
+ import {
16
+ BaseConvexClient,
17
+ type BaseConvexClientOptions,
18
+ type OptimisticUpdate,
19
+ ConvexHttpClient,
20
+ } from 'convex/browser';
21
+ import {
22
+ FunctionReference,
23
+ FunctionArgs,
24
+ FunctionReturnType,
25
+ getFunctionName,
26
+ } from 'convex/server';
27
+ import type { Value } from 'convex/values';
28
+ import type { FetchAccessToken, AuthSnapshot } from './types';
29
+ export type { FetchAccessToken, AuthSnapshot } from './types';
30
+ import { AUTH_KEY, AUTH_CH, jwtExpMs, stableStringify, safeSession } from './helpers';
31
+
32
+ type QueryToken = string;
33
+
34
+ type WatchHandle<Q extends FunctionReference<'query'>> = {
35
+ localQueryResult(): FunctionReturnType<Q> | undefined;
36
+ onUpdate(cb: () => void): () => void;
37
+ unsubscribe(): void;
38
+ };
39
+
40
+ type Entry = {
41
+ name: string;
42
+ args: Record<string, Value>;
43
+ listeners: Set<() => void>;
44
+ unsubscribe: () => void;
45
+ };
46
+
47
+ type StoredToken = { value: string; exp: number };
48
+
49
+ /* helpers moved to ./helpers (no behavior change) */
50
+
51
+
52
+ const bc =
53
+ typeof BroadcastChannel !== 'undefined'
54
+ ? new BroadcastChannel(AUTH_CH)
55
+ : (null as BroadcastChannel | null);
56
+
57
+ export class ConvexAngularClient {
58
+ private authListeners = new Set<(s: AuthSnapshot) => void>();
59
+ private lastSnap?: AuthSnapshot;
60
+ // ==== internals ====
61
+ private emitAuth() {
62
+ const snap = this.getAuthSnapshot();
63
+ // de-dupe emissions
64
+ if (
65
+ this.lastSnap &&
66
+ this.lastSnap.isAuthenticated === snap.isAuthenticated &&
67
+ this.lastSnap.token === snap.token
68
+ ) {
69
+ return;
70
+ }
71
+ this.lastSnap = snap;
72
+ for (const cb of this.authListeners) cb(snap);
73
+ }
74
+
75
+ private base: BaseConvexClient;
76
+ private http: ConvexHttpClient;
77
+ private byToken = new Map<QueryToken, Entry>();
78
+
79
+ private fetchToken?: FetchAccessToken;
80
+ private inflightToken?: Promise<string | null>;
81
+ private token?: StoredToken;
82
+
83
+ private refreshTimer?: number;
84
+ private inflightHttp = new Map<string, Promise<any>>();
85
+ private authLocked = false;
86
+
87
+ private readonly skewMs: number;
88
+
89
+ constructor(url: string, opts?: BaseConvexClientOptions & { authSkewMs?: number }) {
90
+ this.skewMs = opts?.authSkewMs ?? 30_000;
91
+
92
+ this.base = new BaseConvexClient(
93
+ url,
94
+ (updatedTokens: QueryToken[]) => {
95
+ for (const t of updatedTokens) {
96
+ const e = this.byToken.get(t);
97
+ if (!e) continue;
98
+ for (const cb of e.listeners) cb();
99
+ }
100
+ },
101
+ opts,
102
+ );
103
+
104
+ this.http = new ConvexHttpClient(url);
105
+
106
+ // pick up cached token
107
+ const raw = safeSession.get(AUTH_KEY);
108
+ if (raw) {
109
+ try {
110
+ const t: StoredToken = JSON.parse(raw);
111
+ if (t?.value && t?.exp && Date.now() < t.exp - this.skewMs) {
112
+ this.token = t;
113
+ this.http.setAuth(t.value);
114
+ this.scheduleRefresh();
115
+ }
116
+ } catch {}
117
+ }
118
+ // 3) BroadcastChannel: keep as-is (clear only on 'clear', set on 'set')
119
+ bc?.addEventListener('message', (ev: MessageEvent) => {
120
+ const d: any = ev.data;
121
+ if (d?.type === 'set') {
122
+ if (this.authLocked) return; // ignore while logging out
123
+ this.applyToken(d.token?.value ?? null);
124
+ } else if (d?.type === 'clear') {
125
+ this.applyToken(null);
126
+ }
127
+ });
128
+
129
+ // visibility/network pokes
130
+ const poke = () => {
131
+ if (!this.freshTokenInCache()) void this.getToken(true);
132
+ };
133
+ if (typeof window !== 'undefined') {
134
+ window.addEventListener('online', poke);
135
+ document.addEventListener?.('visibilitychange', () => {
136
+ if (document.visibilityState === 'visible') poke();
137
+ });
138
+ }
139
+ }
140
+
141
+ setAuth(fetcher: FetchAccessToken) {
142
+ this.fetchToken = fetcher;
143
+ this.base.setAuth(
144
+ async ({ forceRefreshToken }) => (this.authLocked ? null : this.getToken(forceRefreshToken)),
145
+ () => {
146
+ // identity changed (connect/reconnect). Do NOT clear token here.
147
+ // If we're truly logged out, next HTTP/WS use will 401 and we clear then.
148
+ this.inflightToken = undefined;
149
+ this.emitAuth(); // soft notify, no flip to false
150
+ },
151
+ );
152
+ }
153
+
154
+ /** Optional: call once on app start */
155
+ async warmAuth(): Promise<void> {
156
+ await this.getToken(true);
157
+ }
158
+
159
+ private freshTokenInCache(): string | null {
160
+ if (!this.token) return null;
161
+ return Date.now() < this.token.exp - this.skewMs ? this.token.value : null;
162
+ }
163
+
164
+ // ==== public auth helpers ====
165
+ onAuth(cb: (s: AuthSnapshot) => void): () => void {
166
+ cb(this.getAuthSnapshot());
167
+ this.authListeners.add(cb);
168
+ return () => this.authListeners.delete(cb);
169
+ }
170
+
171
+ logoutLocal(lock = true) {
172
+ if (lock) this.authLocked = true;
173
+ this.inflightToken = undefined;
174
+ this.applyToken(null); // this is the ONLY place we hard clear locally
175
+ }
176
+
177
+ // 5) snapshot uses token presence (not freshness)
178
+ getAuthSnapshot() {
179
+ return {
180
+ isAuthenticated: !!this.token?.value,
181
+ token: this.token?.value ?? null,
182
+ exp: this.token?.exp,
183
+ };
184
+ }
185
+
186
+ /** Allow re-auth then fetch a fresh token (use after successful sign-in) */
187
+ async refreshAuth(): Promise<void> {
188
+ this.authLocked = false;
189
+ await this.getToken(true);
190
+ }
191
+
192
+ // 4) applyToken: always emit after mutation (you already fixed this)
193
+ private applyToken(token: string | null) {
194
+ if (!token) {
195
+ this.token = undefined;
196
+ this.http.clearAuth();
197
+ safeSession.del(AUTH_KEY);
198
+ bc?.postMessage({ type: 'clear' });
199
+ if (this.refreshTimer) window.clearTimeout(this.refreshTimer);
200
+ } else {
201
+ if (this.authLocked) {
202
+ this.emitAuth();
203
+ return;
204
+ }
205
+ const exp = jwtExpMs(token);
206
+ this.token = { value: token, exp };
207
+ this.http.setAuth(token);
208
+ safeSession.set(AUTH_KEY, JSON.stringify(this.token));
209
+ bc?.postMessage({ type: 'set', token: this.token });
210
+ this.scheduleRefresh();
211
+ }
212
+ this.emitAuth();
213
+ }
214
+
215
+ private scheduleRefresh() {
216
+ if (!this.token) return;
217
+ if (this.refreshTimer) window.clearTimeout(this.refreshTimer);
218
+ const jitter = 2_000 + Math.floor(Math.random() * 2_000);
219
+ const due = Math.max(0, this.token.exp - this.skewMs - Date.now() - jitter);
220
+ this.refreshTimer = window.setTimeout(() => {
221
+ void this.getToken(true);
222
+ }, due);
223
+ }
224
+
225
+ private async getToken(force: boolean): Promise<string | null> {
226
+ if (!this.fetchToken || this.authLocked) return null; // πŸ”’ deny any token
227
+ const cached = this.freshTokenInCache();
228
+ if (!force && cached) return cached;
229
+
230
+ if (!this.inflightToken) {
231
+ this.inflightToken = (async () => {
232
+ const t = await this.fetchToken!({ forceRefreshToken: true });
233
+ // ignore token if we got locked while waiting
234
+ if (this.authLocked) {
235
+ this.emitAuth(); // ensure listeners see current (likely false)
236
+ return null;
237
+ }
238
+ this.applyToken(t ?? null);
239
+ return t ?? null;
240
+ })().finally(() => setTimeout(() => (this.inflightToken = undefined), 0));
241
+ }
242
+ return await this.inflightToken;
243
+ }
244
+
245
+ private async ensureHttpAuth(): Promise<void> {
246
+ await this.getToken(false);
247
+ }
248
+
249
+ // β€”β€”β€” live query β€”β€”β€”
250
+ watchQuery<Q extends FunctionReference<'query'>>(q: Q, args: FunctionArgs<Q>): WatchHandle<Q> {
251
+ const name = getFunctionName(q);
252
+ const valueArgs = (args ?? {}) as unknown as Record<string, Value>;
253
+ const { queryToken, unsubscribe } = this.base.subscribe(name, valueArgs);
254
+
255
+ const entry: Entry = { name, args: valueArgs, listeners: new Set(), unsubscribe };
256
+ this.byToken.set(queryToken, entry);
257
+
258
+ return {
259
+ localQueryResult: () =>
260
+ this.base.localQueryResult(name, valueArgs) as FunctionReturnType<Q> | undefined,
261
+ onUpdate: (cb) => {
262
+ entry.listeners.add(cb);
263
+ return () => entry.listeners.delete(cb);
264
+ },
265
+ unsubscribe: () => {
266
+ entry.unsubscribe();
267
+ this.byToken.delete(queryToken);
268
+ },
269
+ };
270
+ }
271
+
272
+ // β€”β€”β€” mutation β€”β€”β€”
273
+ async mutation<M extends FunctionReference<'mutation'>>(
274
+ m: M,
275
+ args: FunctionArgs<M>,
276
+ opts?: { optimisticUpdate?: OptimisticUpdate<FunctionArgs<M>> },
277
+ ): Promise<FunctionReturnType<M>> {
278
+ const name = getFunctionName(m);
279
+ return this.base.mutation(
280
+ name,
281
+ args as any,
282
+ opts?.optimisticUpdate ? { optimisticUpdate: opts.optimisticUpdate as any } : undefined,
283
+ );
284
+ }
285
+ // β€”β€”β€” action (no dedupe) β€”β€”β€”
286
+ async action<A extends FunctionReference<'action'> & { _args: Record<string, never> }>(
287
+ a: A,
288
+ ): Promise<FunctionReturnType<A>>;
289
+ async action<A extends FunctionReference<'action'>>(
290
+ a: A,
291
+ args: FunctionArgs<A>,
292
+ ): Promise<FunctionReturnType<A>>;
293
+ async action<A extends FunctionReference<'action'>>(
294
+ a: A,
295
+ args?: FunctionArgs<A>,
296
+ ): Promise<FunctionReturnType<A>> {
297
+ await this.ensureHttpAuth();
298
+
299
+ const call = () => this.http.action(a, ...(args ? ([args] as any) : []));
300
+
301
+ try {
302
+ return await call();
303
+ } catch (e: any) {
304
+ const status = e?.status ?? e?.response?.status;
305
+ if (this.fetchToken && status === 401) {
306
+ await this.getToken(true);
307
+ return await call();
308
+ }
309
+ throw e;
310
+ }
311
+ }
312
+
313
+ // β€”β€”β€” one-shot query (HTTP with de-dupe) β€”β€”β€”
314
+ async query<Q extends FunctionReference<'query'> & { _args: Record<string, never> }>(
315
+ q: Q,
316
+ ): Promise<FunctionReturnType<Q>>;
317
+ async query<Q extends FunctionReference<'query'>>(
318
+ q: Q,
319
+ args: FunctionArgs<Q>,
320
+ ): Promise<FunctionReturnType<Q>>;
321
+ async query<Q extends FunctionReference<'query'>>(
322
+ q: Q,
323
+ args?: FunctionArgs<Q>,
324
+ ): Promise<FunctionReturnType<Q>> {
325
+ await this.ensureHttpAuth();
326
+
327
+ const name = getFunctionName(q);
328
+ const key = name + ':' + (args ? stableStringify(args) : '');
329
+
330
+ if (!this.inflightHttp.has(key)) {
331
+ this.inflightHttp.set(
332
+ key,
333
+ (async () => {
334
+ try {
335
+ return await this.http.query(q, ...(args ? ([args] as any) : []));
336
+ } catch (e: any) {
337
+ const status = e?.status ?? e?.response?.status;
338
+ if (this.fetchToken && status === 401) {
339
+ await this.getToken(true);
340
+ return await this.http.query(q, ...(args ? ([args] as any) : []));
341
+ }
342
+ throw e;
343
+ } finally {
344
+ setTimeout(() => this.inflightHttp.delete(key), 0);
345
+ }
346
+ })(),
347
+ );
348
+ }
349
+ return this.inflightHttp.get(key)!;
350
+ }
351
+ }