@svadmin/core 0.49.1 → 0.52.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/README.md +32 -0
- package/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -774
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -28
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -286
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -103
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
2
|
+
import { captureAdminContext } from './context.svelte';
|
|
3
|
+
import { captureQueryProvider } from './query-snapshot';
|
|
4
|
+
import { snapshotCreateManyParams, parseCreateManyParams, decodeCreateManyReceipt, suppliedCreateId, CreateManyPartialError,
|
|
5
|
+
type ContractCreateManyParams } from './create-many-contract';
|
|
6
|
+
import { contractKey, parseContractRecord, parseContractId, type ContractSchemas, type ContractRecord, type ResourceContract } from './resource-contract';
|
|
7
|
+
import { snapshotPlainData } from './plain-data';
|
|
8
|
+
import { decodeBaseRecord } from './record-decoder';
|
|
9
|
+
import { definedOptions } from './defined-options';
|
|
10
|
+
import { replaceReactiveUnionMembers } from './reactive-projection';
|
|
11
|
+
import { invalidateOwnedQueries } from './query-invalidation';
|
|
12
|
+
import { createQueryResultView } from './query-session.svelte';
|
|
13
|
+
import { createOvertimeTracker, type OvertimeOptions } from './hook-utils.svelte';
|
|
14
|
+
import { getAdminOptions } from './options.svelte';
|
|
15
|
+
import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
|
|
16
|
+
import { auditWithProvider } from './audit';
|
|
17
|
+
import { HttpError } from './types';
|
|
18
|
+
import { useTranslation } from './i18n.svelte';
|
|
19
|
+
import { toast } from './toast.svelte';
|
|
20
|
+
|
|
21
|
+
export interface ContractCreateManyOptions<S extends ContractSchemas> {
|
|
22
|
+
resource: ResourceContract<S>;
|
|
23
|
+
enabled?: boolean;
|
|
24
|
+
overtimeOptions?: OvertimeOptions;
|
|
25
|
+
}
|
|
26
|
+
type Result<S extends ContractSchemas> = { data: ContractRecord<S>[] };
|
|
27
|
+
type Failure = HttpError | CreateManyPartialError;
|
|
28
|
+
export interface Callbacks<S extends ContractSchemas> {
|
|
29
|
+
onSuccess?: (data: Result<S>) => void | Promise<void>;
|
|
30
|
+
onError?: (error: Failure) => void | Promise<void>;
|
|
31
|
+
onSettled?: (data: Result<S> | undefined, error: Failure | null) => void | Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
function observe(callback: () => void | Promise<void>): void {
|
|
34
|
+
try { void Promise.resolve(callback()).catch(() => {}); }
|
|
35
|
+
catch { /* Observers cannot change a completed write's outcome. */ }
|
|
36
|
+
}
|
|
37
|
+
function failure(cause: unknown, dispatched = false): HttpError {
|
|
38
|
+
let code: unknown;
|
|
39
|
+
let status: unknown;
|
|
40
|
+
try {
|
|
41
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
42
|
+
code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
|
|
43
|
+
status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
|
|
44
|
+
}
|
|
45
|
+
} catch { /* Reflection errors remain private. */ }
|
|
46
|
+
const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
|
|
47
|
+
const contract = code === 'INVALID_RESOURCE_CONTRACT' && !dispatched;
|
|
48
|
+
const response = code === 'INVALID_PROVIDER_RESPONSE';
|
|
49
|
+
return new HttpError(input ? 'Invalid batch create input' : contract ? 'A create schema is required'
|
|
50
|
+
: response ? 'Invalid provider response' : 'Batch create failed',
|
|
51
|
+
input ? 422 : contract ? 400 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
|
|
52
|
+
code: input ? 'INVALID_RESOURCE_INPUT' : contract ? 'INVALID_RESOURCE_CONTRACT'
|
|
53
|
+
: response ? 'INVALID_PROVIDER_RESPONSE' : 'CREATE_MANY_FAILED',
|
|
54
|
+
details: { writeMayHaveSucceeded: dispatched },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function copyFailure(error: Failure): Failure {
|
|
58
|
+
if (error instanceof CreateManyPartialError) {
|
|
59
|
+
return new CreateManyPartialError(error.succeeded, error.failedIndexes, error.causes.map(cause => failure(cause, true)));
|
|
60
|
+
}
|
|
61
|
+
return new HttpError(error.message, error.statusCode, undefined, {
|
|
62
|
+
...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Batch progress and completion remain owned by one resource and auth revision. */
|
|
67
|
+
export function useContractCreateMany<S extends ContractSchemas>(options: ContractCreateManyOptions<S>) {
|
|
68
|
+
contractKey(options.resource);
|
|
69
|
+
const context = captureAdminContext();
|
|
70
|
+
const client = useQueryClient();
|
|
71
|
+
const adminOptions = getAdminOptions();
|
|
72
|
+
const i18n = useTranslation();
|
|
73
|
+
let mounted = true;
|
|
74
|
+
function captureOrigin() {
|
|
75
|
+
const auth = context.authProvider;
|
|
76
|
+
return {
|
|
77
|
+
auth, authScope: captureAuthLiveScope(auth), router: context.routerProvider,
|
|
78
|
+
tenant: context.tenantCacheKey?.__svadminTenant, contract: options.resource,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function originCurrent(origin: ReturnType<typeof captureOrigin>): boolean {
|
|
82
|
+
try {
|
|
83
|
+
return mounted && options.enabled !== false && origin.auth === context.authProvider &&
|
|
84
|
+
origin.authScope.isCurrent() && origin.router === context.routerProvider &&
|
|
85
|
+
origin.tenant === context.tenantCacheKey?.__svadminTenant && origin.contract === options.resource;
|
|
86
|
+
} catch { return false; }
|
|
87
|
+
}
|
|
88
|
+
function captureScope(input: ReturnType<typeof snapshotCreateManyParams>) {
|
|
89
|
+
const origin = captureOrigin();
|
|
90
|
+
const contract = origin.contract;
|
|
91
|
+
const captured = captureQueryProvider(context, {
|
|
92
|
+
resource: contract.name, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }),
|
|
93
|
+
});
|
|
94
|
+
const name = context.resolveDataProviderName(contract.name, input.dataProviderName);
|
|
95
|
+
const raw = context.providers?.[name];
|
|
96
|
+
if (!raw) throw new HttpError('Data provider unavailable', 400);
|
|
97
|
+
return {
|
|
98
|
+
...origin, ...captured, create: raw.create.bind(raw), createMany: raw.createMany?.bind(raw),
|
|
99
|
+
matcher: { ...context.queryKeyMatcher(contract.name, input.dataProviderName), contract: contractKey(contract) },
|
|
100
|
+
notification: context.notificationProvider, audit: context.auditLogProvider, live: context.liveProvider,
|
|
101
|
+
key: JSON.stringify([contractKey(contract), name, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta]),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function capture(params: ContractCreateManyParams<S>) {
|
|
105
|
+
const input = snapshotCreateManyParams(params);
|
|
106
|
+
const scope = captureScope(input);
|
|
107
|
+
const { variables } = parseCreateManyParams(scope.contract, input);
|
|
108
|
+
if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
|
|
109
|
+
throw new HttpError('Invalid enabled value', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
110
|
+
}
|
|
111
|
+
return { ...scope, input, variables, signature: JSON.stringify([scope.key, scope.authScope.cacheKey, variables]) };
|
|
112
|
+
}
|
|
113
|
+
type Scope = ReturnType<typeof capture>;
|
|
114
|
+
type Progress = {
|
|
115
|
+
attempted: number[]; succeeded: { index: number; record: ContractRecord<S> }[]; failed: number[];
|
|
116
|
+
};
|
|
117
|
+
const newProgress = (): Progress => ({ attempted: [], succeeded: [], failed: [] });
|
|
118
|
+
type Invocation = {
|
|
119
|
+
scope: Scope | undefined; origin: ReturnType<typeof captureOrigin>;
|
|
120
|
+
token: object; callbacks: Callbacks<S>; readScope(): Scope; readProgress(): Progress;
|
|
121
|
+
};
|
|
122
|
+
let latest = $state.raw<Scope>();
|
|
123
|
+
let latestToken: object | undefined;
|
|
124
|
+
let active: { scope: Scope; promise: Promise<Result<S>>; token: object; progress: Progress } | undefined;
|
|
125
|
+
function targetCurrent(scope: Scope): boolean {
|
|
126
|
+
if (!mounted || options.enabled === false) return false;
|
|
127
|
+
try {
|
|
128
|
+
return captureScope(scope.input).key === scope.key &&
|
|
129
|
+
context.authProvider === scope.auth && context.routerProvider === scope.router;
|
|
130
|
+
} catch { return false; }
|
|
131
|
+
}
|
|
132
|
+
const sessionCurrent = (scope: Scope) => {
|
|
133
|
+
try { return context.authProvider === scope.auth && scope.authScope.isCurrent(); }
|
|
134
|
+
catch { return false; }
|
|
135
|
+
};
|
|
136
|
+
const isCurrent = (scope: Scope) => targetCurrent(scope) && sessionCurrent(scope);
|
|
137
|
+
function cancelled(scope: Scope | undefined, progress: Progress): HttpError {
|
|
138
|
+
return new HttpError('Batch create scope changed', 409, undefined, {
|
|
139
|
+
code: 'CREATE_MANY_CANCELLED', details: {
|
|
140
|
+
writeMayHaveSucceeded: progress.attempted.length > 0,
|
|
141
|
+
attemptedIndexes: [...progress.attempted], failedIndexes: [...progress.failed],
|
|
142
|
+
...definedOptions({
|
|
143
|
+
succeeded: scope && sessionCurrent(scope) ? snapshotPlainData(progress.succeeded) : undefined,
|
|
144
|
+
unattemptedIndexes: scope?.variables.map((_, index) => index).filter(index => !progress.attempted.includes(index)),
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function copyResult(scope: Scope, data: Result<S>): Result<S> {
|
|
150
|
+
return { data: data.data.map(record => parseContractRecord(scope.contract, record)) };
|
|
151
|
+
}
|
|
152
|
+
function notify(scope: Scope, current: () => boolean, type: 'success' | 'error', message: string): void {
|
|
153
|
+
observe(() => {
|
|
154
|
+
if (!current()) return;
|
|
155
|
+
if (scope.notification) return scope.notification.open({ type, message });
|
|
156
|
+
if (type === 'success') toast.success(message);
|
|
157
|
+
else toast.error(message);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function refresh(scope: Scope, attempted: readonly number[], ids: ReadonlySet<string | number>) {
|
|
161
|
+
if (!attempted.length) return;
|
|
162
|
+
const owner = {
|
|
163
|
+
client, matcher: { ...scope.matcher, resource: scope.contract.name },
|
|
164
|
+
owner: { source: scope.source, authSession: scope.authScope.cacheKey },
|
|
165
|
+
current: () => sessionCurrent(scope),
|
|
166
|
+
};
|
|
167
|
+
// Every started collection/detail refresh settles even if a sibling fails or retires.
|
|
168
|
+
await Promise.allSettled([
|
|
169
|
+
invalidateOwnedQueries({ ...owner, scopes: ['list', 'many'] }),
|
|
170
|
+
...[...ids].map(id => invalidateOwnedQueries({ ...owner, scopes: ['detail'], id })),
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
const mutation = createMutation<Result<S>, Failure, Invocation>(() => ({
|
|
174
|
+
retry: false, onMutate: () => undefined, onSuccess: () => {}, onError: () => {}, onSettled: () => {},
|
|
175
|
+
mutationFn: async invocation => {
|
|
176
|
+
let scope: Scope;
|
|
177
|
+
try { scope = invocation.readScope(); }
|
|
178
|
+
catch (cause) {
|
|
179
|
+
const error = failure(cause);
|
|
180
|
+
const current = () => latestToken === invocation.token && originCurrent(invocation.origin);
|
|
181
|
+
if (!current()) throw cancelled(undefined, invocation.readProgress());
|
|
182
|
+
observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error)); });
|
|
183
|
+
observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error)); });
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
const progress = invocation.readProgress();
|
|
187
|
+
const { attempted, succeeded, failed } = progress;
|
|
188
|
+
const causes: HttpError[] = [];
|
|
189
|
+
const seen = new Set<string | number>();
|
|
190
|
+
const details = new Set<string | number>();
|
|
191
|
+
let rejected: Failure | undefined;
|
|
192
|
+
const current = () => isCurrent(scope) && latestToken === invocation.token;
|
|
193
|
+
const ensureCurrent = () => {
|
|
194
|
+
if (!current() || active?.token !== invocation.token) throw cancelled(scope, progress);
|
|
195
|
+
};
|
|
196
|
+
const request = () => ({
|
|
197
|
+
resource: scope.contract.name,
|
|
198
|
+
...definedOptions({ meta: scope.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.meta)) }),
|
|
199
|
+
});
|
|
200
|
+
const attempt = (input: unknown, index: number) => {
|
|
201
|
+
const id = suppliedCreateId(scope.contract, input);
|
|
202
|
+
if (id !== undefined) details.add(id);
|
|
203
|
+
attempted.push(index);
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
ensureCurrent();
|
|
207
|
+
if (scope.createMany) {
|
|
208
|
+
const params = {
|
|
209
|
+
...request(), variables: scope.variables.map(value => snapshotPlainData(value)),
|
|
210
|
+
};
|
|
211
|
+
scope.variables.forEach(attempt);
|
|
212
|
+
const response = await scope.createMany(params);
|
|
213
|
+
if (!sessionCurrent(scope)) throw cancelled(scope, progress);
|
|
214
|
+
const records = decodeCreateManyReceipt(scope.contract, scope.variables, response, true, seen);
|
|
215
|
+
records.forEach((record, index) => {
|
|
216
|
+
succeeded.push({ index, record });
|
|
217
|
+
details.add(parseContractId(scope.contract, record.id));
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
for (const [index, input] of scope.variables.entries()) {
|
|
221
|
+
ensureCurrent();
|
|
222
|
+
const params = { ...request(), variables: snapshotPlainData(input) };
|
|
223
|
+
attempt(input, index);
|
|
224
|
+
try {
|
|
225
|
+
const response = await scope.create(params);
|
|
226
|
+
if (!sessionCurrent(scope)) throw cancelled(scope, progress);
|
|
227
|
+
for (const record of decodeCreateManyReceipt(scope.contract, [input], response, false, seen)) {
|
|
228
|
+
const id = parseContractId(scope.contract, record.id);
|
|
229
|
+
succeeded.push({ index, record });
|
|
230
|
+
seen.add(id);
|
|
231
|
+
details.add(id);
|
|
232
|
+
}
|
|
233
|
+
} catch (cause) {
|
|
234
|
+
if (!sessionCurrent(scope)) throw cancelled(scope, progress);
|
|
235
|
+
failed.push(index);
|
|
236
|
+
causes.push(failure(cause, true));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (failed.length) rejected = new CreateManyPartialError(succeeded, failed, causes);
|
|
240
|
+
}
|
|
241
|
+
} catch (cause) {
|
|
242
|
+
rejected = failure(cause, attempted.length > 0);
|
|
243
|
+
} finally {
|
|
244
|
+
for (const { record } of succeeded) {
|
|
245
|
+
if (!sessionCurrent(scope)) break;
|
|
246
|
+
auditWithProvider({ action: 'create', resource: scope.contract.name,
|
|
247
|
+
recordId: parseContractId(scope.contract, record.id), ...definedOptions({ meta: scope.meta }) },
|
|
248
|
+
scope.audit, () => sessionCurrent(scope));
|
|
249
|
+
}
|
|
250
|
+
if (succeeded.length) observe(() => {
|
|
251
|
+
if (sessionCurrent(scope)) return scope.live?.publish?.({
|
|
252
|
+
type: 'INSERT', resource: scope.contract.name,
|
|
253
|
+
payload: { ids: succeeded.map(({ record }) => parseContractId(scope.contract, record.id)) },
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
if (sessionCurrent(scope)) await refresh(scope, attempted, details);
|
|
257
|
+
}
|
|
258
|
+
ensureCurrent();
|
|
259
|
+
if (rejected) {
|
|
260
|
+
const error = rejected;
|
|
261
|
+
const authError = error instanceof CreateManyPartialError
|
|
262
|
+
? error.causes.find(cause => cause.statusCode === 401 || cause.statusCode === 403) ?? error.causes[0] ?? error
|
|
263
|
+
: error;
|
|
264
|
+
observe(async () => {
|
|
265
|
+
await handleAuthError(failure(authError, true), context,
|
|
266
|
+
() => targetCurrent(scope) && latestToken === invocation.token,
|
|
267
|
+
() => clearAuthQueries(client, scope.auth), scope.authScope,
|
|
268
|
+
() => mounted && latestToken !== invocation.token);
|
|
269
|
+
});
|
|
270
|
+
notify(scope, current, 'error', i18n.t('common.operationFailed'));
|
|
271
|
+
observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error)); });
|
|
272
|
+
observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error)); });
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
const result = { data: succeeded.map(({ record }) => record) };
|
|
276
|
+
notify(scope, current, 'success', i18n.t('common.createSuccess'));
|
|
277
|
+
observe(() => { if (current()) return invocation.callbacks.onSuccess?.(copyResult(scope, result)); });
|
|
278
|
+
observe(() => { if (current()) return invocation.callbacks.onSettled?.(copyResult(scope, result), null); });
|
|
279
|
+
ensureCurrent();
|
|
280
|
+
return copyResult(scope, result);
|
|
281
|
+
},
|
|
282
|
+
}));
|
|
283
|
+
$effect.pre(() => {
|
|
284
|
+
if (latest && !isCurrent(latest)) {
|
|
285
|
+
latest = undefined;
|
|
286
|
+
active = undefined;
|
|
287
|
+
mutation.reset();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
$effect(() => () => { mounted = false; active = undefined; latestToken = undefined; });
|
|
291
|
+
|
|
292
|
+
function detachedPromise(promise: Promise<Result<S>>, scope: Scope, token: object, progress: Progress): Promise<Result<S>> {
|
|
293
|
+
return promise.then(data => {
|
|
294
|
+
if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, progress);
|
|
295
|
+
return copyResult(scope, data);
|
|
296
|
+
}, (cause: unknown) => {
|
|
297
|
+
if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, progress);
|
|
298
|
+
throw cause instanceof HttpError ? copyFailure(cause) : failure(cause, progress.attempted.length > 0);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
function mutateAsync(params: ContractCreateManyParams<S>, callbacks: Callbacks<S> = {}): Promise<Result<S>> {
|
|
302
|
+
const origin = captureOrigin();
|
|
303
|
+
const observer = { onSuccess: callbacks.onSuccess, onError: callbacks.onError, onSettled: callbacks.onSettled };
|
|
304
|
+
let scope: Scope;
|
|
305
|
+
try { scope = capture(params); }
|
|
306
|
+
catch (cause) {
|
|
307
|
+
const error = failure(cause);
|
|
308
|
+
if (active && isCurrent(active.scope)) return Promise.reject(error);
|
|
309
|
+
latest = undefined;
|
|
310
|
+
active = undefined;
|
|
311
|
+
const token = {};
|
|
312
|
+
const progress = newProgress();
|
|
313
|
+
latestToken = token;
|
|
314
|
+
return mutation.mutateAsync(Object.freeze({
|
|
315
|
+
scope: undefined, origin, token, callbacks: definedOptions(observer), readScope: () => { throw error; },
|
|
316
|
+
readProgress: () => progress,
|
|
317
|
+
})).catch((cause: unknown) => {
|
|
318
|
+
if (!originCurrent(origin) || latestToken !== token) throw cancelled(undefined, progress);
|
|
319
|
+
throw cause instanceof HttpError ? copyFailure(cause) : failure(cause);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (active && isCurrent(active.scope)) {
|
|
323
|
+
if (active.scope.signature === scope.signature) {
|
|
324
|
+
const owner = active.scope;
|
|
325
|
+
const { token, progress } = active;
|
|
326
|
+
const current = () => isCurrent(owner) && latestToken === token;
|
|
327
|
+
return detachedPromise(active.promise, owner, token, progress).then(data => {
|
|
328
|
+
if (current()) {
|
|
329
|
+
observe(() => observer.onSuccess?.(copyResult(owner, data)));
|
|
330
|
+
observe(() => { if (current()) return observer.onSettled?.(copyResult(owner, data), null); });
|
|
331
|
+
}
|
|
332
|
+
if (!current()) throw cancelled(owner, progress);
|
|
333
|
+
return data;
|
|
334
|
+
}, (cause: unknown) => {
|
|
335
|
+
const error = cause instanceof HttpError ? cause : failure(cause, progress.attempted.length > 0);
|
|
336
|
+
if (current()) {
|
|
337
|
+
observe(() => observer.onError?.(copyFailure(error)));
|
|
338
|
+
observe(() => { if (current()) return observer.onSettled?.(undefined, copyFailure(error)); });
|
|
339
|
+
}
|
|
340
|
+
if (!current()) throw cancelled(owner, progress);
|
|
341
|
+
throw error;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return Promise.reject(new HttpError('Batch create is already running', 409, undefined, { code: 'CREATE_MANY_BUSY' }));
|
|
345
|
+
}
|
|
346
|
+
const token = {};
|
|
347
|
+
latestToken = token;
|
|
348
|
+
latest = scope;
|
|
349
|
+
const target = scope;
|
|
350
|
+
const progress = newProgress();
|
|
351
|
+
// Preserve scope and token identity through Svelte's mutation-state projection.
|
|
352
|
+
const promise = mutation.mutateAsync(Object.freeze({
|
|
353
|
+
scope, origin, token, callbacks: definedOptions(observer), readScope: () => target,
|
|
354
|
+
readProgress: () => progress,
|
|
355
|
+
})).finally(() => { if (active?.token === token) active = undefined; });
|
|
356
|
+
active = { scope, token, promise, progress };
|
|
357
|
+
return detachedPromise(promise, target, token, progress);
|
|
358
|
+
}
|
|
359
|
+
// Copy reads while retaining TanStack's success/error discriminated union.
|
|
360
|
+
function detachedState(state: typeof mutation): typeof mutation;
|
|
361
|
+
function detachedState(state: typeof mutation): object {
|
|
362
|
+
return replaceReactiveUnionMembers(state, {
|
|
363
|
+
get data() {
|
|
364
|
+
const scope = state.variables?.scope;
|
|
365
|
+
return state.data && scope ? copyResult(scope, state.data) : undefined;
|
|
366
|
+
},
|
|
367
|
+
get error() { return state.error ? copyFailure(state.error) : null; },
|
|
368
|
+
get failureReason() { return state.failureReason ? copyFailure(state.failureReason) : null; },
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const result = replaceReactiveUnionMembers(detachedState(mutation), {
|
|
372
|
+
get variables() {
|
|
373
|
+
const scope = mutation.variables?.scope;
|
|
374
|
+
return scope ? parseCreateManyParams(scope.contract, scope.input) : undefined;
|
|
375
|
+
},
|
|
376
|
+
mutateAsync,
|
|
377
|
+
mutate(params: ContractCreateManyParams<S>, callbacks?: Callbacks<S>): void {
|
|
378
|
+
void mutateAsync(params, callbacks).catch(() => {});
|
|
379
|
+
},
|
|
380
|
+
reset() { active = undefined; latest = undefined; latestToken = undefined; mutation.reset(); },
|
|
381
|
+
});
|
|
382
|
+
const idle: typeof result = {
|
|
383
|
+
context: undefined, data: undefined, error: null, failureCount: 0, failureReason: null,
|
|
384
|
+
isError: false, isIdle: true, isPending: false, isPaused: false, isSuccess: false,
|
|
385
|
+
status: 'idle', submittedAt: 0, variables: undefined,
|
|
386
|
+
mutate: result.mutate, mutateAsync: result.mutateAsync, reset: result.reset,
|
|
387
|
+
};
|
|
388
|
+
const view = createQueryResultView(() => {
|
|
389
|
+
const invocation = mutation.variables;
|
|
390
|
+
if (!mounted || (invocation && (!originCurrent(invocation.origin) || latestToken !== invocation.token ||
|
|
391
|
+
(invocation.scope && !isCurrent(invocation.scope))))) return idle;
|
|
392
|
+
return result;
|
|
393
|
+
});
|
|
394
|
+
const overtime = createOvertimeTracker(() => view.isPending, options.overtimeOptions ?? adminOptions.overtime);
|
|
395
|
+
return { mutation: view, get overtime() { return overtime; } };
|
|
396
|
+
}
|
|
@@ -1,217 +1,3 @@
|
|
|
1
|
-
import { captureAdminContext } from './context.svelte';
|
|
2
|
-
import { useParsed } from './useParsed.svelte';
|
|
3
|
-
import type { Sort, Filter, BaseRecord } from './types';
|
|
4
|
-
import { downloadData } from './export-format';
|
|
5
|
-
import type { ExportFormat } from './export-format';
|
|
6
|
-
import { parseCSV } from './helpers-pure';
|
|
7
1
|
export { parseCSV } from './helpers-pure';
|
|
8
|
-
export { downloadData,
|
|
2
|
+
export { downloadData,toCsv,toJson,toXlsx,escapeCsvField } from './export-format';
|
|
9
3
|
export type { ExportFormat } from './export-format';
|
|
10
|
-
|
|
11
|
-
export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
12
|
-
resource?: string;
|
|
13
|
-
mapData?: (item: TData) => Record<string, unknown>;
|
|
14
|
-
sorters?: Sort[];
|
|
15
|
-
filters?: Filter[];
|
|
16
|
-
maxItemCount?: number;
|
|
17
|
-
pageSize?: number;
|
|
18
|
-
download?: boolean;
|
|
19
|
-
/** 导出格式,默认 csv(兼容旧版) */
|
|
20
|
-
format?: ExportFormat;
|
|
21
|
-
meta?: Record<string, unknown>;
|
|
22
|
-
dataProviderName?: string;
|
|
23
|
-
onError?: (error: Error) => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* useExport — export records from a resource as CSV/JSON/XLSX download
|
|
28
|
-
* Supports mapData, sorters, filters, maxItemCount, pageSize
|
|
29
|
-
*/
|
|
30
|
-
export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExportOptions<TData> = {}) {
|
|
31
|
-
const adminContext = captureAdminContext();
|
|
32
|
-
const parsed = useParsed();
|
|
33
|
-
const resource = $derived(options.resource ?? parsed.resource ?? '');
|
|
34
|
-
let isLoading = $state(false);
|
|
35
|
-
|
|
36
|
-
async function triggerExport() {
|
|
37
|
-
const exportResource = resource;
|
|
38
|
-
isLoading = true;
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
const provider = adminContext.getDataProviderForResource(exportResource, options.dataProviderName);
|
|
42
|
-
const batchSize = options.pageSize ?? 20;
|
|
43
|
-
const maxItems = options.maxItemCount ?? Infinity;
|
|
44
|
-
let allRecords: TData[] = [];
|
|
45
|
-
let page = 1;
|
|
46
|
-
|
|
47
|
-
while (allRecords.length < maxItems) {
|
|
48
|
-
const result = await provider.getList<TData>({
|
|
49
|
-
resource: exportResource,
|
|
50
|
-
pagination: { current: page, pageSize: batchSize },
|
|
51
|
-
sorters: options.sorters,
|
|
52
|
-
filters: options.filters,
|
|
53
|
-
meta: options.meta,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
allRecords = [...allRecords, ...result.data];
|
|
57
|
-
if (result.data.length < batchSize || allRecords.length >= result.total) break;
|
|
58
|
-
page++;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (maxItems !== Infinity) allRecords = allRecords.slice(0, maxItems);
|
|
62
|
-
if (allRecords.length === 0) return allRecords;
|
|
63
|
-
|
|
64
|
-
const mapped = options.mapData
|
|
65
|
-
? allRecords.map(options.mapData)
|
|
66
|
-
: allRecords.map(r => r as unknown as Record<string, unknown>);
|
|
67
|
-
|
|
68
|
-
if (options.download !== false && typeof document !== 'undefined') {
|
|
69
|
-
downloadData(mapped, exportResource, options.format ?? 'csv');
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return allRecords;
|
|
73
|
-
} catch (error) {
|
|
74
|
-
const exportError = error instanceof Error ? error : new Error(String(error));
|
|
75
|
-
if (!options.onError) throw exportError;
|
|
76
|
-
options.onError(exportError);
|
|
77
|
-
return [];
|
|
78
|
-
} finally {
|
|
79
|
-
isLoading = false;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
triggerExport,
|
|
85
|
-
get isLoading() { return isLoading; },
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface UseImportOptions<TData = Record<string, unknown>> {
|
|
90
|
-
resource?: string;
|
|
91
|
-
/** 指定导入文件格式;默认 auto = 根据文件扩展名推断。注意:import 仅支持 csv 和 json,不支持 xlsx 解析 */
|
|
92
|
-
format?: 'auto' | 'csv' | 'json';
|
|
93
|
-
mapData?: (item: Record<string, unknown>) => TData;
|
|
94
|
-
batchSize?: number;
|
|
95
|
-
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
96
|
-
onProgress?: (info: { totalAmount: number; processedAmount: number }) => void;
|
|
97
|
-
meta?: Record<string, unknown>;
|
|
98
|
-
dataProviderName?: string;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* useImport — import records from a CSV file
|
|
103
|
-
* Supports mapData, batchSize, onFinish, onProgress, meta
|
|
104
|
-
*/
|
|
105
|
-
export function useImport<TData = Record<string, unknown>>(options: UseImportOptions<TData> = {}) {
|
|
106
|
-
const adminContext = captureAdminContext();
|
|
107
|
-
const parsed = useParsed();
|
|
108
|
-
const resource = $derived(options.resource ?? parsed.resource ?? '');
|
|
109
|
-
let isLoading = $state(false);
|
|
110
|
-
let mutationResult = $state<{ succeeded: unknown[]; errored: { request: unknown; error: unknown }[] } | null>(null);
|
|
111
|
-
|
|
112
|
-
async function handleChange(info: { file: File }) {
|
|
113
|
-
const importResource = resource;
|
|
114
|
-
isLoading = true;
|
|
115
|
-
const succeeded: unknown[] = [];
|
|
116
|
-
const errored: { request: unknown; error: unknown }[] = [];
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
const provider = adminContext.getDataProviderForResource(importResource, options.dataProviderName);
|
|
120
|
-
let text = await info.file.text();
|
|
121
|
-
// Strip BOM
|
|
122
|
-
if (text.charCodeAt(0) === 0xFEFF) {
|
|
123
|
-
text = text.slice(1);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// 自动检测或使用指定格式
|
|
127
|
-
const format = options.format ?? 'auto';
|
|
128
|
-
const isJson = format === 'json' || (format === 'auto' && info.file.name.toLowerCase().endsWith('.json'));
|
|
129
|
-
|
|
130
|
-
let records: Record<string, unknown>[];
|
|
131
|
-
|
|
132
|
-
if (isJson) {
|
|
133
|
-
// JSON 格式:期望数组对象
|
|
134
|
-
const parsed = JSON.parse(text);
|
|
135
|
-
if (!Array.isArray(parsed)) {
|
|
136
|
-
mutationResult = { succeeded: [], errored: [{ request: null, error: new Error('JSON import requires an array of objects') }] };
|
|
137
|
-
options.onFinish?.(mutationResult);
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
records = parsed.map((item: unknown) =>
|
|
141
|
-
options.mapData ? (options.mapData(item as Record<string, unknown>) as unknown as Record<string, unknown>) : (item as Record<string, unknown>)
|
|
142
|
-
);
|
|
143
|
-
} else {
|
|
144
|
-
// CSV 格式
|
|
145
|
-
const rows = parseCSV(text);
|
|
146
|
-
if (rows.length < 2) {
|
|
147
|
-
mutationResult = { succeeded: [], errored: [] };
|
|
148
|
-
options.onFinish?.({ succeeded: [], errored: [] });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const headers = rows[0];
|
|
153
|
-
records = [];
|
|
154
|
-
for (let i = 1; i < rows.length; i++) {
|
|
155
|
-
const values = rows[i];
|
|
156
|
-
const record: Record<string, unknown> = {};
|
|
157
|
-
headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
|
|
158
|
-
records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const batchSize = options.batchSize ?? 1;
|
|
163
|
-
let processed = 0;
|
|
164
|
-
|
|
165
|
-
if (batchSize === 1) {
|
|
166
|
-
for (const record of records) {
|
|
167
|
-
try {
|
|
168
|
-
const res = await provider.create({ resource: importResource, variables: record, meta: options.meta });
|
|
169
|
-
succeeded.push(res);
|
|
170
|
-
} catch (error) {
|
|
171
|
-
errored.push({ request: record, error });
|
|
172
|
-
}
|
|
173
|
-
processed++;
|
|
174
|
-
options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
|
|
175
|
-
}
|
|
176
|
-
} else {
|
|
177
|
-
for (let i = 0; i < records.length; i += batchSize) {
|
|
178
|
-
const batch = records.slice(i, i + batchSize);
|
|
179
|
-
if (provider.createMany) {
|
|
180
|
-
try {
|
|
181
|
-
const res = await provider.createMany({ resource: importResource, variables: batch, meta: options.meta });
|
|
182
|
-
succeeded.push(res);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
errored.push({ request: batch, error });
|
|
185
|
-
}
|
|
186
|
-
} else {
|
|
187
|
-
for (const record of batch) {
|
|
188
|
-
try {
|
|
189
|
-
const res = await provider.create({ resource: importResource, variables: record, meta: options.meta });
|
|
190
|
-
succeeded.push(res);
|
|
191
|
-
} catch (error) {
|
|
192
|
-
errored.push({ request: record, error });
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
processed += batch.length;
|
|
197
|
-
options.onProgress?.({ totalAmount: records.length, processedAmount: processed });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
mutationResult = { succeeded, errored };
|
|
202
|
-
options.onFinish?.({ succeeded, errored });
|
|
203
|
-
} catch (error) {
|
|
204
|
-
mutationResult = { succeeded, errored: [...errored, { request: null, error }] };
|
|
205
|
-
options.onFinish?.({ succeeded, errored: mutationResult.errored });
|
|
206
|
-
} finally {
|
|
207
|
-
isLoading = false;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
inputProps: { type: 'file' as const, accept: '.csv,.json' },
|
|
213
|
-
handleChange,
|
|
214
|
-
get isLoading() { return isLoading; },
|
|
215
|
-
get mutationResult() { return mutationResult; },
|
|
216
|
-
};
|
|
217
|
-
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type OptionalKeys<T extends Record<PropertyKey,unknown>>={
|
|
2
|
+
[K in keyof T]-?: undefined extends T[K]? K:never;
|
|
3
|
+
}[keyof T];
|
|
4
|
+
|
|
5
|
+
export type DefinedOptions<T extends Record<PropertyKey,unknown>>=
|
|
6
|
+
Omit<T,OptionalKeys<T>>&{
|
|
7
|
+
[K in OptionalKeys<T>]?: Exclude<T[K],undefined>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Construct an options record with absent values omitted; null and falsy values are retained. */
|
|
11
|
+
export function definedOptions<const T extends Record<PropertyKey,unknown>>(options: T): DefinedOptions<T>;
|
|
12
|
+
export function definedOptions(options: Record<PropertyKey,unknown>): object {
|
|
13
|
+
const result: Record<PropertyKey,unknown>={};
|
|
14
|
+
for(const key of Reflect.ownKeys(options)) {
|
|
15
|
+
const value=options[key];
|
|
16
|
+
if(value!==undefined) {
|
|
17
|
+
Object.defineProperty(result,key,{
|
|
18
|
+
value,writable: true,enumerable: true,configurable: true,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep reactive getters live while exposing undefined members as absent options. */
|
|
26
|
+
export function definedReactiveOptions<const T extends Record<PropertyKey, unknown>>(options: T): DefinedOptions<T>;
|
|
27
|
+
export function definedReactiveOptions(options: Record<PropertyKey, unknown>): object {
|
|
28
|
+
return new Proxy({}, {
|
|
29
|
+
get(_target, key): unknown {
|
|
30
|
+
return Reflect.get(options, key, options);
|
|
31
|
+
},
|
|
32
|
+
set(_target, key, value: unknown): boolean {
|
|
33
|
+
return Reflect.set(options, key, value, options);
|
|
34
|
+
},
|
|
35
|
+
has(_target, key): boolean {
|
|
36
|
+
return Reflect.has(options, key) && Reflect.get(options, key, options) !== undefined;
|
|
37
|
+
},
|
|
38
|
+
ownKeys(): (string | symbol)[] {
|
|
39
|
+
return Reflect.ownKeys(options).filter((key) => Reflect.get(options, key, options) !== undefined);
|
|
40
|
+
},
|
|
41
|
+
getOwnPropertyDescriptor(_target, key): PropertyDescriptor | undefined {
|
|
42
|
+
const descriptor = Reflect.getOwnPropertyDescriptor(options, key);
|
|
43
|
+
if (!descriptor || Reflect.get(options, key, options) === undefined) return undefined;
|
|
44
|
+
return { ...descriptor, configurable: true };
|
|
45
|
+
},
|
|
46
|
+
deleteProperty(_target, key): boolean {
|
|
47
|
+
return Reflect.deleteProperty(options, key);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|