@svadmin/core 0.50.0 → 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/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 -776
- 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 -30
- 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 -288
- 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 -108
- 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
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
package/src/export-format.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
/**
|
|
16
|
+
/** Converts records to a CSV string. */
|
|
17
17
|
export function toCsv(records: Record<string, unknown>[]): string {
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
41
|
+
/** Escapes XML special characters. */
|
|
41
42
|
export function escapeXml(xmlValue: string): string {
|
|
42
43
|
return xmlValue.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
|
-
*
|
|
47
|
+
* Generates a minimal XLSX document using SpreadsheetML with no dependencies.
|
|
47
48
|
*/
|
|
48
49
|
export function toXlsx(records: Record<string, unknown>[]): string {
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
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>[],
|