@svadmin/core 0.53.0 → 0.55.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.
@@ -1,12 +1,15 @@
1
1
  import { captureAdminContext } from './context.svelte';
2
- import { definedOptions } from './defined-options';
3
- import { HttpError } from './types';
2
+ import { definedOptions, definedReactiveOptions } from './defined-options';
3
+ import { HttpError, type TaskProvider, type TaskRecord } from './types';
4
+ import { untrack } from 'svelte';
5
+ import { captureAuthLiveScope } from './auth-hooks.svelte';
4
6
  import { contractKey, parseContractRecord, type ResourceContract, type ContractSchemas, type ContractRecord } from './resource-contract';
5
7
  import type { ContractFilter, ContractSort } from './strict-hooks.svelte';
6
8
  import { captureQueryProvider, snapshotListParams } from './query-snapshot';
7
9
  import { decodeBaseRecord, decodeListResult } from './record-decoder';
8
10
  import { snapshotPlainData } from './plain-data';
9
- import { downloadData, type ExportFormat } from './export-format';
11
+ import { downloadData, downloadExportArtifact, snapshotExportTaskResult, safeArtifactUrl, type ExportFormat } from './export-format';
12
+ import { useSubmitTask, useTask } from './task-hooks.svelte';
10
13
 
11
14
  export interface UseExportOptions<S extends ContractSchemas> {
12
15
  resource: ResourceContract<S>;
@@ -19,6 +22,12 @@ export interface UseExportOptions<S extends ContractSchemas> {
19
22
  format?: ExportFormat;
20
23
  meta?: Record<string, unknown>;
21
24
  dataProviderName?: string;
25
+ enabled?: boolean;
26
+ taskName?: string;
27
+ taskProvider?: TaskProvider;
28
+ taskIdempotencyKey?: string;
29
+ initialTaskId?: string;
30
+ onTaskSubmitted?: (taskId: string) => void | Promise<void>;
22
31
  onError?: (error: HttpError) => void | Promise<void>;
23
32
  }
24
33
 
@@ -53,6 +62,10 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
53
62
  let isLoading = $state(false);
54
63
  let error = $state<HttpError | null>(null);
55
64
  let mounted = true;
65
+ let taskId = $state<string | undefined>(untrack(() => options.initialTaskId));
66
+ let taskHandledId = $state<string | undefined>();
67
+ const submitTask = useSubmitTask();
68
+ const taskMode = $derived(options.taskName !== undefined);
56
69
 
57
70
  function captureScope() {
58
71
  const contract = options.resource;
@@ -63,16 +76,52 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
63
76
  });
64
77
  return {
65
78
  contract, ...captured,
79
+ authProvider: context.authProvider, auth: captureAuthLiveScope(context.authProvider),
80
+ router: context.routerProvider, access: context.accessControlProvider,
81
+ taskProvider: options.taskProvider ?? context.taskProvider,
82
+ taskName: options.taskName, idempotencyKey: options.taskIdempotencyKey, format: options.format ?? 'csv',
83
+ taskQueryKey: options.taskName === undefined ? undefined : JSON.stringify(snapshotPlainData({
84
+ filters: options.filters ?? [], sorters: options.sorters ?? [],
85
+ maxItemCount: options.maxItemCount ?? null,
86
+ })),
66
87
  key: JSON.stringify([key, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta]),
67
88
  };
68
89
  }
69
90
  type Scope = ReturnType<typeof captureScope>;
91
+ let taskOrigin = $state.raw<Scope | undefined>(
92
+ untrack(() => {
93
+ try { return taskId && taskMode ? captureScope() : undefined; }
94
+ catch { return undefined; }
95
+ }),
96
+ );
97
+ let taskPolling = $state(true);
70
98
  let current: { token: object; scope: Scope; promise: Promise<ContractRecord<S>[]> } | undefined;
