@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 +17 -13
- package/ng-package.json +7 -0
- package/package.json +26 -26
- package/src/lib/auth/auth-client.provider.ts +35 -0
- package/src/lib/auth/convex-better-auth.provider.ts +89 -0
- package/src/lib/convex-angular.spec.ts +23 -0
- package/src/lib/convex-angular.ts +15 -0
- package/src/lib/core/convex-angular-client.ts +351 -0
- package/src/lib/core/helpers.ts +60 -0
- package/src/lib/core/inject-convex.token.ts +12 -0
- package/src/lib/core/types.ts +14 -0
- package/src/lib/resources/action.resource.ts +153 -0
- package/src/lib/resources/live.resource.ts +149 -0
- package/src/lib/resources/mutation.resource.ts +171 -0
- package/src/lib/setup/convex-angular.providers.ts +66 -0
- package/src/public-api.ts +19 -0
- package/tsconfig.lib.json +20 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +14 -0
- package/fesm2022/RazakAlpha-convngx.mjs +0 -747
- package/fesm2022/RazakAlpha-convngx.mjs.map +0 -1
- package/fesm2022/alpha-convngx.mjs +0 -747
- package/fesm2022/alpha-convngx.mjs.map +0 -1
- package/index.d.ts +0 -2678
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal helpers for ConvexAngularClient.
|
|
3
|
+
* NOTE: Pure refactor; functionality unchanged.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const AUTH_KEY = 'convex:jwt';
|
|
7
|
+
export const AUTH_CH = 'convex-auth';
|
|
8
|
+
|
|
9
|
+
/** Decode JWT exp claim (ms). Returns 0 if invalid. */
|
|
10
|
+
export function jwtExpMs(jwt: string): number {
|
|
11
|
+
try {
|
|
12
|
+
const payload = JSON.parse(atob(jwt.split('.')[1] || ''));
|
|
13
|
+
return typeof payload?.exp === 'number' ? payload.exp * 1000 : 0;
|
|
14
|
+
} catch {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Canonical JSON stringify for stable de-dupe keys. */
|
|
20
|
+
export function stableStringify(input: unknown): string {
|
|
21
|
+
const seen = new WeakSet<object>();
|
|
22
|
+
const norm = (v: any): any => {
|
|
23
|
+
if (v === null || typeof v !== 'object') return v;
|
|
24
|
+
if (seen.has(v)) return v;
|
|
25
|
+
seen.add(v);
|
|
26
|
+
if (Array.isArray(v)) return v.map(norm);
|
|
27
|
+
return Object.keys(v)
|
|
28
|
+
.sort()
|
|
29
|
+
.reduce((acc: any, k) => {
|
|
30
|
+
acc[k] = norm(v[k]);
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
};
|
|
34
|
+
return JSON.stringify(norm(input));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** sessionStorage helpers (some browsers may throw on access) */
|
|
38
|
+
export const safeSession = {
|
|
39
|
+
get: (k: string): string | null => {
|
|
40
|
+
try {
|
|
41
|
+
return typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(k) : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
set: (k: string, v: string) => {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(k, v);
|
|
49
|
+
} catch {
|
|
50
|
+
/* no-op */
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
del: (k: string) => {
|
|
54
|
+
try {
|
|
55
|
+
if (typeof sessionStorage !== 'undefined') sessionStorage.removeItem(k);
|
|
56
|
+
} catch {
|
|
57
|
+
/* no-op */
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injection token and helper for accessing the Convex client from DI.
|
|
3
|
+
* Keep this tiny and stable — many helpers rely on it.
|
|
4
|
+
*/
|
|
5
|
+
import { inject, InjectionToken } from '@angular/core';
|
|
6
|
+
import { ConvexAngularClient } from './convex-angular-client';
|
|
7
|
+
|
|
8
|
+
/** DI token for the configured ConvexAngularClient instance */
|
|
9
|
+
export const CONVEX = new InjectionToken<ConvexAngularClient>('CONVEX');
|
|
10
|
+
|
|
11
|
+
/** Convenience helper to inject the Convex client */
|
|
12
|
+
export const injectConvex = () => inject(CONVEX);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Convex Angular core.
|
|
3
|
+
* Pure extraction for readability; no behavior changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Auth token fetcher used by ConvexAngularClient.setAuth */
|
|
7
|
+
export type FetchAccessToken = (o: { forceRefreshToken: boolean }) => Promise<string | null>;
|
|
8
|
+
|
|
9
|
+
/** Snapshot of current auth state (token presence-based) */
|
|
10
|
+
export type AuthSnapshot = {
|
|
11
|
+
isAuthenticated: boolean;
|
|
12
|
+
token: string | null;
|
|
13
|
+
exp?: number;
|
|
14
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// convex-action-resource.ts
|
|
2
|
+
import {
|
|
3
|
+
ResourceRef,
|
|
4
|
+
ResourceStreamItem,
|
|
5
|
+
computed,
|
|
6
|
+
resource,
|
|
7
|
+
signal,
|
|
8
|
+
type Signal,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex/server';
|
|
11
|
+
import { injectConvex } from '../core/inject-convex.token';
|
|
12
|
+
|
|
13
|
+
export type ActionRef = FunctionReference<'action'>;
|
|
14
|
+
type NoArgsAction = ActionRef & { _args: Record<string, never> };
|
|
15
|
+
type IfEmptyArgs<A extends ActionRef, TIfEmpty, TIfNot> = keyof A['_args'] extends never
|
|
16
|
+
? TIfEmpty
|
|
17
|
+
: TIfNot;
|
|
18
|
+
|
|
19
|
+
export interface ConvexActionOptions<A extends ActionRef> {
|
|
20
|
+
onSuccess?: (data: FunctionReturnType<A>) => void;
|
|
21
|
+
onError?: (err: Error) => void;
|
|
22
|
+
/** concurrency: queue = sequential, drop = ignore while inflight, replace = prefer latest */
|
|
23
|
+
mode?: 'queue' | 'drop' | 'replace';
|
|
24
|
+
/** simple retry */
|
|
25
|
+
retries?: number;
|
|
26
|
+
retryDelayMs?: (attempt: number) => number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type RunFn<A extends ActionRef> = IfEmptyArgs<
|
|
30
|
+
A,
|
|
31
|
+
(args?: FunctionArgs<A>) => Promise<FunctionReturnType<A>>,
|
|
32
|
+
(args: FunctionArgs<A>) => Promise<FunctionReturnType<A>>
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
export interface ConvexActionResource<A extends ActionRef> {
|
|
36
|
+
run: RunFn<A>;
|
|
37
|
+
state: ResourceRef<FunctionReturnType<A> | undefined>;
|
|
38
|
+
data: Signal<FunctionReturnType<A> | undefined>;
|
|
39
|
+
error: Signal<Error | undefined>;
|
|
40
|
+
isRunning: Signal<boolean>;
|
|
41
|
+
reset(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Overloads for nice run() arg ergonomics */
|
|
45
|
+
export function convexActionResource<A extends NoArgsAction>(
|
|
46
|
+
action: A,
|
|
47
|
+
opts?: ConvexActionOptions<A>,
|
|
48
|
+
): ConvexActionResource<A>;
|
|
49
|
+
export function convexActionResource<A extends ActionRef>(
|
|
50
|
+
action: A,
|
|
51
|
+
opts?: ConvexActionOptions<A>,
|
|
52
|
+
): ConvexActionResource<A>;
|
|
53
|
+
|
|
54
|
+
/** Single impl */
|
|
55
|
+
export function convexActionResource<A extends ActionRef>(
|
|
56
|
+
action: A,
|
|
57
|
+
opts?: ConvexActionOptions<A>,
|
|
58
|
+
): ConvexActionResource<A> {
|
|
59
|
+
const convex = injectConvex();
|
|
60
|
+
const isRunning = signal(false);
|
|
61
|
+
const inflight = signal<Promise<unknown> | undefined>(undefined);
|
|
62
|
+
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
|
63
|
+
const trigger = signal<{ id: number; args: FunctionArgs<A> } | undefined>(undefined);
|
|
64
|
+
let seq = 0;
|
|
65
|
+
|
|
66
|
+
const state = resource<
|
|
67
|
+
FunctionReturnType<A> | undefined,
|
|
68
|
+
{ id: number; args: FunctionArgs<A> } | undefined
|
|
69
|
+
>({
|
|
70
|
+
params: computed(() => trigger()),
|
|
71
|
+
stream: async ({ params, abortSignal }) => {
|
|
72
|
+
const out = signal<ResourceStreamItem<FunctionReturnType<A> | undefined>>({
|
|
73
|
+
value: undefined,
|
|
74
|
+
});
|
|
75
|
+
if (!params) return out;
|
|
76
|
+
|
|
77
|
+
const done = () => {
|
|
78
|
+
const w = pending.get(params.id);
|
|
79
|
+
if (w) pending.delete(params.id);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const runOnce = async () => {
|
|
83
|
+
let attempt = 0;
|
|
84
|
+
const retries = opts?.retries ?? 0;
|
|
85
|
+
const delay = (n: number) =>
|
|
86
|
+
new Promise((r) => setTimeout(r, opts?.retryDelayMs?.(n) ?? 500 * n));
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
try {
|
|
90
|
+
const res = await convex.action(action, params.args);
|
|
91
|
+
if (!abortSignal.aborted) {
|
|
92
|
+
out.set({ value: res });
|
|
93
|
+
opts?.onSuccess?.(res);
|
|
94
|
+
pending.get(params.id)?.resolve(res);
|
|
95
|
+
}
|
|
96
|
+
done();
|
|
97
|
+
return;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
100
|
+
if (!abortSignal.aborted) out.set({ error: err });
|
|
101
|
+
if (attempt >= retries) {
|
|
102
|
+
opts?.onError?.(err);
|
|
103
|
+
pending.get(params.id)?.reject(err);
|
|
104
|
+
done();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
attempt++;
|
|
108
|
+
await delay(attempt);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
isRunning.set(true);
|
|
114
|
+
try {
|
|
115
|
+
const p = runOnce();
|
|
116
|
+
inflight.set(p);
|
|
117
|
+
await p;
|
|
118
|
+
} finally {
|
|
119
|
+
if (!abortSignal.aborted) isRunning.set(false);
|
|
120
|
+
if (inflight() && (await inflight()) === undefined) inflight.set(undefined);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return out;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const data = computed(() => state.value());
|
|
128
|
+
const error = computed(() => state.error());
|
|
129
|
+
|
|
130
|
+
const run: RunFn<A> = (async (args?: FunctionArgs<A>) => {
|
|
131
|
+
const job = { id: ++seq, args: (args ?? ({} as any)) as FunctionArgs<A> };
|
|
132
|
+
|
|
133
|
+
if (opts?.mode === 'drop' && inflight()) {
|
|
134
|
+
return inflight() as Promise<FunctionReturnType<A>>;
|
|
135
|
+
}
|
|
136
|
+
if (opts?.mode === 'queue' && inflight()) {
|
|
137
|
+
await inflight();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const promise = new Promise<FunctionReturnType<A>>((resolve, reject) => {
|
|
141
|
+
pending.set(job.id, { resolve, reject });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
trigger.set(job); // "replace" naturally supersedes older emissions
|
|
145
|
+
return promise;
|
|
146
|
+
}) as RunFn<A>;
|
|
147
|
+
|
|
148
|
+
const reset = () => {
|
|
149
|
+
(state as any).reset?.();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return { run, state, data, error, isRunning: isRunning.asReadonly(), reset };
|
|
153
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// convex-resource.ts
|
|
2
|
+
import {
|
|
3
|
+
ResourceRef,
|
|
4
|
+
ResourceStreamItem,
|
|
5
|
+
computed,
|
|
6
|
+
inject,
|
|
7
|
+
resource,
|
|
8
|
+
signal,
|
|
9
|
+
InjectionToken,
|
|
10
|
+
Provider,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import { FunctionReference, FunctionReturnType } from 'convex/server';
|
|
13
|
+
import { injectConvex } from '../core/inject-convex.token';
|
|
14
|
+
|
|
15
|
+
export type QueryRef = FunctionReference<'query'>;
|
|
16
|
+
export type KeepMode = 'none' | 'last';
|
|
17
|
+
|
|
18
|
+
export interface ConvexResourceOptions {
|
|
19
|
+
keep?: KeepMode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULTS: Required<ConvexResourceOptions> = { keep: 'last' };
|
|
23
|
+
|
|
24
|
+
export const CONVEX_RESOURCE_OPTIONS = new InjectionToken<Required<ConvexResourceOptions>>(
|
|
25
|
+
'CONVEX_RESOURCE_OPTIONS',
|
|
26
|
+
{ factory: () => DEFAULTS },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export function provideConvexResourceOptions(opts: Partial<ConvexResourceOptions>): Provider {
|
|
30
|
+
return { provide: CONVEX_RESOURCE_OPTIONS, useValue: { ...DEFAULTS, ...opts } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// “no-args” queries have `_args` compatible with {}
|
|
34
|
+
type NoArgsQuery = QueryRef & { _args: Record<string, never> };
|
|
35
|
+
|
|
36
|
+
/** Overloads */
|
|
37
|
+
export function convexLiveResource<Q extends NoArgsQuery>(
|
|
38
|
+
query: Q,
|
|
39
|
+
opts?: ConvexResourceOptions,
|
|
40
|
+
): ResourceRef<FunctionReturnType<Q> | undefined>;
|
|
41
|
+
export function convexLiveResource<Q extends NoArgsQuery>(
|
|
42
|
+
query: Q,
|
|
43
|
+
params: () => {} | undefined,
|
|
44
|
+
opts?: ConvexResourceOptions,
|
|
45
|
+
): ResourceRef<FunctionReturnType<Q> | undefined>;
|
|
46
|
+
export function convexLiveResource<Q extends QueryRef>(
|
|
47
|
+
query: Q,
|
|
48
|
+
params: () => Q['_args'] | undefined,
|
|
49
|
+
opts?: ConvexResourceOptions,
|
|
50
|
+
): ResourceRef<FunctionReturnType<Q> | undefined>;
|
|
51
|
+
|
|
52
|
+
/** Impl */
|
|
53
|
+
export function convexLiveResource<Q extends QueryRef>(
|
|
54
|
+
query: Q,
|
|
55
|
+
a?: (() => Q['_args'] | undefined) | ConvexResourceOptions,
|
|
56
|
+
b?: ConvexResourceOptions,
|
|
57
|
+
): ResourceRef<FunctionReturnType<Q> | undefined> {
|
|
58
|
+
const convex = injectConvex();
|
|
59
|
+
const global = inject(CONVEX_RESOURCE_OPTIONS);
|
|
60
|
+
const keepMode = (typeof a === 'function' ? b : a)?.keep ?? global.keep;
|
|
61
|
+
|
|
62
|
+
const paramsFactory: (() => Q['_args'] | undefined) | undefined =
|
|
63
|
+
typeof a === 'function' ? a : undefined;
|
|
64
|
+
|
|
65
|
+
const lastGlobal = signal<FunctionReturnType<Q> | undefined>(undefined);
|
|
66
|
+
|
|
67
|
+
const argsSig = computed<Q['_args'] | undefined>(() => {
|
|
68
|
+
if (!paramsFactory) return {} as Q['_args']; // no-args: always enabled
|
|
69
|
+
return paramsFactory();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- reload tagging ---
|
|
73
|
+
const reloadStamp = signal(0); // increments only when .reload() is called
|
|
74
|
+
let lastSeenReload = 0; // compared inside stream to detect reload
|
|
75
|
+
|
|
76
|
+
type ParamEnvelope = { args: Q['_args'] | undefined; __r: number } | undefined;
|
|
77
|
+
|
|
78
|
+
const request = computed<ParamEnvelope>(() => ({
|
|
79
|
+
args: argsSig(),
|
|
80
|
+
__r: reloadStamp(),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const base = resource<FunctionReturnType<Q> | undefined, ParamEnvelope>({
|
|
84
|
+
params: request,
|
|
85
|
+
stream: async ({ params, abortSignal }) => {
|
|
86
|
+
const s = signal<ResourceStreamItem<FunctionReturnType<Q> | undefined>>({
|
|
87
|
+
value: keepMode === 'last' ? lastGlobal() : undefined,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!params || !params.args) return s; // gated
|
|
91
|
+
const isReload = params.__r !== lastSeenReload;
|
|
92
|
+
lastSeenReload = params.__r;
|
|
93
|
+
|
|
94
|
+
// One-shot fetch: ONLY on reload
|
|
95
|
+
if (isReload) {
|
|
96
|
+
convex
|
|
97
|
+
.query(query, params.args)
|
|
98
|
+
.then((next) => {
|
|
99
|
+
if (abortSignal.aborted) return;
|
|
100
|
+
s.set({ value: next });
|
|
101
|
+
lastGlobal.set(next);
|
|
102
|
+
})
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
if (abortSignal.aborted) return;
|
|
105
|
+
s.set({ error: err as Error });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Live subscription (always)
|
|
110
|
+
const w = convex.watchQuery(query, params.args as Q['_args']);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const local = w.localQueryResult();
|
|
114
|
+
if (local !== undefined) {
|
|
115
|
+
s.set({ value: local });
|
|
116
|
+
lastGlobal.set(local);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
s.set({ error: err as Error });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const off = w.onUpdate(() => {
|
|
123
|
+
try {
|
|
124
|
+
const next = w.localQueryResult();
|
|
125
|
+
s.set({ value: next });
|
|
126
|
+
lastGlobal.set(next);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
s.set({ error: err as Error });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
abortSignal.addEventListener('abort', () => off(), { once: true });
|
|
133
|
+
return s;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Wrap to bump reloadStamp only when user calls reload()
|
|
138
|
+
const origReload = base.reload.bind(base);
|
|
139
|
+
|
|
140
|
+
const wrapped: ResourceRef<FunctionReturnType<Q> | undefined> = {
|
|
141
|
+
...base,
|
|
142
|
+
reload: () => {
|
|
143
|
+
reloadStamp.set(reloadStamp() + 1);
|
|
144
|
+
return origReload();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return wrapped;
|
|
149
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// convex-mutation-resource.ts
|
|
2
|
+
import {
|
|
3
|
+
ResourceRef,
|
|
4
|
+
ResourceStreamItem,
|
|
5
|
+
computed,
|
|
6
|
+
resource,
|
|
7
|
+
signal,
|
|
8
|
+
type Signal,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import type { OptimisticUpdate } from 'convex/browser';
|
|
11
|
+
import { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex/server';
|
|
12
|
+
import { injectConvex } from '../core/inject-convex.token';
|
|
13
|
+
|
|
14
|
+
export type MutationRef = FunctionReference<'mutation'>;
|
|
15
|
+
type NoArgsMutation = MutationRef & { _args: Record<string, never> };
|
|
16
|
+
type IfEmptyArgs<M extends MutationRef, TIfEmpty, TIfNot> = keyof M['_args'] extends never
|
|
17
|
+
? TIfEmpty
|
|
18
|
+
: TIfNot;
|
|
19
|
+
|
|
20
|
+
export interface ConvexMutationOptions<M extends MutationRef> {
|
|
21
|
+
optimisticUpdate?: OptimisticUpdate<FunctionArgs<M>>;
|
|
22
|
+
onSuccess?: (data: FunctionReturnType<M>) => void;
|
|
23
|
+
onError?: (err: Error) => void;
|
|
24
|
+
/** concurrency: queue = sequential, drop = ignore while inflight, replace = prefer latest */
|
|
25
|
+
mode?: 'queue' | 'drop' | 'replace';
|
|
26
|
+
/** simple retry */
|
|
27
|
+
retries?: number;
|
|
28
|
+
retryDelayMs?: (attempt: number) => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type RunFn<M extends MutationRef> = IfEmptyArgs<
|
|
32
|
+
M,
|
|
33
|
+
(args?: FunctionArgs<M>) => Promise<FunctionReturnType<M>>,
|
|
34
|
+
(args: FunctionArgs<M>) => Promise<FunctionReturnType<M>>
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
export interface ConvexMutationResource<M extends MutationRef> {
|
|
38
|
+
/** imperative trigger */
|
|
39
|
+
run: RunFn<M>;
|
|
40
|
+
/** resource-shaped state (bind in templates if you like) */
|
|
41
|
+
state: ResourceRef<FunctionReturnType<M> | undefined>;
|
|
42
|
+
/** convenience signals */
|
|
43
|
+
data: Signal<FunctionReturnType<M> | undefined>;
|
|
44
|
+
error: Signal<Error | undefined>;
|
|
45
|
+
isRunning: Signal<boolean>;
|
|
46
|
+
reset(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Overloads for nice run() arg ergonomics */
|
|
50
|
+
export function convexMutationResource<M extends NoArgsMutation>(
|
|
51
|
+
mutation: M,
|
|
52
|
+
opts?: ConvexMutationOptions<M>,
|
|
53
|
+
): ConvexMutationResource<M>;
|
|
54
|
+
export function convexMutationResource<M extends MutationRef>(
|
|
55
|
+
mutation: M,
|
|
56
|
+
opts?: ConvexMutationOptions<M>,
|
|
57
|
+
): ConvexMutationResource<M>;
|
|
58
|
+
|
|
59
|
+
/** Single impl */
|
|
60
|
+
export function convexMutationResource<M extends MutationRef>(
|
|
61
|
+
mutation: M,
|
|
62
|
+
opts?: ConvexMutationOptions<M>,
|
|
63
|
+
): ConvexMutationResource<M> {
|
|
64
|
+
const convex = injectConvex();
|
|
65
|
+
const isRunning = signal(false);
|
|
66
|
+
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
|
67
|
+
const inflight = signal<Promise<unknown> | undefined>(undefined);
|
|
68
|
+
const trigger = signal<{ id: number; args: FunctionArgs<M> } | undefined>(undefined);
|
|
69
|
+
let seq = 0;
|
|
70
|
+
|
|
71
|
+
const state = resource<
|
|
72
|
+
FunctionReturnType<M> | undefined,
|
|
73
|
+
{ id: number; args: FunctionArgs<M> } | undefined
|
|
74
|
+
>({
|
|
75
|
+
params: computed(() => trigger()),
|
|
76
|
+
stream: async ({ params, abortSignal }) => {
|
|
77
|
+
const out = signal<ResourceStreamItem<FunctionReturnType<M> | undefined>>({
|
|
78
|
+
value: undefined,
|
|
79
|
+
});
|
|
80
|
+
if (!params) return out;
|
|
81
|
+
|
|
82
|
+
const done = () => {
|
|
83
|
+
// clean out waiter if still present
|
|
84
|
+
const w = pending.get(params.id);
|
|
85
|
+
if (w) pending.delete(params.id);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const runOnce = async () => {
|
|
89
|
+
let attempt = 0;
|
|
90
|
+
const retries = opts?.retries ?? 0;
|
|
91
|
+
const delay = (n: number) =>
|
|
92
|
+
new Promise((r) => setTimeout(r, opts?.retryDelayMs?.(n) ?? 500 * n));
|
|
93
|
+
|
|
94
|
+
// NB: Convex mutations don’t support abort; we still observe abortSignal to stop emitting.
|
|
95
|
+
// We *do not* call mutation again on aborted; we just stop updating UI.
|
|
96
|
+
// If you choose mode: 'replace', older runs get superseded by newer trigger()s.
|
|
97
|
+
// That’s the main “cancellation” we can mimic here.
|
|
98
|
+
while (true) {
|
|
99
|
+
try {
|
|
100
|
+
const res = await convex.mutation(mutation, params.args, {
|
|
101
|
+
optimisticUpdate: opts?.optimisticUpdate,
|
|
102
|
+
});
|
|
103
|
+
if (!abortSignal.aborted) {
|
|
104
|
+
out.set({ value: res });
|
|
105
|
+
opts?.onSuccess?.(res);
|
|
106
|
+
pending.get(params.id)?.resolve(res);
|
|
107
|
+
}
|
|
108
|
+
done();
|
|
109
|
+
return;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
112
|
+
if (!abortSignal.aborted) {
|
|
113
|
+
out.set({ error: err });
|
|
114
|
+
}
|
|
115
|
+
if (attempt >= retries) {
|
|
116
|
+
opts?.onError?.(err);
|
|
117
|
+
pending.get(params.id)?.reject(err);
|
|
118
|
+
done();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
attempt++;
|
|
122
|
+
await delay(attempt);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
isRunning.set(true);
|
|
128
|
+
try {
|
|
129
|
+
const p = runOnce();
|
|
130
|
+
inflight.set(p);
|
|
131
|
+
await p;
|
|
132
|
+
} finally {
|
|
133
|
+
if (!abortSignal.aborted) isRunning.set(false);
|
|
134
|
+
if (inflight() && (await inflight()) === undefined) inflight.set(undefined);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return out;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const data = computed(() => state.value());
|
|
142
|
+
const error = computed(() => state.error());
|
|
143
|
+
|
|
144
|
+
const run: RunFn<M> = (async (args?: FunctionArgs<M>) => {
|
|
145
|
+
const job = { id: ++seq, args: (args ?? ({} as any)) as FunctionArgs<M> };
|
|
146
|
+
|
|
147
|
+
if (opts?.mode === 'drop' && inflight()) {
|
|
148
|
+
// return the inflight promise if present (best-effort)
|
|
149
|
+
return inflight() as Promise<any>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (opts?.mode === 'queue' && inflight()) {
|
|
153
|
+
await inflight();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const promise = new Promise<FunctionReturnType<M>>((resolve, reject) => {
|
|
157
|
+
pending.set(job.id, { resolve, reject });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// "replace": push new job; older job’s UI will be superseded by the next emission
|
|
161
|
+
trigger.set(job);
|
|
162
|
+
return promise;
|
|
163
|
+
}) as RunFn<M>;
|
|
164
|
+
|
|
165
|
+
const reset = () => {
|
|
166
|
+
// clear UI state; does not affect in-flight promise
|
|
167
|
+
(state as any).reset?.(); // safe if Angular adds reset later; otherwise ignore
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return { run, state, data, error, isRunning: isRunning.asReadonly(), reset };
|
|
171
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Provider } from '@angular/core';
|
|
2
|
+
import { AUTH_CLIENT, provideAuthClient, type AuthClient } from '../auth/auth-client.provider';
|
|
3
|
+
import { provideConvexBetterAuth } from '../auth/convex-better-auth.provider';
|
|
4
|
+
import { provideConvexResourceOptions, type KeepMode } from '../resources/live.resource';
|
|
5
|
+
|
|
6
|
+
export interface ConvexAngularOptions {
|
|
7
|
+
/** Convex deployment URL, e.g. https://xxx.convex.cloud */
|
|
8
|
+
convexUrl: string;
|
|
9
|
+
/** Better Auth base URL (convex site url), e.g. https://xxx.convex.site */
|
|
10
|
+
authBaseURL: string;
|
|
11
|
+
/** Skew before JWT expiry to refresh token */
|
|
12
|
+
authSkewMs?: number;
|
|
13
|
+
/** Default keep mode for live resources ('last' | 'none') */
|
|
14
|
+
keep?: KeepMode;
|
|
15
|
+
/**
|
|
16
|
+
* Optional: user-provided Better Auth client.
|
|
17
|
+
* Must include convexClient() and crossDomainClient() plugins.
|
|
18
|
+
*/
|
|
19
|
+
authClient?: AuthClient;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single entry-point provider to wire Convex + Better Auth + resource defaults.
|
|
24
|
+
* Behavior:
|
|
25
|
+
* - If opts.authClient is provided, we use it (and verify required plugins).
|
|
26
|
+
* - Else we create a default Better Auth client using authBaseURL with required plugins.
|
|
27
|
+
*/
|
|
28
|
+
export function provideConvexAngular(opts: ConvexAngularOptions): Provider[] {
|
|
29
|
+
const providers: Provider[] = [];
|
|
30
|
+
|
|
31
|
+
if (opts.authClient) {
|
|
32
|
+
providers.push({
|
|
33
|
+
provide: AUTH_CLIENT,
|
|
34
|
+
useFactory: () => {
|
|
35
|
+
const c = opts.authClient as AuthClient;
|
|
36
|
+
const hasConvex = typeof c.convex?.token === 'function';
|
|
37
|
+
const hasCross = typeof c.crossDomain?.oneTimeToken?.verify === 'function';
|
|
38
|
+
if (!hasConvex || !hasCross) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Provided AUTH client is missing required plugins: convexClient() and crossDomainClient().',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return c;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
// Default client with known plugin set (convex + crossDomain)
|
|
48
|
+
providers.push(
|
|
49
|
+
provideAuthClient({
|
|
50
|
+
baseURL: opts.authBaseURL,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
providers.push(
|
|
56
|
+
...provideConvexBetterAuth({
|
|
57
|
+
convexUrl: opts.convexUrl,
|
|
58
|
+
authSkewMs: opts.authSkewMs,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (opts.keep) {
|
|
63
|
+
providers.push(provideConvexResourceOptions({ keep: opts.keep }));
|
|
64
|
+
}
|
|
65
|
+
return providers;
|
|
66
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Public API Surface of convex-angular
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Core client and DI token
|
|
6
|
+
export * from './lib/core/convex-angular-client';
|
|
7
|
+
export * from './lib/core/inject-convex.token';
|
|
8
|
+
|
|
9
|
+
// One-call setup provider (combine common providers)
|
|
10
|
+
export * from './lib/setup/convex-angular.providers';
|
|
11
|
+
|
|
12
|
+
// Better Auth + Convex provider
|
|
13
|
+
export * from './lib/auth/convex-better-auth.provider';
|
|
14
|
+
export * from './lib/auth/auth-client.provider';
|
|
15
|
+
|
|
16
|
+
// Angular resource helpers for Convex
|
|
17
|
+
export * from './lib/resources/live.resource';
|
|
18
|
+
export * from './lib/resources/mutation.resource';
|
|
19
|
+
export * from './lib/resources/action.resource';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
2
|
+
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
3
|
+
{
|
|
4
|
+
"extends": "../../tsconfig.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"outDir": "../../out-tsc/lib",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"inlineSources": true,
|
|
11
|
+
"types": []
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"src/**/*.ts"
|
|
15
|
+
],
|
|
16
|
+
"exclude": [
|
|
17
|
+
"**/*.spec.ts",
|
|
18
|
+
"src/lib/auth.ts"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
2
|
+
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
3
|
+
{
|
|
4
|
+
"extends": "./tsconfig.lib.json",
|
|
5
|
+
"compilerOptions": {
|
|
6
|
+
"declarationMap": false
|
|
7
|
+
},
|
|
8
|
+
"angularCompilerOptions": {
|
|
9
|
+
"compilationMode": "partial"
|
|
10
|
+
}
|
|
11
|
+
}
|