@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,50 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { snapshotPlainData } from './plain-data';
3
+ import { checkExact } from './schema-validation';
4
+ import { HttpError } from './types';
5
+ import { definedOptions } from './defined-options';
6
+ import { parseContractDeleteInput, parseContractId, parseContractRecord,
7
+ type ContractDeleteInput, type ContractSchemas, type ResourceContract } from './resource-contract';
8
+ import { decodeOneResult, rejectProviderResponse } from './record-decoder';
9
+
10
+ export type ContractDeleteParams<S extends ContractSchemas> = {
11
+ meta?: Record<string, unknown>;
12
+ dataProviderName?: string;
13
+ } & (undefined extends ContractDeleteInput<S>
14
+ ? { variables?: ContractDeleteInput<S> } : { variables: ContractDeleteInput<S> });
15
+
16
+ const envelope = Type.Object({
17
+ variables: Type.Optional(Type.Unknown()),
18
+ meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
19
+ dataProviderName: Type.Optional(Type.String({ minLength: 1 })),
20
+ }, { additionalProperties: false });
21
+
22
+ export function snapshotDeleteParams(value: unknown) {
23
+ try {
24
+ const snapshot = snapshotPlainData(value);
25
+ if (checkExact(envelope, snapshot)) return snapshot;
26
+ } catch {
27
+ // Accessor and serialization failures must not expose request values.
28
+ }
29
+ throw new HttpError('Invalid deletion input', 422, undefined, {
30
+ code: 'INVALID_RESOURCE_INPUT', details: { phase: 'input', writeMayHaveSucceeded: false },
31
+ });
32
+ }
33
+
34
+ /** Private schemas establish the public payload type, including payload-free deletion. */
35
+ export function parseDeleteParams<S extends ContractSchemas>(contract: ResourceContract<S>, value: unknown): ContractDeleteParams<S>;
36
+ export function parseDeleteParams(contract: ResourceContract, value: unknown): object {
37
+ const input = snapshotDeleteParams(value);
38
+ const variables = parseContractDeleteInput(contract, input.variables);
39
+ return definedOptions({ variables, meta: input.meta, dataProviderName: input.dataProviderName });
40
+ }
41
+
42
+ export function decodeDeleteReceipt<S extends ContractSchemas>(
43
+ contract: ResourceContract<S>, id: string | number, response: unknown,
44
+ ) {
45
+ try {
46
+ const result = decodeOneResult(snapshotPlainData(response), value => parseContractRecord(contract, value), true);
47
+ if (parseContractId(contract, result.data.id) !== id) return rejectProviderResponse(true);
48
+ return result;
49
+ } catch { return rejectProviderResponse(true); }
50
+ }
@@ -0,0 +1,403 @@
1
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
+ import { captureAdminContext } from './context.svelte';
3
+ import { captureQueryProvider } from './query-snapshot';
4
+ import { snapshotDeleteParams, parseDeleteParams, decodeDeleteReceipt, type ContractDeleteParams } from './delete-contract';
5
+ import { contractKey, parseContractRecord, parseContractId,
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 { HttpError, UndoError } from './types';
18
+ import { useTranslation } from './i18n.svelte';
19
+ import { toast, getToasts, removeToast } from './toast.svelte';
20
+
21
+ export type { ContractDeleteParams } from './delete-contract';
22
+ export interface ContractDeleteOptions<S extends ContractSchemas> {
23
+ resource: ResourceContract<S>;
24
+ id: NoInfer<ContractId<S>>;
25
+ enabled?: boolean;
26
+ undoable?: boolean;
27
+ undoableTimeout?: number;
28
+ overtimeOptions?: OvertimeOptions;
29
+ }
30
+ type Result<S extends ContractSchemas> = { data: ContractRecord<S> };
31
+ type Failure = HttpError | UndoError;
32
+ export interface Callbacks<S extends ContractSchemas> {
33
+ onSuccess?: (data: Result<S>, params: ContractDeleteParams<S>) => void | Promise<void>;
34
+ onError?: (error: Failure, params: ContractDeleteParams<S> | undefined) => void | Promise<void>;
35
+ onSettled?: (data: Result<S> | undefined, error: Failure | null, params: ContractDeleteParams<S> | undefined) => void | Promise<void>;
36
+ }
37
+ function observe(callback: () => void | Promise<void>): void {
38
+ try { void Promise.resolve(callback()).catch(() => {}); }
39
+ catch { /* Observers cannot change a completed write's outcome. */ }
40
+ }
41
+ function failure(cause: unknown, dispatched = false): HttpError {
42
+ let code: unknown;
43
+ let status: unknown;
44
+ try {
45
+ if (typeof cause === 'object' && cause !== null) {
46
+ code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
47
+ status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
48
+ }
49
+ } catch { /* Reflection errors remain private. */ }
50
+ const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
51
+ const provider = code === 'DATA_PROVIDER_REQUIRED' && !dispatched;
52
+ const response = code === 'INVALID_PROVIDER_RESPONSE';
53
+ return new HttpError(input ? 'Invalid deletion input' : provider ? 'Data provider unavailable'
54
+ : response ? 'Invalid provider response' : 'Delete failed',
55
+ input ? 422 : provider ? 400 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
56
+ code: input ? 'INVALID_RESOURCE_INPUT' : provider ? 'DATA_PROVIDER_REQUIRED'
57
+ : response ? 'INVALID_PROVIDER_RESPONSE' : 'DELETE_FAILED',
58
+ details: { phase: dispatched ? 'response' : 'input', writeMayHaveSucceeded: dispatched },
59
+ });
60
+ }
61
+ function copyFailure(error: Failure): Failure {
62
+ if (!(error instanceof HttpError)) return new UndoError();
63
+ return new HttpError(error.message, error.statusCode, undefined, {
64
+ ...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
65
+ });
66
+ }
67
+
68
+ /** Undo windows and deletions retain their checked identity and originating auth revision. */
69
+ export function useContractDelete<S extends ContractSchemas>(options: ContractDeleteOptions<S>) {
70
+ contractKey(options.resource);
71
+ const context = captureAdminContext();
72
+ const client = useQueryClient();
73
+ const adminOptions = getAdminOptions();
74
+ const i18n = useTranslation();
75
+ let mounted = true;
76
+ function captureOrigin() {
77
+ const auth = context.authProvider;
78
+ return {
79
+ auth, authScope: captureAuthLiveScope(auth), router: context.routerProvider,
80
+ tenant: context.tenantCacheKey?.__svadminTenant, contract: options.resource, id: options.id,
81
+ };
82
+ }
83
+ function originCurrent(origin: ReturnType<typeof captureOrigin>): boolean {
84
+ try {
85
+ return mounted && options.enabled !== false && origin.auth === context.authProvider &&
86
+ origin.authScope.isCurrent() && origin.router === context.routerProvider &&
87
+ origin.tenant === context.tenantCacheKey?.__svadminTenant && origin.contract === options.resource &&
88
+ Object.is(origin.id, options.id);
89
+ } catch { return false; }
90
+ }
91
+ function captureScope(input: ReturnType<typeof snapshotDeleteParams>) {
92
+ const origin = captureOrigin();
93
+ const contract = origin.contract;
94
+ const id = parseContractId(contract, origin.id);
95
+ const undoable = options.undoable ?? false;
96
+ const timeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
97
+ if ((options.enabled !== undefined && typeof options.enabled !== 'boolean') ||
98
+ typeof undoable !== 'boolean' || !Number.isSafeInteger(timeout) || timeout < 0 || timeout > 2_147_483_647) {
99
+ throw new HttpError('Invalid deletion policy', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
100
+ }
101
+ const captured = captureQueryProvider(context, {
102
+ resource: contract.name, ...definedOptions({ meta: input.meta, dataProviderName: input.dataProviderName }),
103
+ });
104
+ const name = context.resolveDataProviderName(contract.name, input.dataProviderName);
105
+ const raw = context.providers?.[name];
106
+ if (!raw) throw new HttpError('Data provider unavailable', 400, undefined, { code: 'DATA_PROVIDER_REQUIRED' });
107
+ return {
108
+ ...origin, id, undoable, timeout, ...captured, deleteOne: raw.deleteOne.bind(raw),
109
+ matcher: { ...context.queryKeyMatcher(contract.name, input.dataProviderName), contract: contractKey(contract) },
110
+ notification: context.notificationProvider, audit: context.auditLogProvider, live: context.liveProvider,
111
+ key: JSON.stringify([contractKey(contract), id, name, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta, undoable, timeout]),
112
+ };
113
+ }
114
+ function capture(params: ContractDeleteParams<S>) {
115
+ const input = snapshotDeleteParams(params);
116
+ const scope = captureScope(input);
117
+ const { variables } = parseDeleteParams(scope.contract, input);
118
+ return { ...scope, input, variables, signature: JSON.stringify([scope.key, scope.authScope.cacheKey, variables]) };
119
+ }
120
+ type Scope = ReturnType<typeof capture>;
121
+ type Invocation = {
122
+ scope: Scope | undefined; origin: ReturnType<typeof captureOrigin>;
123
+ token: object; callbacks: Callbacks<S>; readScope(): Scope; markDispatched(): void;
124
+ };
125
+ let latest = $state.raw<Scope>();
126
+ let latestToken: object | undefined;
127
+ let active: { scope: Scope; promise: Promise<Result<S>>; token: object; wasDispatched(): boolean } | undefined;
128
+ let waiter: { token: object; cancel(): void } | undefined;
129
+ function targetCurrent(scope: Scope): boolean {
130
+ if (!mounted || options.enabled === false) return false;
131
+ try {
132
+ return captureScope(scope.input).key === scope.key &&
133
+ context.authProvider === scope.auth && context.routerProvider === scope.router;
134
+ } catch { return false; }
135
+ }
136
+ const sessionCurrent = (scope: Scope) => {
137
+ try { return context.authProvider === scope.auth && scope.authScope.isCurrent(); }
138
+ catch { return false; }
139
+ };
140
+ const isCurrent = (scope: Scope) => targetCurrent(scope) && sessionCurrent(scope);
141
+ function cancelled(scope: Scope | undefined, dispatched: boolean, result?: Result<S>): HttpError {
142
+ return new HttpError('Delete scope changed', 409, undefined, {
143
+ code: 'DELETE_CANCELLED', details: {
144
+ writeMayHaveSucceeded: dispatched,
145
+ ...definedOptions({
146
+ id: scope?.id,
147
+ deleted: scope && sessionCurrent(scope) && result ? snapshotPlainData(result.data) : undefined,
148
+ }),
149
+ },
150
+ });
151
+ }
152
+ function waitForUndo(scope: Scope, token: object): Promise<void> {
153
+ return new Promise((resolve, reject) => {
154
+ let settled = false;
155
+ const toastRef: { id: number | undefined } = { id: undefined };
156
+ const finish = (error?: Failure) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ if (toastRef.id !== undefined) removeToast(toastRef.id);
160
+ if (waiter?.token === token) waiter = undefined;
161
+ if (error) reject(error);
162
+ else resolve();
163
+ };
164
+ const onUndo = () => finish(new UndoError());
165
+ const onTimeout = () => finish();
166
+ toast.undoable('Action can be undone', scope.timeout, onUndo, onTimeout);
167
+ toastRef.id = getToasts().find(item => item.onUndo === onUndo && item.onTimeout === onTimeout)?.id;
168
+ if (toastRef.id === undefined) {
169
+ finish(new HttpError('Undo window unavailable', 500));
170
+ return;
171
+ }
172
+ waiter = { token, cancel: () => finish(cancelled(scope, false)) };
173
+ });
174
+ }
175
+ function copyResult(scope: Scope, result: Result<S>): Result<S> {
176
+ return { data: parseContractRecord(scope.contract, result.data) };
177
+ }
178
+ function copyParams(scope: Scope): ContractDeleteParams<S> {
179
+ return parseDeleteParams(scope.contract, scope.input);
180
+ }
181
+ function notify(scope: Scope, current: () => boolean, type: 'success' | 'error', message: string): void {
182
+ observe(() => {
183
+ if (!current()) return;
184
+ if (scope.notification) return scope.notification.open({ type, message });
185
+ if (type === 'success') toast.success(message);
186
+ else toast.error(message);
187
+ });
188
+ }
189
+ async function refresh(scope: Scope, confirmed: boolean) {
190
+ const matcher = { ...scope.matcher, resource: scope.contract.name };
191
+ const owner = { source: scope.source, authSession: scope.authScope.cacheKey };
192
+ try {
193
+ if (confirmed) {
194
+ const detailMatches = (key: readonly unknown[]) => {
195
+ if (!sessionCurrent(scope)) return false;
196
+ const descriptor = readOwnedDataQuery(key, matcher, owner);
197
+ return descriptor?.action === 'one' && descriptor.id === scope.id;
198
+ };
199
+ const queries = client.getQueryCache().findAll({ predicate: query => detailMatches(query.queryKey) });
200
+ // Cache removal callbacks may change session before the next detail is removed.
201
+ for (const query of queries) {
202
+ if (!sessionCurrent(scope)) return;
203
+ client.removeQueries({ predicate: candidate => candidate === query && detailMatches(candidate.queryKey) });
204
+ }
205
+ }
206
+ await invalidateOwnedQueries({
207
+ client, matcher, owner, scopes: ['list', 'many', 'detail'], id: scope.id,
208
+ current: () => sessionCurrent(scope),
209
+ });
210
+ } catch {
211
+ // Cache failure cannot falsify a deletion receipt; currentness is checked after settlement.
212
+ }
213
+ }
214
+ const mutation = createMutation<Result<S>, Failure, Invocation>(() => ({
215
+ retry: false, onMutate: () => undefined, onSuccess: () => {}, onError: () => {}, onSettled: () => {},
216
+ mutationFn: async invocation => {
217
+ let scope: Scope;
218
+ try { scope = invocation.readScope(); }
219
+ catch (cause) {
220
+ const error = failure(cause);
221
+ const current = () => latestToken === invocation.token && originCurrent(invocation.origin);
222
+ if (!current()) throw cancelled(undefined, false);
223
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error), undefined); });
224
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error), undefined); });
225
+ throw error;
226
+ }
227
+ let dispatched = false;
228
+ let result: Result<S> | undefined;
229
+ let rejected: Failure | undefined;
230
+ const current = () => isCurrent(scope) && latestToken === invocation.token;
231
+ const ensureCurrent = () => {
232
+ if (!current() || active?.token !== invocation.token) throw cancelled(scope, dispatched, result);
233
+ };
234
+ try {
235
+ ensureCurrent();
236
+ if (scope.undoable) await waitForUndo(scope, invocation.token);
237
+ ensureCurrent();
238
+ const request = {
239
+ resource: scope.contract.name, id: scope.id,
240
+ ...definedOptions({
241
+ variables: scope.variables === undefined ? undefined : snapshotPlainData(scope.variables),
242
+ meta: scope.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.meta)),
243
+ }),
244
+ };
245
+ dispatched = true;
246
+ invocation.markDispatched();
247
+ const response = await scope.deleteOne(request);
248
+ if (!sessionCurrent(scope)) throw cancelled(scope, dispatched);
249
+ result = decodeDeleteReceipt(scope.contract, scope.id, response);
250
+ } catch (cause) {
251
+ rejected = cause instanceof UndoError && !dispatched ? new UndoError() : failure(cause, dispatched);
252
+ } finally {
253
+ if (result && sessionCurrent(scope)) {
254
+ auditWithProvider({ action: 'delete', resource: scope.contract.name, recordId: scope.id,
255
+ ...definedOptions({ meta: scope.meta }) }, scope.audit, () => sessionCurrent(scope));
256
+ observe(() => {
257
+ if (sessionCurrent(scope)) return scope.live?.publish?.({ type: 'DELETE', resource: scope.contract.name, payload: { ids: [scope.id] } });
258
+ });
259
+ }
260
+ if (dispatched && sessionCurrent(scope)) await refresh(scope, result !== undefined);
261
+ }
262
+ ensureCurrent();
263
+ if (rejected || !result) {
264
+ const error = rejected ?? failure(undefined, dispatched);
265
+ if (error instanceof HttpError) {
266
+ observe(async () => {
267
+ await handleAuthError(copyFailure(error), 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', i18n.t('common.operationFailed'));
273
+ }
274
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error), copyParams(scope)); });
275
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error), copyParams(scope)); });
276
+ throw error;
277
+ }
278
+ const completed = result;
279
+ notify(scope, current, 'success', i18n.t('common.deleteSuccess'));
280
+ observe(() => { if (current()) return invocation.callbacks.onSuccess?.(copyResult(scope, completed), copyParams(scope)); });
281
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(copyResult(scope, completed), null, copyParams(scope)); });
282
+ ensureCurrent();
283
+ return copyResult(scope, completed);
284
+ },
285
+ }));
286
+ function clearActive() {
287
+ waiter?.cancel();
288
+ waiter = undefined;
289
+ latest = undefined;
290
+ active = undefined;
291
+ }
292
+ $effect.pre(() => {
293
+ if (latest && !isCurrent(latest)) { clearActive(); mutation.reset(); }
294
+ });
295
+ $effect(() => () => { mounted = false; latestToken = undefined; clearActive(); });
296
+
297
+ function detachedPromise(promise: Promise<Result<S>>, scope: Scope, token: object, wasDispatched: () => boolean): Promise<Result<S>> {
298
+ return promise.then(data => {
299
+ if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, wasDispatched(), data);
300
+ return copyResult(scope, data);
301
+ }, (cause: unknown) => {
302
+ if (!sessionCurrent(scope)) throw cancelled(scope, wasDispatched());
303
+ throw cause instanceof HttpError || cause instanceof UndoError ? copyFailure(cause) : failure(cause, wasDispatched());
304
+ });
305
+ }
306
+ function mutateAsync(params: ContractDeleteParams<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
+ clearActive();
315
+ const token = {};
316
+ latestToken = token;
317
+ return mutation.mutateAsync(Object.freeze({
318
+ scope: undefined, origin, token, callbacks: definedOptions(observer), readScope: () => { throw error; },
319
+ markDispatched: () => {},
320
+ })).catch((cause: unknown) => {
321
+ if (!originCurrent(origin) || latestToken !== token) throw cancelled(undefined, false);
322
+ throw cause instanceof HttpError ? copyFailure(cause) : failure(cause);
323
+ });
324
+ }
325
+ if (active && isCurrent(active.scope)) {
326
+ if (active.scope.signature === scope.signature) {
327
+ const owner = active.scope;
328
+ const token = active.token;
329
+ return detachedPromise(active.promise, owner, token, active.wasDispatched).then(data => {
330
+ if (isCurrent(owner) && latest === owner) {
331
+ observe(() => observer.onSuccess?.(copyResult(owner, data), copyParams(owner)));
332
+ observe(() => {
333
+ if (isCurrent(owner) && latestToken === token) return observer.onSettled?.(copyResult(owner, data), null, copyParams(owner));
334
+ });
335
+ }
336
+ if (!isCurrent(owner) || latestToken !== token) throw cancelled(owner, true, data);
337
+ return data;
338
+ }, (cause: unknown) => {
339
+ const error = cause instanceof HttpError || cause instanceof UndoError ? cause : failure(cause, true);
340
+ if (isCurrent(owner) && latest === owner) {
341
+ observe(() => observer.onError?.(copyFailure(error), copyParams(owner)));
342
+ observe(() => {
343
+ if (isCurrent(owner) && latestToken === token) return observer.onSettled?.(undefined, copyFailure(error), copyParams(owner));
344
+ });
345
+ }
346
+ throw error;
347
+ });
348
+ }
349
+ return Promise.reject(new HttpError('Delete is already running', 409, undefined, { code: 'DELETE_BUSY' }));
350
+ }
351
+ clearActive();
352
+ const token = {};
353
+ latestToken = token;
354
+ latest = scope;
355
+ const target = scope;
356
+ let dispatched = false;
357
+ const wasDispatched = () => dispatched;
358
+ // Keep scope and token identities intact through Svelte's mutation-state projection.
359
+ const promise = mutation.mutateAsync(Object.freeze({
360
+ scope, origin, token, callbacks: definedOptions(observer), readScope: () => target,
361
+ markDispatched: () => { dispatched = true; },
362
+ })).finally(() => { if (active?.token === token) active = undefined; });
363
+ active = { scope, token, promise, wasDispatched };
364
+ return detachedPromise(promise, target, token, wasDispatched);
365
+ }
366
+ // Copy reads while retaining TanStack's success/error discriminated union.
367
+ function detachedState(state: typeof mutation): typeof mutation;
368
+ function detachedState(state: typeof mutation): object {
369
+ return replaceReactiveUnionMembers(state, {
370
+ get data() {
371
+ const scope = state.variables?.scope;
372
+ return state.data && scope ? copyResult(scope, state.data) : undefined;
373
+ },
374
+ get error() { return state.error ? copyFailure(state.error) : null; },
375
+ get failureReason() { return state.failureReason ? copyFailure(state.failureReason) : null; },
376
+ });
377
+ }
378
+ const result = replaceReactiveUnionMembers(detachedState(mutation), {
379
+ get variables() {
380
+ const scope = mutation.variables?.scope;
381
+ return scope ? copyParams(scope) : undefined;
382
+ },
383
+ mutateAsync,
384
+ mutate(params: ContractDeleteParams<S>, callbacks?: Callbacks<S>): void {
385
+ void mutateAsync(params, callbacks).catch(() => {});
386
+ },
387
+ reset() { latestToken = undefined; clearActive(); mutation.reset(); },
388
+ });
389
+ const idle: typeof result = {
390
+ context: undefined, data: undefined, error: null, failureCount: 0, failureReason: null,
391
+ isError: false, isIdle: true, isPending: false, isPaused: false, isSuccess: false,
392
+ status: 'idle', submittedAt: 0, variables: undefined,
393
+ mutate: result.mutate, mutateAsync: result.mutateAsync, reset: result.reset,
394
+ };
395
+ const view = createQueryResultView(() => {
396
+ const invocation = mutation.variables;
397
+ if (!mounted || (invocation && (!originCurrent(invocation.origin) || latestToken !== invocation.token ||
398
+ (invocation.scope && !isCurrent(invocation.scope))))) return idle;
399
+ return result;
400
+ });
401
+ const overtime = createOvertimeTracker(() => view.isPending, options.overtimeOptions ?? adminOptions.overtime);
402
+ return { mutation: view, get overtime() { return overtime; } };
403
+ }
@@ -0,0 +1,60 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { snapshotPlainData } from './plain-data';
3
+ import { checkExact } from './schema-validation';
4
+ import { parseContractId, parseContractRecord, parseContractDeleteInput, type ContractSchemas, type ContractId, type ResourceContract } from './resource-contract';
5
+ import type { ContractDeleteParams } from './delete-hooks.svelte';
6
+ import { definedOptions } from './defined-options';
7
+ import { decodeManyResult, decodeOneResult, rejectProviderResponse } from './record-decoder';
8
+ import { HttpError } from './types';
9
+
10
+ const envelope = Type.Object({
11
+ ids: Type.Array(Type.Union([Type.String(), Type.Number()]), { minItems: 1, uniqueItems: true }),
12
+ variables: Type.Optional(Type.Unknown()),
13
+ meta: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
14
+ dataProviderName: Type.Optional(Type.String({ minLength: 1 })),
15
+ }, { additionalProperties: false });
16
+ export type ContractDeleteManyParams<S extends ContractSchemas> =
17
+ ContractDeleteParams<S> & { ids: NoInfer<ContractId<S>>[] };
18
+
19
+ export function snapshotDeleteManyParams(value: unknown) {
20
+ try {
21
+ const input = snapshotPlainData(value);
22
+ if (checkExact(envelope, input)) return input;
23
+ } catch { /* Input failures must not disclose transport values or invoke accessors. */ }
24
+ throw new HttpError('Invalid batch deletion input', 422, undefined, {
25
+ code: 'INVALID_RESOURCE_INPUT', details: { writeMayHaveSucceeded: false },
26
+ });
27
+ }
28
+
29
+ /** The private ID and delete schemas justify the operation-specific parameter type. */
30
+ export function parseDeleteManyParams<S extends ContractSchemas>(
31
+ contract: ResourceContract<S>, value: unknown,
32
+ ): ContractDeleteManyParams<S>;
33
+ export function parseDeleteManyParams(contract: ResourceContract, value: unknown): object {
34
+ const input = snapshotDeleteManyParams(value);
35
+ return definedOptions({
36
+ ids: input.ids.map(id => parseContractId(contract, id)),
37
+ variables: parseContractDeleteInput(contract, input.variables),
38
+ meta: input.meta, dataProviderName: input.dataProviderName,
39
+ });
40
+ }
41
+
42
+ export function decodeDeleteManyReceipt<S extends ContractSchemas>(
43
+ contract: ResourceContract<S>, ids: readonly (string | number)[], response: unknown, batch: boolean,
44
+ ) {
45
+ try {
46
+ const snapshot = snapshotPlainData(response);
47
+ const records = batch
48
+ ? decodeManyResult(snapshot, value => parseContractRecord(contract, value), true).data
49
+ : [decodeOneResult(snapshot, value => parseContractRecord(contract, value), true).data];
50
+ if (records.length !== ids.length) return rejectProviderResponse(true);
51
+ const expected = new Set(ids);
52
+ const seen = new Set<string | number>();
53
+ for (const record of records) {
54
+ const id = parseContractId(contract, record.id);
55
+ if (!expected.has(id) || seen.has(id)) return rejectProviderResponse(true);
56
+ seen.add(id);
57
+ }
58
+ return records;
59
+ } catch { return rejectProviderResponse(true); }
60
+ }