@svadmin/core 0.50.0 → 0.53.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.
Files changed (98) hide show
  1. package/package.json +21 -4
  2. package/src/access-control-contract.ts +250 -0
  3. package/src/adapters/casbin.ts +20 -14
  4. package/src/adapters/casl.ts +22 -19
  5. package/src/admin-tool.ts +7 -7
  6. package/src/audit-contract.ts +105 -0
  7. package/src/audit.ts +88 -128
  8. package/src/auth-hooks.svelte.ts +593 -261
  9. package/src/auth-mutation-contract.ts +143 -0
  10. package/src/auth-query-contract.ts +97 -0
  11. package/src/captured-mutation.svelte.ts +45 -0
  12. package/src/chatProvider.svelte.ts +1 -1
  13. package/src/command-contract.ts +195 -0
  14. package/src/context.svelte.ts +217 -205
  15. package/src/contract-context.svelte.ts +37 -0
  16. package/src/contract-form.svelte.ts +566 -0
  17. package/src/create-contract.ts +56 -0
  18. package/src/create-hooks.svelte.ts +347 -0
  19. package/src/create-many-contract.ts +90 -0
  20. package/src/create-many-hooks.svelte.ts +396 -0
  21. package/src/data-transfer.svelte.ts +1 -215
  22. package/src/defined-options.ts +50 -0
  23. package/src/delete-contract.ts +50 -0
  24. package/src/delete-hooks.svelte.ts +403 -0
  25. package/src/delete-many-contract.ts +60 -0
  26. package/src/delete-many-hooks.svelte.ts +402 -0
  27. package/src/enterprise.ts +4 -4
  28. package/src/export-format.ts +16 -14
  29. package/src/export-hooks.svelte.ts +191 -0
  30. package/src/feature-gate-contract.ts +53 -0
  31. package/src/hook-utils.svelte.ts +53 -105
  32. package/src/hooks.svelte.ts +19 -776
  33. package/src/http.ts +88 -87
  34. package/src/i18n.svelte.ts +64 -2
  35. package/src/import-contract.ts +106 -0
  36. package/src/import-hooks.svelte.ts +310 -0
  37. package/src/index.ts +77 -30
  38. package/src/inferencer-graphql.ts +7 -6
  39. package/src/inferencer.ts +9 -0
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -288
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +220 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +103 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +190 -180
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +68 -110
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
  98. package/src/schema-form.ts +0 -31
