@svadmin/core 0.24.0 → 0.25.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.
- package/package.json +1 -1
- package/src/context.svelte.ts +19 -1
- package/src/i18n.svelte.ts +116 -3
- package/src/index.ts +15 -0
- package/src/task-hooks.svelte.ts +240 -0
- package/src/task-hooks.test.svelte.ts +129 -0
- package/src/types.ts +44 -0
package/package.json
CHANGED
package/src/context.svelte.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Svelte 5 Context — module-level $state singletons (no setContext/getContext required)
|
|
2
2
|
// This removes the constraint that hooks must be called at component init time.
|
|
3
3
|
|
|
4
|
-
import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
|
|
4
|
+
import type { DataProvider, AuthProvider, ResourceDefinition, TaskProvider } from './types';
|
|
5
5
|
import type { RouterProvider } from './router-provider';
|
|
6
6
|
import { resetNotificationProvider } from './notification.svelte';
|
|
7
7
|
import { resetAuditLogProvider } from './audit';
|
|
@@ -136,6 +136,23 @@ export function getLiveProvider(): import('./live.svelte').LiveProvider | undefi
|
|
|
136
136
|
return liveProviderState;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// ─── Task Provider ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
let taskProviderState = $state<TaskProvider | undefined>(undefined);
|
|
142
|
+
|
|
143
|
+
export function setTaskProvider(provider: TaskProvider | undefined): void {
|
|
144
|
+
taskProviderState = provider;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getTaskProvider(): TaskProvider;
|
|
148
|
+
export function getTaskProvider(options: { optional: true }): TaskProvider | undefined;
|
|
149
|
+
export function getTaskProvider(options?: { optional?: boolean }): TaskProvider | undefined {
|
|
150
|
+
if (!taskProviderState && !options?.optional) {
|
|
151
|
+
throw new Error('TaskProvider not found. Did you call setTaskProvider in App.svelte?');
|
|
152
|
+
}
|
|
153
|
+
return taskProviderState;
|
|
154
|
+
}
|
|
155
|
+
|
|
139
156
|
// ─── Reset — for testing / HMR ─────────────────────────────────
|
|
140
157
|
|
|
141
158
|
export function resetContext(): void {
|
|
@@ -144,6 +161,7 @@ export function resetContext(): void {
|
|
|
144
161
|
resources = [];
|
|
145
162
|
routerProvider = undefined;
|
|
146
163
|
liveProviderState = undefined;
|
|
164
|
+
taskProviderState = undefined;
|
|
147
165
|
resetNotificationProvider();
|
|
148
166
|
resetAuditLogProvider();
|
|
149
167
|
resetAccessControlProvider();
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const baseLocales: Record<string, Record<string, string>> = {
|
|
6
6
|
'zh-CN': {
|
|
7
7
|
'common.list': '列表',
|
|
8
8
|
'common.prev': '上一页',
|
|
@@ -253,6 +253,62 @@ let locales = $state<Record<string, Record<string, string>>>({
|
|
|
253
253
|
'profile.currentPassword': '当前密码',
|
|
254
254
|
'profile.passwordChanged': '密码已修改',
|
|
255
255
|
'profile.updatePassword': '更新密码',
|
|
256
|
+
// Tasks
|
|
257
|
+
'task.drawerTitle': '任务中心',
|
|
258
|
+
'task.listTitle': '任务列表',
|
|
259
|
+
'task.dlqTitle': '死信队列',
|
|
260
|
+
'task.tabTasks': '任务',
|
|
261
|
+
'task.tabDlq': '死信',
|
|
262
|
+
'task.count': '{count} 个任务',
|
|
263
|
+
'task.activeCount': '{count} 个进行中',
|
|
264
|
+
'task.lastQueuedAt': '最近排队时间 {time}',
|
|
265
|
+
'task.loadingList': '正在加载任务...',
|
|
266
|
+
'task.loadingDetail': '正在加载任务详情...',
|
|
267
|
+
'task.noTasks': '暂无任务',
|
|
268
|
+
'task.noDlq': '暂无死信任务',
|
|
269
|
+
'task.noSelection': '未选择任务',
|
|
270
|
+
'task.selectHint': '从左侧选择一个任务查看详情',
|
|
271
|
+
'task.taskColumn': '任务',
|
|
272
|
+
'task.statusColumn': '状态',
|
|
273
|
+
'task.updatedColumn': '更新时间',
|
|
274
|
+
'task.actionsColumn': '操作',
|
|
275
|
+
'task.detailsAction': '详情',
|
|
276
|
+
'task.detailsTitle': '任务详情',
|
|
277
|
+
'task.detailsSummary': '任务详情',
|
|
278
|
+
'task.createdLabel': '创建时间',
|
|
279
|
+
'task.updatedLabel': '更新时间',
|
|
280
|
+
'task.messageLabel': '消息',
|
|
281
|
+
'task.errorLabel': '错误',
|
|
282
|
+
'task.payloadLabel': '负载',
|
|
283
|
+
'task.searchPlaceholder': '按名称、ID、状态或消息筛选任务...',
|
|
284
|
+
'task.statusFilterLabel': '状态筛选',
|
|
285
|
+
'task.statusAll': '全部状态',
|
|
286
|
+
'task.autoRefreshLabel': '自动刷新',
|
|
287
|
+
'task.refreshOff': '关闭',
|
|
288
|
+
'task.refresh5s': '每 5 秒',
|
|
289
|
+
'task.refresh15s': '每 15 秒',
|
|
290
|
+
'task.refresh30s': '每 30 秒',
|
|
291
|
+
'task.submitTitle': '提交任务',
|
|
292
|
+
'task.submitDescription': '直接从任务中心提交一个新任务并跟踪执行状态。',
|
|
293
|
+
'task.taskNameLabel': '任务名称',
|
|
294
|
+
'task.idempotencyKeyLabel': '幂等键',
|
|
295
|
+
'task.bodyLabel': '任务负载(JSON)',
|
|
296
|
+
'task.bodyPlaceholder': '{\n "prompt": "生成一张海报"\n}',
|
|
297
|
+
'task.submitAction': '提交任务',
|
|
298
|
+
'task.invalidJson': '任务负载必须是合法 JSON',
|
|
299
|
+
'task.submitSuccess': '任务提交成功',
|
|
300
|
+
'task.submitFailed': '任务提交失败',
|
|
301
|
+
'task.fetchFailed': '加载任务失败',
|
|
302
|
+
'task.fetchListFailed': '加载任务列表失败',
|
|
303
|
+
'task.status.pending': '等待中',
|
|
304
|
+
'task.status.queued': '已排队',
|
|
305
|
+
'task.status.running': '执行中',
|
|
306
|
+
'task.status.processing': '处理中',
|
|
307
|
+
'task.status.completed': '已完成',
|
|
308
|
+
'task.status.success': '成功',
|
|
309
|
+
'task.status.done': '已完成',
|
|
310
|
+
'task.status.failed': '失败',
|
|
311
|
+
'task.status.error': '错误',
|
|
256
312
|
},
|
|
257
313
|
'en': {
|
|
258
314
|
'common.list': 'List',
|
|
@@ -504,10 +560,67 @@ let locales = $state<Record<string, Record<string, string>>>({
|
|
|
504
560
|
'profile.currentPassword': 'Current Password',
|
|
505
561
|
'profile.passwordChanged': 'Password updated successfully',
|
|
506
562
|
'profile.updatePassword': 'Update Password',
|
|
563
|
+
// Tasks
|
|
564
|
+
'task.drawerTitle': 'Task Center',
|
|
565
|
+
'task.listTitle': 'Tasks',
|
|
566
|
+
'task.dlqTitle': 'Dead Letter Queue',
|
|
567
|
+
'task.tabTasks': 'Tasks',
|
|
568
|
+
'task.tabDlq': 'DLQ',
|
|
569
|
+
'task.count': '{count} task(s)',
|
|
570
|
+
'task.activeCount': '{count} active',
|
|
571
|
+
'task.lastQueuedAt': 'Last queued at {time}',
|
|
572
|
+
'task.loadingList': 'Loading tasks...',
|
|
573
|
+
'task.loadingDetail': 'Loading task...',
|
|
574
|
+
'task.noTasks': 'No tasks found',
|
|
575
|
+
'task.noDlq': 'No dead-letter tasks',
|
|
576
|
+
'task.noSelection': 'No task selected',
|
|
577
|
+
'task.selectHint': 'Select a task from the list to inspect details',
|
|
578
|
+
'task.taskColumn': 'Task',
|
|
579
|
+
'task.statusColumn': 'Status',
|
|
580
|
+
'task.updatedColumn': 'Updated',
|
|
581
|
+
'task.actionsColumn': 'Actions',
|
|
582
|
+
'task.detailsAction': 'Details',
|
|
583
|
+
'task.detailsTitle': 'Task Details',
|
|
584
|
+
'task.detailsSummary': 'Task details',
|
|
585
|
+
'task.createdLabel': 'Created',
|
|
586
|
+
'task.updatedLabel': 'Updated',
|
|
587
|
+
'task.messageLabel': 'Message',
|
|
588
|
+
'task.errorLabel': 'Error',
|
|
589
|
+
'task.payloadLabel': 'Payload',
|
|
590
|
+
'task.searchPlaceholder': 'Filter tasks by name, ID, status, or message...',
|
|
591
|
+
'task.statusFilterLabel': 'Status filter',
|
|
592
|
+
'task.statusAll': 'All statuses',
|
|
593
|
+
'task.autoRefreshLabel': 'Auto refresh',
|
|
594
|
+
'task.refreshOff': 'Off',
|
|
595
|
+
'task.refresh5s': 'Every 5s',
|
|
596
|
+
'task.refresh15s': 'Every 15s',
|
|
597
|
+
'task.refresh30s': 'Every 30s',
|
|
598
|
+
'task.submitTitle': 'Submit Task',
|
|
599
|
+
'task.submitDescription': 'Submit a new task from the drawer and follow it through completion.',
|
|
600
|
+
'task.taskNameLabel': 'Task name',
|
|
601
|
+
'task.idempotencyKeyLabel': 'Idempotency key',
|
|
602
|
+
'task.bodyLabel': 'Task payload (JSON)',
|
|
603
|
+
'task.bodyPlaceholder': '{\n "prompt": "Generate a poster"\n}',
|
|
604
|
+
'task.submitAction': 'Submit Task',
|
|
605
|
+
'task.invalidJson': 'Task payload must be valid JSON',
|
|
606
|
+
'task.submitSuccess': 'Task submitted successfully',
|
|
607
|
+
'task.submitFailed': 'Task submission failed',
|
|
608
|
+
'task.fetchFailed': 'Fetch task failed',
|
|
609
|
+
'task.fetchListFailed': 'Fetch tasks failed',
|
|
610
|
+
'task.status.pending': 'Pending',
|
|
611
|
+
'task.status.queued': 'Queued',
|
|
612
|
+
'task.status.running': 'Running',
|
|
613
|
+
'task.status.processing': 'Processing',
|
|
614
|
+
'task.status.completed': 'Completed',
|
|
615
|
+
'task.status.success': 'Success',
|
|
616
|
+
'task.status.done': 'Done',
|
|
617
|
+
'task.status.failed': 'Failed',
|
|
618
|
+
'task.status.error': 'Error',
|
|
507
619
|
},
|
|
508
|
-
}
|
|
620
|
+
};
|
|
509
621
|
|
|
510
|
-
|
|
622
|
+
let locales = $state(structuredClone(baseLocales));
|
|
623
|
+
const initialLocales = structuredClone(baseLocales);
|
|
511
624
|
|
|
512
625
|
/** Detect best locale from browser language */
|
|
513
626
|
function detectLocale(): string {
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
setResources, getResources, getResource,
|
|
7
7
|
setRouterProvider, getRouterProvider,
|
|
8
8
|
setLiveProvider, getLiveProvider,
|
|
9
|
+
setTaskProvider, getTaskProvider,
|
|
9
10
|
resetContext,
|
|
10
11
|
} from './context.svelte';
|
|
11
12
|
export type { DataProviderInput } from './context.svelte';
|
|
@@ -87,6 +88,12 @@ export {
|
|
|
87
88
|
useOnError, usePermissions,
|
|
88
89
|
getLogoutVersion, resetLogoutVersion,
|
|
89
90
|
} from './auth-hooks.svelte';
|
|
91
|
+
export {
|
|
92
|
+
useSubmitTask,
|
|
93
|
+
useTask,
|
|
94
|
+
useTaskList,
|
|
95
|
+
useTaskSubscription,
|
|
96
|
+
} from './task-hooks.svelte';
|
|
90
97
|
export { useParsed, resetGlobalPath, syncGlobalPath } from './useParsed.svelte';
|
|
91
98
|
export * from './useStepsForm.svelte';
|
|
92
99
|
export { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
|
|
@@ -110,3 +117,11 @@ export type { TableStateOptions } from './table-state.svelte';
|
|
|
110
117
|
export type { UseInfiniteListOptions } from './hooks.svelte';
|
|
111
118
|
export type { UseSelectOptions } from './hooks.svelte';
|
|
112
119
|
export type { UseFormReturn } from './form-hooks.svelte';
|
|
120
|
+
export type {
|
|
121
|
+
TaskProvider,
|
|
122
|
+
TaskRecord,
|
|
123
|
+
SubmitTaskOptions,
|
|
124
|
+
TaskHandle,
|
|
125
|
+
TaskListResult,
|
|
126
|
+
TaskSubscription,
|
|
127
|
+
} from './types';
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
|
2
|
+
import { getAdminOptions } from './options.svelte';
|
|
3
|
+
import { getTaskProvider } from './context.svelte';
|
|
4
|
+
import { checkError, createOvertimeTracker, fireErrorNotification, fireSuccessNotification } from './hook-utils.svelte';
|
|
5
|
+
import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
|
|
6
|
+
import { t } from './i18n.svelte';
|
|
7
|
+
import type {
|
|
8
|
+
HttpError,
|
|
9
|
+
SubmitTaskOptions,
|
|
10
|
+
TaskHandle,
|
|
11
|
+
TaskListResult,
|
|
12
|
+
TaskProvider,
|
|
13
|
+
TaskRecord,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
function normalizeTaskProvider<TTask extends TaskRecord = TaskRecord>(
|
|
17
|
+
provider?: TaskProvider<TTask>,
|
|
18
|
+
): TaskProvider<TTask> {
|
|
19
|
+
return (provider ?? getTaskProvider({ optional: true }) ?? getTaskProvider()) as TaskProvider<TTask>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseSubmitTaskOptions {
|
|
23
|
+
mutationOptions?: {
|
|
24
|
+
onSuccess?: (data: unknown, variables: unknown, context: unknown) => void;
|
|
25
|
+
onError?: (error: unknown, variables: unknown, context: unknown) => void;
|
|
26
|
+
};
|
|
27
|
+
overtimeOptions?: OvertimeOptions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseSubmitTaskMutateParams<TTask extends TaskRecord = TaskRecord> {
|
|
31
|
+
taskName: string;
|
|
32
|
+
options?: SubmitTaskOptions;
|
|
33
|
+
taskProvider?: TaskProvider<TTask>;
|
|
34
|
+
successNotification?: NotificationConfig;
|
|
35
|
+
errorNotification?: NotificationConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useSubmitTask<
|
|
39
|
+
TTask extends TaskRecord = TaskRecord,
|
|
40
|
+
TError = HttpError,
|
|
41
|
+
>(options: UseSubmitTaskOptions = {}) {
|
|
42
|
+
const adminOptions = getAdminOptions();
|
|
43
|
+
const queryClient = useQueryClient();
|
|
44
|
+
const overtime = createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime);
|
|
45
|
+
|
|
46
|
+
const mutation = createMutation<TaskHandle<TTask>, TError, UseSubmitTaskMutateParams<TTask>>(() => ({
|
|
47
|
+
mutationFn: async (params) => {
|
|
48
|
+
const provider = normalizeTaskProvider<TTask>(params.taskProvider);
|
|
49
|
+
return provider.submit(params.taskName, params.options);
|
|
50
|
+
},
|
|
51
|
+
onSuccess: (data, params, context) => {
|
|
52
|
+
fireSuccessNotification(
|
|
53
|
+
params.successNotification,
|
|
54
|
+
t('task.submitSuccess'),
|
|
55
|
+
data,
|
|
56
|
+
params.options,
|
|
57
|
+
params.taskName,
|
|
58
|
+
);
|
|
59
|
+
void queryClient.invalidateQueries({ queryKey: ['taskList'] });
|
|
60
|
+
options.mutationOptions?.onSuccess?.(data, params, context);
|
|
61
|
+
},
|
|
62
|
+
onError: (error, params, context) => {
|
|
63
|
+
checkError(error);
|
|
64
|
+
fireErrorNotification(
|
|
65
|
+
params.errorNotification,
|
|
66
|
+
t('task.submitFailed'),
|
|
67
|
+
error,
|
|
68
|
+
params.taskName,
|
|
69
|
+
);
|
|
70
|
+
options.mutationOptions?.onError?.(error, params, context);
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
return { mutation, get overtime() { return overtime; } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface UseTaskOptions<TTask extends TaskRecord = TaskRecord, TError = HttpError> {
|
|
78
|
+
taskId?: string;
|
|
79
|
+
taskProvider?: TaskProvider<TTask>;
|
|
80
|
+
queryOptions?: {
|
|
81
|
+
enabled?: boolean;
|
|
82
|
+
staleTime?: number;
|
|
83
|
+
gcTime?: number;
|
|
84
|
+
refetchOnWindowFocus?: boolean;
|
|
85
|
+
refetchInterval?: number | false;
|
|
86
|
+
refetchIntervalInBackground?: boolean;
|
|
87
|
+
};
|
|
88
|
+
successNotification?: NotificationConfig;
|
|
89
|
+
errorNotification?: NotificationConfig;
|
|
90
|
+
overtimeOptions?: OvertimeOptions;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function useTask<TTask extends TaskRecord = TaskRecord, TError = HttpError>(
|
|
94
|
+
options: UseTaskOptions<TTask, TError> = {},
|
|
95
|
+
) {
|
|
96
|
+
const adminOptions = getAdminOptions();
|
|
97
|
+
|
|
98
|
+
const query = createQuery<TTask, TError>(() => {
|
|
99
|
+
const provider = normalizeTaskProvider<TTask>(options.taskProvider);
|
|
100
|
+
const queryOptions = options.queryOptions;
|
|
101
|
+
return {
|
|
102
|
+
queryKey: ['task', options.taskId],
|
|
103
|
+
queryFn: async () => {
|
|
104
|
+
if (!options.taskId) throw new Error('useTask requires a taskId');
|
|
105
|
+
return provider.get(options.taskId);
|
|
106
|
+
},
|
|
107
|
+
enabled: (queryOptions?.enabled ?? true) && !!options.taskId,
|
|
108
|
+
staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
109
|
+
gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
|
|
110
|
+
refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
|
|
111
|
+
refetchInterval: queryOptions?.refetchInterval,
|
|
112
|
+
refetchIntervalInBackground: queryOptions?.refetchIntervalInBackground,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
|
|
117
|
+
|
|
118
|
+
let lastSuccessAt = 0;
|
|
119
|
+
let lastErrorAt = 0;
|
|
120
|
+
$effect(() => {
|
|
121
|
+
if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
|
|
122
|
+
lastSuccessAt = query.dataUpdatedAt;
|
|
123
|
+
if (options.successNotification) {
|
|
124
|
+
fireSuccessNotification(options.successNotification, '', query.data, undefined, options.taskId);
|
|
125
|
+
}
|
|
126
|
+
} else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
|
|
127
|
+
lastErrorAt = query.errorUpdatedAt;
|
|
128
|
+
checkError(query.error);
|
|
129
|
+
fireErrorNotification(options.errorNotification, t('task.fetchFailed'), query.error, options.taskId);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return new Proxy(query, {
|
|
134
|
+
get(target, prop) {
|
|
135
|
+
if (prop === 'overtime') return overtime;
|
|
136
|
+
return target[prop as keyof typeof target];
|
|
137
|
+
},
|
|
138
|
+
}) as typeof query & { overtime: typeof overtime };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface UseTaskListOptions<TTask extends TaskRecord = TaskRecord, TError = HttpError> {
|
|
142
|
+
params?: Record<string, unknown>;
|
|
143
|
+
dlq?: boolean;
|
|
144
|
+
taskProvider?: TaskProvider<TTask>;
|
|
145
|
+
queryOptions?: {
|
|
146
|
+
enabled?: boolean;
|
|
147
|
+
staleTime?: number;
|
|
148
|
+
gcTime?: number;
|
|
149
|
+
refetchOnWindowFocus?: boolean;
|
|
150
|
+
refetchInterval?: number | false;
|
|
151
|
+
refetchIntervalInBackground?: boolean;
|
|
152
|
+
};
|
|
153
|
+
successNotification?: NotificationConfig;
|
|
154
|
+
errorNotification?: NotificationConfig;
|
|
155
|
+
overtimeOptions?: OvertimeOptions;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function useTaskList<TTask extends TaskRecord = TaskRecord, TError = HttpError>(
|
|
159
|
+
options: UseTaskListOptions<TTask, TError> = {},
|
|
160
|
+
) {
|
|
161
|
+
const adminOptions = getAdminOptions();
|
|
162
|
+
|
|
163
|
+
const query = createQuery<TaskListResult<TTask>, TError>(() => {
|
|
164
|
+
const provider = normalizeTaskProvider<TTask>(options.taskProvider);
|
|
165
|
+
const queryOptions = options.queryOptions;
|
|
166
|
+
return {
|
|
167
|
+
queryKey: ['taskList', options.dlq ? 'dlq' : 'default', options.params],
|
|
168
|
+
queryFn: async () => {
|
|
169
|
+
if (options.dlq) {
|
|
170
|
+
if (!provider.listDlq) throw new Error('TaskProvider does not implement listDlq');
|
|
171
|
+
return provider.listDlq(options.params);
|
|
172
|
+
}
|
|
173
|
+
if (!provider.list) throw new Error('TaskProvider does not implement list');
|
|
174
|
+
return provider.list(options.params);
|
|
175
|
+
},
|
|
176
|
+
enabled: queryOptions?.enabled ?? true,
|
|
177
|
+
staleTime: queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
178
|
+
gcTime: queryOptions?.gcTime ?? adminOptions.reactQuery?.gcTime,
|
|
179
|
+
refetchOnWindowFocus: queryOptions?.refetchOnWindowFocus ?? adminOptions.reactQuery?.refetchOnWindowFocus,
|
|
180
|
+
refetchInterval: queryOptions?.refetchInterval,
|
|
181
|
+
refetchIntervalInBackground: queryOptions?.refetchIntervalInBackground,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
|
|
186
|
+
|
|
187
|
+
let lastSuccessAt = 0;
|
|
188
|
+
let lastErrorAt = 0;
|
|
189
|
+
$effect(() => {
|
|
190
|
+
if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
|
|
191
|
+
lastSuccessAt = query.dataUpdatedAt;
|
|
192
|
+
if (options.successNotification) {
|
|
193
|
+
fireSuccessNotification(options.successNotification, '', query.data, options.params, options.dlq ? 'taskDlq' : 'tasks');
|
|
194
|
+
}
|
|
195
|
+
} else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
|
|
196
|
+
lastErrorAt = query.errorUpdatedAt;
|
|
197
|
+
checkError(query.error);
|
|
198
|
+
fireErrorNotification(options.errorNotification, t('task.fetchListFailed'), query.error, options.dlq ? 'taskDlq' : 'tasks');
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return new Proxy(query, {
|
|
203
|
+
get(target, prop) {
|
|
204
|
+
if (prop === 'overtime') return overtime;
|
|
205
|
+
return target[prop as keyof typeof target];
|
|
206
|
+
},
|
|
207
|
+
}) as typeof query & { overtime: typeof overtime };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface UseTaskSubscriptionOptions<TTask extends TaskRecord = TaskRecord> {
|
|
211
|
+
taskId: string;
|
|
212
|
+
taskProvider?: TaskProvider<TTask>;
|
|
213
|
+
enabled?: boolean;
|
|
214
|
+
onTask: (task: TTask) => void;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function useTaskSubscription<TTask extends TaskRecord = TaskRecord>(
|
|
218
|
+
options: UseTaskSubscriptionOptions<TTask>,
|
|
219
|
+
): void {
|
|
220
|
+
$effect(() => {
|
|
221
|
+
const enabled = options.enabled ?? true;
|
|
222
|
+
if (!enabled) return;
|
|
223
|
+
|
|
224
|
+
const provider = normalizeTaskProvider<TTask>(options.taskProvider);
|
|
225
|
+
if (!provider.subscribe) {
|
|
226
|
+
throw new Error('TaskProvider does not implement subscribe');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const subscription = provider.subscribe(options.taskId, options.onTask);
|
|
230
|
+
if (!subscription) return;
|
|
231
|
+
|
|
232
|
+
if (typeof subscription === 'function') {
|
|
233
|
+
return subscription;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (typeof subscription.unsubscribe === 'function') {
|
|
237
|
+
return () => subscription.unsubscribe();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { flushSync } from 'svelte';
|
|
3
|
+
import { QueryClient } from '@tanstack/svelte-query';
|
|
4
|
+
import { useSubmitTask, useTask, useTaskList, useTaskSubscription } from './task-hooks.svelte';
|
|
5
|
+
|
|
6
|
+
const mockTaskProvider = {
|
|
7
|
+
submit: vi.fn(async () => ({
|
|
8
|
+
id: 'task-1',
|
|
9
|
+
wait: async () => ({ id: 'task-1', status: 'done' }),
|
|
10
|
+
})),
|
|
11
|
+
get: vi.fn(async (taskId: string) => ({ id: taskId, status: 'running' })),
|
|
12
|
+
list: vi.fn(async () => ({ data: [{ id: 'task-1', status: 'queued' }], total: 1 })),
|
|
13
|
+
listDlq: vi.fn(async () => ({ data: [{ id: 'task-2', status: 'failed' }], total: 1 })),
|
|
14
|
+
subscribe: vi.fn((_taskId: string, callback: (task: { id: string; status: string }) => void) => {
|
|
15
|
+
callback({ id: 'task-1', status: 'running' });
|
|
16
|
+
return () => {};
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
vi.mock('./context.svelte', () => ({
|
|
21
|
+
getTaskProvider: () => mockTaskProvider,
|
|
22
|
+
getAuthProvider: () => null,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('./notification.svelte', () => ({
|
|
26
|
+
notify: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
|
30
|
+
const actual = await importOriginal();
|
|
31
|
+
return {
|
|
32
|
+
...actual as any,
|
|
33
|
+
useQueryClient: () => new QueryClient(),
|
|
34
|
+
createMutation: (factory: any) => {
|
|
35
|
+
const config = factory();
|
|
36
|
+
return {
|
|
37
|
+
mutateAsync: async (vars: unknown) => config.mutationFn(vars),
|
|
38
|
+
mutate: vi.fn(),
|
|
39
|
+
isPending: false,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
createQuery: (factory: any) => {
|
|
43
|
+
const config = factory();
|
|
44
|
+
return {
|
|
45
|
+
data: config.queryKey[0] === 'taskList'
|
|
46
|
+
? { data: [{ id: 'task-1', status: 'queued' }], total: 1 }
|
|
47
|
+
: { id: 'task-1', status: 'running' },
|
|
48
|
+
isLoading: false,
|
|
49
|
+
isSuccess: true,
|
|
50
|
+
isError: false,
|
|
51
|
+
error: null,
|
|
52
|
+
dataUpdatedAt: Date.now(),
|
|
53
|
+
errorUpdatedAt: 0,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('task-hooks', () => {
|
|
60
|
+
it('useSubmitTask exposes a working mutation', async () => {
|
|
61
|
+
let submitTask: ReturnType<typeof useSubmitTask>;
|
|
62
|
+
|
|
63
|
+
const cleanup = $effect.root(() => {
|
|
64
|
+
submitTask = useSubmitTask();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
flushSync();
|
|
68
|
+
|
|
69
|
+
const handle = await submitTask!.mutation.mutateAsync({
|
|
70
|
+
taskName: 'image.generate',
|
|
71
|
+
options: { body: { prompt: 'poster' } },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(handle.id).toBe('task-1');
|
|
75
|
+
expect(mockTaskProvider.submit).toHaveBeenCalledWith('image.generate', {
|
|
76
|
+
body: { prompt: 'poster' },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('useTask returns query state for a single task', () => {
|
|
83
|
+
let taskQuery: ReturnType<typeof useTask>;
|
|
84
|
+
|
|
85
|
+
const cleanup = $effect.root(() => {
|
|
86
|
+
taskQuery = useTask({ taskId: 'task-1' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
flushSync();
|
|
90
|
+
|
|
91
|
+
expect(taskQuery!.data).toBeDefined();
|
|
92
|
+
expect((taskQuery!.data as any).status).toBe('running');
|
|
93
|
+
|
|
94
|
+
cleanup();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('useTaskList returns task collection data', () => {
|
|
98
|
+
let taskListQuery: ReturnType<typeof useTaskList>;
|
|
99
|
+
|
|
100
|
+
const cleanup = $effect.root(() => {
|
|
101
|
+
taskListQuery = useTaskList();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
flushSync();
|
|
105
|
+
|
|
106
|
+
expect(taskListQuery!.data).toBeDefined();
|
|
107
|
+
expect((taskListQuery!.data as any).data[0].id).toBe('task-1');
|
|
108
|
+
|
|
109
|
+
cleanup();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('useTaskSubscription subscribes to task updates', () => {
|
|
113
|
+
const onTask = vi.fn();
|
|
114
|
+
|
|
115
|
+
const cleanup = $effect.root(() => {
|
|
116
|
+
useTaskSubscription({
|
|
117
|
+
taskId: 'task-1',
|
|
118
|
+
onTask,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
flushSync();
|
|
123
|
+
|
|
124
|
+
expect(mockTaskProvider.subscribe).toHaveBeenCalledWith('task-1', expect.any(Function));
|
|
125
|
+
expect(onTask).toHaveBeenCalledWith({ id: 'task-1', status: 'running' });
|
|
126
|
+
|
|
127
|
+
cleanup();
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -192,6 +192,50 @@ export interface DataProvider {
|
|
|
192
192
|
custom?: <TData = unknown, TVariables = unknown>(params: CustomParams<TVariables>) => Promise<CustomResult<TData>>;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// ─── TaskProvider ─────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export interface TaskRecord extends BaseRecord {
|
|
198
|
+
id: string;
|
|
199
|
+
status?: string;
|
|
200
|
+
createdAt?: string | Date;
|
|
201
|
+
updatedAt?: string | Date;
|
|
202
|
+
error?: unknown;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface SubmitTaskOptions {
|
|
206
|
+
body?: Record<string, unknown>;
|
|
207
|
+
idempotencyKey?: string;
|
|
208
|
+
headers?: Record<string, string>;
|
|
209
|
+
meta?: Record<string, unknown>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface TaskSubscription {
|
|
213
|
+
unsubscribe(): void;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface TaskHandle<TTask extends TaskRecord = TaskRecord> {
|
|
217
|
+
id?: string;
|
|
218
|
+
wait(): Promise<TTask>;
|
|
219
|
+
subscribe?(callback: (task: TTask) => void): TaskSubscription | (() => void) | void;
|
|
220
|
+
cancel?(): Promise<unknown>;
|
|
221
|
+
retry?(): Promise<unknown>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface TaskListResult<TTask extends TaskRecord = TaskRecord> {
|
|
225
|
+
data: TTask[];
|
|
226
|
+
total?: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface TaskProvider<TTask extends TaskRecord = TaskRecord> {
|
|
230
|
+
submit(taskName: string, options?: SubmitTaskOptions): Promise<TaskHandle<TTask>>;
|
|
231
|
+
get(taskId: string): Promise<TTask>;
|
|
232
|
+
list?(params?: Record<string, unknown>): Promise<TaskListResult<TTask>>;
|
|
233
|
+
listDlq?(params?: Record<string, unknown>): Promise<TaskListResult<TTask>>;
|
|
234
|
+
cancel?(taskId: string): Promise<unknown>;
|
|
235
|
+
retry?(taskId: string): Promise<unknown>;
|
|
236
|
+
subscribe?(taskId: string, callback: (task: TTask) => void): TaskSubscription | (() => void) | void;
|
|
237
|
+
}
|
|
238
|
+
|
|
195
239
|
// ─── AuthProvider ─────────────────────────────────────────────
|
|
196
240
|
|
|
197
241
|
export interface Identity {
|