99
+ const taskProvider = $derived(options.taskProvider ?? context.taskProvider);
100
+ const ownedTaskId = $derived(taskOrigin && scopeCurrent(taskOrigin) ? taskId : undefined);
101
+ const taskQuery = useTask(definedReactiveOptions({
102
+ get taskId() { return ownedTaskId; },
103
+ get taskProvider() { return taskProvider; },
104
+ get queryOptions() {
105
+ return { enabled: taskMode && !!ownedTaskId && !!taskProvider,
106
+ refetchInterval: ownedTaskId && taskPolling ? 2000 : false as const };
107
+ },
108
+ }));
109
+
110
+ function scopeCurrent(scope: Scope, requireEnabled = true): boolean {
111
+ if (!mounted || (requireEnabled && options.enabled === false) || !scope.auth.isCurrent()) return false;
112
+ try {
113
+ const next = captureScope();
114
+ return next.key === scope.key && next.authProvider === scope.authProvider &&
115
+ next.router === scope.router && next.access === scope.access &&
116
+ next.taskName === scope.taskName &&
117
+ (scope.taskName === undefined || (next.taskProvider === scope.taskProvider &&
118
+ next.taskName === scope.taskName && next.idempotencyKey === scope.idempotencyKey &&
119
+ next.format === scope.format && next.taskQueryKey === scope.taskQueryKey));
120
+ } catch { return false; }
121
+ }
71
122
 
72
123
  function isActive(token: object, scope: Scope): boolean {
73
- if (!mounted || current?.token !== token) return false;
74
- try { return captureScope().key === scope.key; }
75
- catch { return false; }
124
+ return current?.token === token && scopeCurrent(scope);
76
125
  }
77
126
 
78
127
  let initialized = false;
@@ -89,6 +138,23 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
89
138
  previousScope = nextScope;
90
139
  initialized = true;
91
140
  });
141
+ let wasEnabled = untrack(() => options.enabled !== false);
142
+ $effect.pre(() => {
143
+ const enabled = options.enabled !== false;
144
+ const revoked = wasEnabled && !enabled;
145
+ wasEnabled = enabled;
146
+ if (current && !scopeCurrent(current.scope)) {
147
+ current = undefined;
148
+ isLoading = false;
149
+ error = null;
150
+ }
151
+ if (taskOrigin && (revoked || !scopeCurrent(taskOrigin, false))) {
152
+ taskOrigin = undefined;
153
+ taskId = undefined;
154
+ taskHandledId = undefined;
155
+ error = null;
156
+ }
157
+ });
92
158
  $effect(() => () => {
93
159
  mounted = false;
94
160
  current = undefined;
@@ -106,12 +172,54 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
106
172
  }
107
173
  }
108
174
 