@@ -0,0 +1,402 @@
1
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
+ import { captureAdminContext } from './context.svelte';
3
+ import { captureQueryProvider } from './query-snapshot';
4
+ import { snapshotDeleteManyParams, parseDeleteManyParams, decodeDeleteManyReceipt, type ContractDeleteManyParams } from './delete-many-contract';
5
+ import { contractKey, parseContractId, parseContractRecord, parseContractDeleteInput,
6
+ type ContractSchemas, type ContractId, 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, readOwnedDataQuery } 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 { DeleteManyPartialError, HttpError } from './types';
18
+ import { useTranslation } from './i18n.svelte';
19
+ import { toast } from './toast.svelte';
20
+
21
+ export interface ContractDeleteManyOptions<S extends ContractSchemas> {
22
+ resource: ResourceContract<S>;
23
+ enabled?: boolean;
24
+ overtimeOptions?: OvertimeOptions;
25
+ }
26
+ export type { ContractDeleteManyParams } from './delete-many-contract';
27
+ type Result<S extends ContractSchemas> = { data: ContractRecord<S>[] };
28
+ type Failure = HttpError | DeleteManyPartialError;
29
+ export interface Callbacks<S extends ContractSchemas> {
30
+ onSuccess?: (data: Result<S>) => void | Promise<void>;
31
+ onError?: (error: Failure) => void | Promise<void>;
32
+ onSettled?: (data: Result<S> | undefined, error: Failure | null) => void | Promise<void>;
33
+ }
34
+ function observe(callback: () => void | Promise<void>): void {
35
+ try { void Promise.resolve(callback()).catch(() => {}); }
36
+ catch { /* Observer failures cannot invalidate a completed write. */ }
37
+ }
38
+ function failure(cause: unknown, dispatched = false): HttpError {
39
+ let code: unknown;
40
+ let status: unknown;
41
+ try {
42
+ if (typeof cause === 'object' && cause !== null) {
43
+ code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
44
+ status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
45
+ }
46
+ } catch { /* Treat hostile error reflection as a generic failure. */ }
47
+ const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
48
+ const response = code === 'INVALID_PROVIDER_RESPONSE';
49
+ return new HttpError(input ? 'Invalid batch deletion input' : response ? 'Invalid provider response' : 'Batch deletion failed',
50
+ input ? 422 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
51
+ code: input ? 'INVALID_RESOURCE_INPUT' : response ? 'INVALID_PROVIDER_RESPONSE' : 'DELETE_MANY_FAILED',
52
+ details: { writeMayHaveSucceeded: dispatched },
53
+ });
54
+ }
55
+ function copyFailure(error: Failure): Failure {
56
+ return error instanceof DeleteManyPartialError
57
+ ? new DeleteManyPartialError(error.succeededIds, error.failedIds, error.causes.map(cause => failure(cause, true)))
58
+ : new HttpError(error.message, error.statusCode, undefined, {
59
+ ...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
60
+ });
61
+ }
62
+
63
+ /** Checked batch progress and destructive cleanup belong to one resource and auth revision. */
64
+ export function useContractDeleteMany<S extends ContractSchemas>(options: ContractDeleteManyOptions<S>) {
65
+ contractKey(options.resource);
66
+ const context = captureAdminContext();
67
+ const client = useQueryClient();
68
+ const adminOptions = getAdminOptions();
69
+ const i18n = useTranslation();
70
+ let mounted = true;
71
+ function captureOrigin() {
72
+ const auth = context.authProvider;
73
+ return {
74
+ auth, authScope: captureAuthLiveScope(auth), router: context.routerProvider,
75
+ tenant: context.tenantCacheKey?.__svadminTenant, contract: options.resource,
76
+ };
77
+ }
78
+ function originCurrent(origin: ReturnType<typeof captureOrigin>): boolean {
79
+ try {
80
+ return mounted && options.enabled !== false && origin.auth === context.authProvider &&
81
+ origin.authScope.isCurrent() && origin.router === context.routerProvider &&
82
+ origin.tenant === context.tenantCacheKey?.__svadminTenant && origin.contract === options.resource;
83
+ } catch { return false; }
84
+ }
85
+ function captureScope(input: ReturnType<typeof snapshotDeleteManyParams>) {
86
+ const origin = captureOrigin();
87
+ const contract = origin.contract;
88
+ const captured = captureQueryProvider(context, {
89
+ resource: contract.name, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }),
90
+ });
91
+ const name = context.resolveDataProviderName(contract.name, input.dataProviderName);
92
+ const raw = context.providers?.[name];
93
+ if (!raw) throw new HttpError('Data provider unavailable', 400);
94
+ return {
95
+ ...origin, ...captured, deleteOne: raw.deleteOne.bind(raw), deleteMany: raw.deleteMany?.bind(raw),
96
+ matcher: { ...context.queryKeyMatcher(contract.name, input.dataProviderName), contract: contractKey(contract) },
97
+ notification: context.notificationProvider, audit: context.auditLogProvider, live: context.liveProvider,
98
+ key: JSON.stringify([contractKey(contract), name, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta]),
99
+ };
100
+ }
101
+ function capture(params: ContractDeleteManyParams<S>) {
102
+ const input = snapshotDeleteManyParams(params);
103
+ const scope = captureScope(input);
104
+ const ids = input.ids.map(id => parseContractId(scope.contract, id));
105
+ const variables = parseContractDeleteInput(scope.contract, input.variables);
106
+ if (options.enabled !== undefined && typeof options.enabled !== 'boolean') throw new HttpError('Invalid enabled value', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
107
+ return { ...scope, input, ids, variables, signature: JSON.stringify([scope.key, scope.authScope.cacheKey, ids, variables]) };
108
+ }
109
+ type Scope = ReturnType<typeof capture>;
110
+ function notify(scope: Scope, current: () => boolean, type: 'success' | 'error', message: string): void {
111
+ observe(() => {
112
+ if (!current()) return;
113
+ if (scope.notification) return scope.notification.open({ type, message });
114
+ if (type === 'success') toast.success(message);
115
+ else toast.error(message);
116
+ });
117
+ }
118
+ type Progress = { attempted: ContractId<S>[]; succeeded: ContractId<S>[]; failed: ContractId<S>[] };
119
+ const newProgress = (): Progress => ({ attempted: [], succeeded: [], failed: [] });
120
+ type Invocation = {
121
+ scope: Scope | undefined;
122
+ origin: ReturnType<typeof captureOrigin>;
123
+ token: object;
124
+ callbacks: Callbacks<S>;
125
+ readScope(): Scope;
126
+ readProgress(): Progress;
127
+ };
128
+ let latest = $state.raw<Scope>();
129
+ let latestToken: object | undefined;
130
+ let active: { scope: Scope; promise: Promise<Result<S>>; token: object; progress: Progress } | undefined;
131
+ function targetCurrent(scope: Scope): boolean {
132
+ if (!mounted || options.enabled === false) return false;
133
+ try {
134
+ return captureScope(scope.input).key === scope.key &&
135
+ context.authProvider === scope.auth && context.routerProvider === scope.router;
136
+ } catch { return false; }
137
+ }
138
+ const sessionCurrent = (scope: Scope) => {
139
+ try { return context.authProvider === scope.auth && scope.authScope.isCurrent(); }
140
+ catch { return false; }
141
+ };
142
+ const isCurrent = (scope: Scope) => targetCurrent(scope) && sessionCurrent(scope);
143
+ function cancelled(scope: Scope | undefined, progress: Progress): HttpError {
144
+ return new HttpError('Batch deletion scope changed', 409, undefined, {
145
+ code: 'DELETE_MANY_CANCELLED', details: {
146
+ writeMayHaveSucceeded: progress.attempted.length > 0,
147
+ attemptedIds: [...progress.attempted], failedIds: [...progress.failed],
148
+ ...definedOptions({
149
+ requestedIds: scope ? [...scope.ids] : undefined,
150
+ succeededIds: scope && sessionCurrent(scope) ? [...progress.succeeded] : undefined,
151
+ unattemptedIds: scope?.ids.filter(id => !progress.attempted.includes(id)),
152
+ }),
153
+ },
154
+ });
155
+ }
156
+ function copyResult(scope: Scope, data: Result<S>): Result<S> {
157
+ return { data: data.data.map(record => parseContractRecord(scope.contract, record)) };
158
+ }
159
+ async function refresh(scope: Scope, attempted: readonly (string | number)[], succeeded: readonly (string | number)[]) {
160
+ if (!attempted.length) return;
161
+ const matcher = { ...scope.matcher, resource: scope.contract.name };
162
+ const owner = { source: scope.source, authSession: scope.authScope.cacheKey };
163
+ try {
164
+ if (succeeded.length) {
165
+ const confirmedDetail = (key: readonly unknown[]) => {
166
+ if (!sessionCurrent(scope)) return false;
167
+ const descriptor = readOwnedDataQuery(key, matcher, owner);
168
+ return descriptor?.action === 'one' && descriptor.id !== undefined && succeeded.includes(descriptor.id);
169
+ };
170
+ const queries = client.getQueryCache().findAll({ predicate: query => confirmedDetail(query.queryKey) });
171
+ // Removal callbacks may retire authentication before the next confirmed detail.
172
+ for (const query of queries) {
173
+ if (!sessionCurrent(scope)) return;
174
+ client.removeQueries({ predicate: candidate => candidate === query && confirmedDetail(candidate.queryKey) });
175
+ }
176
+ }
177
+ const request = { client, matcher, owner, current: () => sessionCurrent(scope) };
178
+ await Promise.allSettled([
179
+ invalidateOwnedQueries({ ...request, scopes: ['list', 'many'] }),
180
+ ...attempted.map(id => invalidateOwnedQueries({ ...request, scopes: ['detail'], id })),
181
+ ]);
182
+ } catch {
183
+ // Cache failures cannot falsify a checked deletion receipt.
184
+ }
185
+ }
186
+ const mutation = createMutation<Result<S>, Failure, Invocation>(() => ({
187
+ retry: false,
188
+ onMutate: () => undefined,
189
+ onSuccess: () => {},
190
+ onError: () => {},
191
+ onSettled: () => {},
192
+ mutationFn: async invocation => {
193
+ let scope: Scope;
194
+ try { scope = invocation.readScope(); }
195
+ catch (cause) {
196
+ const error = failure(cause);
197
+ const current = () => latestToken === invocation.token && originCurrent(invocation.origin);
198
+ if (!current()) throw cancelled(undefined, invocation.readProgress());
199
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error)); });
200
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error)); });
201
+ throw error;
202
+ }
203
+ const progress = invocation.readProgress();
204
+ const { attempted, succeeded, failed } = progress;
205
+ const causes: HttpError[] = [];
206
+ const data: ContractRecord<S>[] = [];
207
+ let rejected: Failure | undefined;
208
+ const current = () => isCurrent(scope) && latestToken === invocation.token;
209
+ const ensureCurrent = () => {
210
+ if (!current() || active?.token !== invocation.token) throw cancelled(scope, progress);
211
+ };
212
+ const request = () => ({
213
+ resource: scope.contract.name,
214
+ ...definedOptions({
215
+ meta: scope.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.meta)),
216
+ variables: scope.variables === undefined ? undefined : snapshotPlainData(scope.variables),
217
+ }),
218
+ });
219
+ try {
220
+ ensureCurrent();
221
+ if (scope.deleteMany) {
222
+ const params = { ...request(), ids: [...scope.ids] };
223
+ attempted.push(...scope.ids);
224
+ const response = await scope.deleteMany(params);
225
+ if (!sessionCurrent(scope)) throw cancelled(scope, progress);
226
+ data.push(...decodeDeleteManyReceipt(scope.contract, scope.ids, response, true));
227
+ succeeded.push(...scope.ids);
228
+ } else {
229
+ for (const id of scope.ids) {
230
+ ensureCurrent();
231
+ const params = { ...request(), id };
232
+ attempted.push(id);
233
+ try {
234
+ const response = await scope.deleteOne(params);
235
+ if (!sessionCurrent(scope)) throw cancelled(scope, progress);
236
+ data.push(...decodeDeleteManyReceipt(scope.contract, [id], response, false));
237
+ succeeded.push(id);
238
+ } catch (cause) {
239
+ if (!sessionCurrent(scope)) throw cancelled(scope, progress);
240
+ failed.push(id);
241
+ causes.push(failure(cause, true));
242
+ }
243
+ }
244
+ if (failed.length) rejected = succeeded.length
245
+ ? new DeleteManyPartialError(succeeded, failed, causes) : causes[0] ?? failure(undefined, true);
246
+ }
247
+ } catch (cause) {
248
+ rejected = failure(cause, attempted.length > 0);
249
+ } finally {
250
+ for (const id of succeeded) {
251
+ if (!sessionCurrent(scope)) break;
252
+ auditWithProvider({ action: 'delete', resource: scope.contract.name, recordId: id,
253
+ ...definedOptions({ meta: scope.meta }) }, scope.audit, () => sessionCurrent(scope));
254
+ }
255
+ if (succeeded.length) observe(() => {
256
+ if (sessionCurrent(scope)) return scope.live?.publish?.({
257
+ type: 'DELETE', resource: scope.contract.name, payload: { ids: [...succeeded] },
258
+ });
259
+ });
260
+ if (sessionCurrent(scope)) await refresh(scope, attempted, succeeded);
261
+ }
262
+ ensureCurrent();
263
+ if (rejected) {
264
+ const error = rejected;
265
+ const authError = causes.find(cause => cause.statusCode === 401 || cause.statusCode === 403) ?? error;
266
+ observe(async () => {
267
+ await handleAuthError(failure(authError, true), context,
268
+ () => targetCurrent(scope) && latestToken === invocation.token,
269
+ () => clearAuthQueries(client, scope.auth), scope.authScope,
270
+ () => mounted && latestToken !== invocation.token);
271
+ });
272
+ notify(scope, current, 'error', error instanceof DeleteManyPartialError
273
+ ? i18n.t('common.batchDeletePartialFail', { failed: error.failedIds.length, total: scope.ids.length })
274
+ : i18n.t('common.batchDeleteFailed', { count: scope.ids.length }));
275
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error)); });
276
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error)); });
277
+ throw error;
278
+ }
279
+ const result = { data };
280
+ notify(scope, current, 'success', i18n.t('common.batchDeleteSuccess', { count: scope.ids.length }));
281
+ observe(() => { if (current()) return invocation.callbacks.onSuccess?.(copyResult(scope, result)); });
282
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(copyResult(scope, result), null); });
283
+ ensureCurrent();
284
+ return copyResult(scope, result);
285
+ },
286
+ }));
287
+ $effect.pre(() => {
288
+ if (latest && !isCurrent(latest)) {
289
+ latest = undefined;
290
+ active = undefined;
291
+ mutation.reset();
292
+ }
293
+ });
294
+ $effect(() => () => { mounted = false; active = undefined; latestToken = undefined; });
295
+
296
+ function detachedPromise(promise: Promise<Result<S>>, scope: Scope, token: object, progress: Progress): Promise<Result<S>> {
297
+ return promise.then(data => {
298
+ if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, progress);
299
+ return copyResult(scope, data);
300
+ }, (cause: unknown) => {
301
+ if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, progress);
302
+ throw cause instanceof DeleteManyPartialError || cause instanceof HttpError
303
+ ? copyFailure(cause) : failure(cause, progress.attempted.length > 0);
304
+ });
305
+ }
306
+ function mutateAsync(params: ContractDeleteManyParams<S>, callbacks: Callbacks<S> = {}): Promise<Result<S>> {
307
+ const origin = captureOrigin();
308
+ const observer = { onSuccess: callbacks.onSuccess, onError: callbacks.onError, onSettled: callbacks.onSettled };
309
+ let scope: Scope;
310
+ try { scope = capture(params); }
311
+ catch (cause) {
312
+ const error = failure(cause);
313
+ if (active && isCurrent(active.scope)) return Promise.reject(error);
314
+ latest = undefined;
315
+ active = undefined;
316
+ const token = {};
317
+ const progress = newProgress();
318
+ latestToken = token;
319
+ return mutation.mutateAsync(Object.freeze({
320
+ scope: undefined, origin, token, callbacks: definedOptions(observer),
321
+ readScope: () => { throw error; }, readProgress: () => progress,
322
+ })).catch((cause: unknown) => {
323
+ if (!originCurrent(origin) || latestToken !== token) throw cancelled(undefined, progress);
324
+ throw cause instanceof HttpError ? copyFailure(cause) : failure(cause);
325
+ });
326
+ }
327
+ if (active && isCurrent(active.scope)) {
328
+ if (active.scope.signature === scope.signature) {
329
+ const owner = active.scope;
330
+ const { token, progress } = active;
331
+ const current = () => isCurrent(owner) && latestToken === token;
332
+ return detachedPromise(active.promise, owner, token, progress).then(data => {
333
+ if (current()) {
334
+ observe(() => observer.onSuccess?.(copyResult(owner, data)));
335
+ observe(() => { if (current()) return observer.onSettled?.(copyResult(owner, data), null); });
336
+ }
337
+ if (!current()) throw cancelled(owner, progress);
338
+ return data;
339
+ }, (cause: unknown) => {
340
+ const error = cause instanceof DeleteManyPartialError || cause instanceof HttpError
341
+ ? cause : failure(cause, progress.attempted.length > 0);
342
+ if (current()) {
343
+ observe(() => observer.onError?.(copyFailure(error)));
344
+ observe(() => { if (current()) return observer.onSettled?.(undefined, copyFailure(error)); });
345
+ }
346
+ if (!current()) throw cancelled(owner, progress);
347
+ throw error;
348
+ });
349
+ }
350
+ return Promise.reject(new HttpError('Batch deletion is already running', 409, undefined, { code: 'DELETE_MANY_BUSY' }));
351
+ }
352
+ const token = {};
353
+ latestToken = token;
354
+ latest = scope;
355
+ const target = scope;
356
+ const progress = newProgress();
357
+ // Preserve scope and token identity through Svelte's mutation-state projection.
358
+ const promise = mutation.mutateAsync(Object.freeze({
359
+ scope, origin, token, callbacks: definedOptions(observer), readScope: () => target,
360
+ readProgress: () => progress,
361
+ })).finally(() => { if (active?.token === token) active = undefined; });
362
+ active = { scope, token, promise, progress };
363
+ return detachedPromise(promise, target, token, progress);
364
+ }
365
+ // Copies preserve the mutation's data/error discriminants without sharing mutable records.
366
+ function detachedState(state: typeof mutation): typeof mutation;
367
+ function detachedState(state: typeof mutation): object {
368
+ return replaceReactiveUnionMembers(state, {
369
+ get data() {
370
+ const scope = state.variables?.scope;
371
+ return state.data && scope ? copyResult(scope, state.data) : undefined;
372
+ },
373
+ get error() { return state.error ? copyFailure(state.error) : null; },
374
+ get failureReason() { return state.failureReason ? copyFailure(state.failureReason) : null; },
375
+ });
376
+ }
377
+ const result = replaceReactiveUnionMembers(detachedState(mutation), {
378
+ get variables() {
379
+ const scope = mutation.variables?.scope;
380
+ return scope ? parseDeleteManyParams(scope.contract, scope.input) : undefined;
381
+ },
382
+ mutateAsync,
383
+ mutate(params: ContractDeleteManyParams<S>, callbacks?: Callbacks<S>): void {
384
+ void mutateAsync(params, callbacks).catch(() => {});
385
+ },
386
+ reset() { active = undefined; latest = undefined; latestToken = undefined; mutation.reset(); },
387
+ });
388
+ const idle: typeof result = {
389
+ context: undefined, data: undefined, error: null, failureCount: 0, failureReason: null,
390
+ isError: false, isIdle: true, isPending: false, isPaused: false, isSuccess: false,
391
+ status: 'idle', submittedAt: 0, variables: undefined,
392
+ mutate: result.mutate, mutateAsync: result.mutateAsync, reset: result.reset,
393
+ };
394
+ const view = createQueryResultView(() => {
395
+ const invocation = mutation.variables;
396
+ if (!mounted || (invocation && (!originCurrent(invocation.origin) || latestToken !== invocation.token ||
397
+ (invocation.scope && !isCurrent(invocation.scope))))) return idle;
398
+ return result;
399
+ });
400
+ const overtime = createOvertimeTracker(() => view.isPending, options.overtimeOptions ?? adminOptions.overtime);
401
+ return { mutation: view, get overtime() { return overtime; } };
402
+ }
package/src/enterprise.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** 企业级管理能力的正式 Provider 契约。 */
1
+ /** Formal provider contract for enterprise management capabilities. */
2
2
 
3
3
  export interface EnterpriseActionError {
4
4
  message: string;
@@ -10,7 +10,7 @@ export interface EnterpriseActionResult {
10
10
  error?: EnterpriseActionError;
11
11
  }
12
12
 
13
- /** 每次企业能力调用都必须显式携带当前请求树的租户与追踪上下文。 */
13
+ /** Every enterprise capability call must carry the current request tree's tenant and trace context. */
14
14
  export interface EnterpriseRequestContext {
15
15
  tenantId?: string | number;
16
16
  requestId?: string;
@@ -18,7 +18,7 @@ export interface EnterpriseRequestContext {
18
18
  meta?: Readonly<Record<string, unknown>>;
19
19
  }
20
20
 
21
- /** Provider 调用使用的严格请求上下文;三项边界标识不可省略。 */
21
+ /** Strict provider request context; all three boundary identifiers are required. */
22
22
  export interface StrictEnterpriseRequestContext {
23
23
  tenantId: string | number;
24
24
  requestId: string;
@@ -179,7 +179,7 @@ export interface ApiCredentialSummary {
179
179
 
180
180
  export interface CreatedApiCredential {
181
181
  credential: ApiCredentialSummary;
182
- /** 只在创建时返回一次;列表接口不得返回此字段。 */
182
+ /** Returned only at creation time; list endpoints must not expose this field. */
183
183
  secret: string;
184
184
  }
185
185
 
@@ -1,10 +1,10 @@
1
- // 纯函数导出格式化工具 — 不依赖 Svelte runes 或浏览器 API(downloadData 除外)
2
- // 可被 bun:test 直接测试
1
+ // Pure export formatting helpers with no Svelte rune or browser API dependencies except downloadData.
2
+ // These helpers can be tested directly with bun:test.
3
3
 
4
- /** 支持的导出/导入格式 */
4
+ /** Supported export and import formats. */
5
5
  export type ExportFormat = 'csv' | 'json' | 'xlsx';
6
6
 
7
- /** CSV 转义,含公式注入防护 */
7
+ /** Escapes a CSV field and prevents formula injection. */
8
8
  export function escapeCsvField(fieldValue: string): string {
9
9
  let escapedValue = fieldValue;
10
10
  if (/^[=+@\t\r]/.test(escapedValue)) escapedValue = "'" + escapedValue;
@@ -13,10 +13,11 @@ export function escapeCsvField(fieldValue: string): string {
13
13
  : escapedValue;
14
14
  }
15
15
 
16
- /** 将记录数组转为 CSV 字符串 */
16
+ /** Converts records to a CSV string. */
17
17
  export function toCsv(records: Record<string, unknown>[]): string {
18
- if (records.length === 0) return '';
19
- const fieldNames = Object.keys(records[0]);
18
+ const [firstRecord] = records;
19
+ if (!firstRecord) return '';
20
+ const fieldNames = Object.keys(firstRecord);
20
21
  const header = fieldNames.map(escapeCsvField).join(',');
21
22
  const rows = records.map(record =>
22
23
  fieldNames.map(fieldName => {
@@ -32,22 +33,23 @@ export function toCsv(records: Record<string, unknown>[]): string {
32
33
  return [header, ...rows].join('\n');
33
34
  }
34
35
 
35
- /** 将记录数组转为格式化的 JSON 字符串 */
36
+ /** Converts records to a formatted JSON string. */
36
37
  export function toJson(records: Record<string, unknown>[]): string {
37
38
  return JSON.stringify(records, null, 2);
38
39
  }
39
40
 
40
- /** 转义 XML 特殊字符 */
41
+ /** Escapes XML special characters. */
41
42
  export function escapeXml(xmlValue: string): string {
42
43
  return xmlValue.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
43
44
  }
44
45
 
45
46
  /**
46
- * 生成最小可用的 XLSX(SpreadsheetML)——零依赖。
47
+ * Generates a minimal XLSX document using SpreadsheetML with no dependencies.
47
48
  */
48
49
  export function toXlsx(records: Record<string, unknown>[]): string {
49
- if (records.length === 0) return '';
50
- const fieldNames = Object.keys(records[0]);
50
+ const [firstRecord] = records;
51
+ if (!firstRecord) return '';
52
+ const fieldNames = Object.keys(firstRecord);
51
53
 
52
54
  const headerCells = fieldNames.map(fieldName => `<Cell><Data ss:Type="String">${escapeXml(fieldName)}</Data></Cell>`).join('');
53
55
  const dataRows = records.map(record =>
@@ -75,8 +77,8 @@ ${dataRows}
75
77
  }
76
78
 
77
79
  /**
78
- * 将记录以指定格式下载为文件。
79
- * 依赖浏览器 document API;在 Node/bun:test 中需 mock。
80
+ * Downloads records in the requested format.
81
+ * Requires the browser document API, which must be mocked in Node or bun:test.
80
82
  */
81
83
  export function downloadData(
82
84
  records: Record<string, unknown>[],