175
+ function taskStatus(value: TaskRecord | undefined): 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'unknown' {
176
+ const status = String(value?.status ?? '').trim().toLowerCase();
177
+ if (['pending', 'queued', 'scheduled', 'retry_scheduled'].includes(status)) return 'queued';
178
+ if (['running', 'processing', 'active', 'leased'].includes(status)) return 'processing';
179
+ if (['completed', 'complete', 'success', 'succeeded', 'done'].includes(status)) return 'completed';
180
+ if (['failed', 'error', 'dead_lettered'].includes(status)) return 'failed';
181
+ if (['cancelled', 'canceled', 'aborted'].includes(status)) return 'cancelled';
182
+ return 'unknown';
183
+ }
184
+
185
+ const artifact = $derived.by(() => {
186
+ const task = taskQuery.data;
187
+ if (!ownedTaskId || !task || taskStatus(task) !== 'completed') return undefined;
188
+ const result = snapshotExportTaskResult(task.result ?? task.result_data);
189
+ return result && result.format === taskOrigin?.format && safeArtifactUrl(result.downloadUrl) ? result : undefined;
190
+ });
191
+
192
+ $effect(() => {
193
+ const task = taskQuery.data;
194
+ if (!taskMode || !task || !ownedTaskId || taskHandledId === ownedTaskId) return;
195
+ const status = taskStatus(task);
196
+ if (status === 'queued' || status === 'processing' || status === 'unknown') return;
197
+ taskHandledId = ownedTaskId;
198
+ taskPolling = false;
199
+ if (status === 'completed' && !artifact) report(new HttpError(
200
+ 'Invalid export task result', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' }), options.onError);
201
+ else if (status === 'failed') report(exportFailure(undefined), options.onError);
202
+ });
203
+
204
+ function downloadTask(): boolean {
205
+ if (!artifact || !taskOrigin || !scopeCurrent(taskOrigin)) return false;
206
+ try {
207
+ downloadExportArtifact(artifact, taskOrigin.contract.name);
208
+ return true;
209
+ } catch {
210
+ report(new HttpError('Invalid export artifact', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' }), options.onError);
211
+ return false;
212
+ }
213
+ }
214
+
109
215
  function triggerExport(): Promise<ContractRecord<S>[]> {
110
216
  if (!mounted) return Promise.reject(cancelled());
111
217
  let onError: UseExportOptions<S>['onError'];
112
218
  try {
113
219
  const scope = captureScope();
114
- if (current?.scope.key === scope.key) return current.promise;
220
+ if (!scopeCurrent(scope)) return Promise.reject(cancelled());
221
+ if (current && scopeCurrent(current.scope)) return current.promise;
222
+ if (scope.taskName !== undefined && ownedTaskId) return Promise.resolve([]);
115
223
  previousScope = scope.key;
116
224
  initialized = true;
117
225
  const batchSize = options.pageSize ?? 20;
@@ -119,6 +227,10 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
119
227
  const format = options.format ?? 'csv';
120
228
  const download = options.download ?? true;
121
229
  const mapData = options.mapData;
230
+ const taskName = scope.taskName;
231
+ const taskSource = scope.taskProvider;
232
+ const idempotencyKey = scope.idempotencyKey;
233
+ const onTaskSubmitted = options.onTaskSubmitted;
122
234
  onError = options.onError;
123
235
  if (!Number.isSafeInteger(batchSize) || batchSize <= 0 ||
124
236
  (maxItems !== Infinity && (!Number.isSafeInteger(maxItems) || maxItems < 0)) ||
@@ -141,6 +253,34 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
141
253
  const promise = Promise.resolve().then(async () => {
142
254
  try {
143
255
  ensureActive();
256
+ if (taskName !== undefined) {
257
+ if (!taskName.trim() || !taskSource || !idempotencyKey?.trim() || mapData !== undefined) invalidInput();
258
+ const task = await submitTask.mutation.mutateAsync({
259
+ taskName: taskName.trim(),
260
+ taskProvider: taskSource,
261
+ successNotification: false,
262
+ errorNotification: false,
263
+ options: {
264
+ idempotencyKey,
265
+ body: {
266
+ protocolVersion: 1,
267
+ resource: scope.contract.name,
268
+ format,
269
+ filters: params.filters ?? [],
270
+ sorters: params.sorters ?? [],
271
+ ...definedOptions(maxItems === Infinity ? {} : { maxItemCount: maxItems }),
272
+ },
273
+ ...definedOptions(scope.meta === undefined ? {} : { meta: scope.meta }),
274
+ },
275
+ });
276
+ ensureActive();
277
+ taskId = task.id;
278
+ taskOrigin = scope;
279
+ taskHandledId = undefined;
280
+ taskPolling = true;
281
+ try { void Promise.resolve(onTaskSubmitted?.(task.id)).catch(() => {}); } catch { /* Observer failures do not re-submit. */ }
282
+ return [];
283
+ }
144
284
  const records: ContractRecord<S>[] = [];
145
285
  let page = 1;
146
286
  while (records.length < maxItems) {
@@ -187,5 +327,26 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
187
327
  }
188
328
  }
189
329
 
190
- return { triggerExport, get isLoading() { return isLoading; }, get error() { return error; } };
330
+ return {
331
+ triggerExport,
332
+ get isLoading() { return isLoading; },
333
+ get error() { return error; },
334
+ get taskId() { return ownedTaskId; },
335
+ get task() { return ownedTaskId ? taskQuery.data : undefined; },
336
+ get artifact() { return artifact; },
337
+ get taskError() { return ownedTaskId ? taskQuery.error : null; },
338
+ get isTaskPending() {
339
+ return !!ownedTaskId && !['completed', 'failed', 'cancelled'].includes(taskStatus(taskQuery.data));
340
+ },
341
+ get refetchTask() {
342
+ const origin = taskOrigin;
343
+ const id = ownedTaskId;
344
+ const refetch = taskQuery.refetch;
345
+ return () => {
346
+ if (!origin || !id || id !== ownedTaskId || !scopeCurrent(origin)) return Promise.reject(cancelled());
347
+ return refetch();
348
+ };
349
+ },
350
+ downloadTask,
351
+ };
191
352
  }
@@ -0,0 +1,42 @@
1
+ import type { CrudOperator, FieldDefinition } from './types';
2
+
3
+ export const FILTER_COLLECTION_LIMIT = 100;
4
+
5
+ export function isNumericFilterField(field: FieldDefinition | undefined): boolean {
6
+ return field !== undefined && ['number', 'currency', 'percent', 'rating', 'rate'].includes(field.type);
7
+ }
8
+
9
+ export function isCollectionFilterOperator(operator: string): boolean {
10
+ return ['in', 'nin', 'between', 'nbetween'].includes(operator);
11
+ }
12
+
13
+ export function filterOperatorsForField(field: FieldDefinition | undefined): CrudOperator[] {
14
+ if (!field || field.filterable === false) return [];
15
+ if (isNumericFilterField(field)) {
16
+ return ['eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'in', 'nin', 'between', 'nbetween', 'null', 'nnull'];
17
+ }
18
+ if (field.type === 'boolean' || field.type === 'select') return ['eq', 'ne', 'in', 'nin', 'null', 'nnull'];
19
+ if (['date', 'time', 'datetime'].includes(field.type)) return ['eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'null', 'nnull'];
20
+ return ['eq', 'ne', 'contains', 'ncontains', 'startswith', 'endswith', 'lt', 'gt', 'lte', 'gte', 'in', 'nin', 'null', 'nnull'];
21
+ }
22
+
23
+ export function isFilterScalarValue(field: FieldDefinition, value: unknown): boolean {
24
+ if (isNumericFilterField(field)) return typeof value === 'number' && Number.isFinite(value);
25
+ if (field.type === 'boolean') return typeof value === 'boolean';
26
+ if (field.type === 'select') return field.options?.some(option => !option.disabled && option.value === value) === true;
27
+ return typeof value === 'string';
28
+ }
29
+
30
+ /** 不排序、不去重、不强制转换;非法集合不能退化为无筛选查询。 */
31
+ export function isFilterCollectionValue(field: FieldDefinition | undefined, operator: string, value: unknown): boolean {
32
+ if (!field || !filterOperatorsForField(field).includes(operator as CrudOperator)
33
+ || !isCollectionFilterOperator(operator) || !Array.isArray(value)
34
+ || value.length < 1 || value.length > FILTER_COLLECTION_LIMIT) return false;
35
+ for (let index = 0; index < value.length; index++) {
36
+ if (!Object.hasOwn(value, index) || !isFilterScalarValue(field, value[index])) return false;
37
+ }
38
+ if (operator === 'between' || operator === 'nbetween') {
39
+ return value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number' && value[0] <= value[1];
40
+ }
41
+ return true;
42
+ }
package/src/gantt.ts ADDED
@@ -0,0 +1,84 @@
1
+ export interface GanttTaskShape {
2
+ id: string;
3
+ title: string;
4
+ startDay: number;
5
+ durationDays: number;
6
+ progress?: number;
7
+ status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
8
+ dependencies?: string[];
9
+ milestone?: boolean;
10
+ }
11
+
12
+ export type GanttValidationError = 'invalidData' | 'limit' | 'dependency';
13
+
14
+ export const GANTT_LIMITS = Object.freeze({
15
+ tasks: 1000,
16
+ days: 366,
17
+ cells: 40_000,
18
+ dependencies: 10_000,
19
+ });
20
+
21
+ export function validateGantt<T extends GanttTaskShape>(
22
+ tasks: readonly T[],
23
+ totalDays: number,
24
+ ): GanttValidationError | undefined {
25
+ if (!Array.isArray(tasks) || !Number.isSafeInteger(totalDays) || totalDays < 1 || totalDays > GANTT_LIMITS.days) {
26
+ return 'invalidData';
27
+ }
28
+ if (tasks.length > GANTT_LIMITS.tasks || tasks.length * totalDays > GANTT_LIMITS.cells) return 'limit';
29
+ const ids = new Set<string>();
30
+ const byId = new Map<string, T>();
31
+ let edgeCount = 0;
32
+ for (const task of tasks) {
33
+ if (!task || typeof task.id !== 'string' || !task.id.trim() || ids.has(task.id)
34
+ || typeof task.title !== 'string' || !task.title.trim()
35
+ || task.id.length > 1000 || task.title.length > 1000
36
+ || !Number.isSafeInteger(task.startDay) || task.startDay < 0
37
+ || !Number.isSafeInteger(task.durationDays)
38
+ || (task.milestone === true ? task.durationDays !== 0 || task.startDay >= totalDays : task.durationDays < 1)
39
+ || task.startDay + task.durationDays > totalDays
40
+ || (task.progress !== undefined && (typeof task.progress !== 'number' || !Number.isFinite(task.progress) || task.progress < 0 || task.progress > 100))
41
+ || (task.status !== undefined && !['planned', 'in_progress', 'completed', 'delayed'].includes(task.status))) {
42
+ return 'invalidData';
43
+ }
44
+ if ((task.milestone !== undefined && typeof task.milestone !== 'boolean')
45
+ || (task.dependencies !== undefined && (!Array.isArray(task.dependencies)
46
+ || task.dependencies.length > GANTT_LIMITS.tasks
47
+ || new Set(task.dependencies).size !== task.dependencies.length
48
+ || task.dependencies.some((dependency: unknown) => typeof dependency !== 'string' || !dependency.trim() || dependency.length > 1000)))) {
49
+ return 'invalidData';
50
+ }
51
+ edgeCount += task.dependencies?.length ?? 0;
52
+ if (edgeCount > GANTT_LIMITS.dependencies) return 'limit';
53
+ ids.add(task.id);
54
+ byId.set(task.id, task);
55
+ }
56
+ const successors = new Map<string, string[]>();
57
+ const remaining = new Map<string, number>();
58
+ const ready: string[] = [];
59
+ for (const task of tasks) {
60
+ remaining.set(task.id, task.dependencies?.length ?? 0);
61
+ if (!task.dependencies?.length) ready.push(task.id);
62
+ for (const dependency of task.dependencies ?? []) {
63
+ const parent = byId.get(dependency);
64
+ if (!parent) return 'dependency';
65
+ if (parent.startDay + parent.durationDays > task.startDay) return 'dependency';
66
+ const children = successors.get(dependency) ?? [];
67
+ children.push(task.id);
68
+ successors.set(dependency, children);
69
+ }
70
+ }
71
+ // 有界迭代拓扑检查,零时长里程碑成环也必须被拒绝。
72
+ for (let index = 0; index < ready.length; index++) {
73
+ const id = ready[index];
74
+ if (id === undefined) return 'dependency';
75
+ for (const child of successors.get(id) ?? []) {
76
+ const pending = remaining.get(child);
77
+ if (pending === undefined) return 'dependency';
78
+ const count = pending - 1;
79
+ remaining.set(child, count);
80
+ if (count === 0) ready.push(child);
81
+ }
82
+ }
83
+ return ready.length === tasks.length ? undefined : 'dependency';
84
+ }