@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,373 @@
1
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
+ import { captureAdminContext } from './context.svelte';
3
+ import { captureQueryProvider } from './query-snapshot';
4
+ import { snapshotUpdateManyParams, parseUpdateManyParams, decodeUpdateManyReceipt, UpdateManyPartialError,
5
+ type ContractUpdateManyParams } from './update-many-contract';
6
+ import { contractKey, parseContractRecord, 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 } from './query-invalidation';
12
+ import { createQueryResultView } from './query-session.svelte';
13
+ import { createOvertimeTracker, type OvertimeOptions } from './hook-utils.svelte';
14
+ import { getAdminOptions } from './options.svelte';
15
+ import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
16
+ import { auditWithProvider } from './audit';
17
+ import { HttpError } from './types';
18
+ import { useTranslation } from './i18n.svelte';
19
+ import { toast } from './toast.svelte';
20
+
21
+ export interface ContractUpdateManyOptions<S extends ContractSchemas> {
22
+ resource: ResourceContract<S>;
23
+ enabled?: boolean;
24
+ overtimeOptions?: OvertimeOptions;
25
+ }
26
+ type Result<S extends ContractSchemas> = { data: ContractRecord<S>[] };
27
+ type Failure = HttpError | UpdateManyPartialError;
28
+ export interface Callbacks<S extends ContractSchemas> {
29
+ onSuccess?: (data: Result<S>) => void | Promise<void>;
30
+ onError?: (error: Failure) => void | Promise<void>;
31
+ onSettled?: (data: Result<S> | undefined, error: Failure | null) => void | Promise<void>;
32
+ }
33
+ function observe(callback: () => void | Promise<void>): void {
34
+ try { void Promise.resolve(callback()).catch(() => {}); }
35
+ catch { /* Observers cannot change a completed write's outcome. */ }
36
+ }
37
+ function failure(cause: unknown, dispatched = false): HttpError {
38
+ let code: unknown;
39
+ let status: unknown;
40
+ try {
41
+ if (typeof cause === 'object' && cause !== null) {
42
+ code = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
43
+ status = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
44
+ }
45
+ } catch { /* Reflection errors remain private. */ }
46
+ const input = code === 'INVALID_RESOURCE_INPUT' && !dispatched;
47
+ const response = code === 'INVALID_PROVIDER_RESPONSE';
48
+ return new HttpError(input ? 'Invalid batch update input' : response ? 'Invalid provider response' : 'Batch update failed',
49
+ input ? 422 : response ? 502 : status === 401 || status === 403 ? status : 500, undefined, {
50
+ code: input ? 'INVALID_RESOURCE_INPUT' : response ? 'INVALID_PROVIDER_RESPONSE' : 'UPDATE_MANY_FAILED',
51
+ details: { writeMayHaveSucceeded: dispatched },
52
+ });
53
+ }
54
+ function copyFailure(error: Failure): Failure {
55
+ if (error instanceof UpdateManyPartialError) {
56
+ return new UpdateManyPartialError(error.succeededIds, error.failedIds, error.causes.map(cause => failure(cause, true)));
57
+ }
58
+ return new HttpError(error.message, error.statusCode, undefined, {
59
+ ...definedOptions({ code: error.code }), details: snapshotPlainData(error.details ?? {}),
60
+ });
61
+ }
62
+
63
+ /** Batch updates and their progress retain the captured resource and auth revision. */
64
+ export function useContractUpdateMany<S extends ContractSchemas>(options: ContractUpdateManyOptions<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 snapshotUpdateManyParams>) {
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, update: raw.update.bind(raw), updateMany: raw.updateMany?.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: ContractUpdateManyParams<S>) {
102
+ const input = snapshotUpdateManyParams(params);
103
+ const scope = captureScope(input);
104
+ const { ids, variables } = parseUpdateManyParams(scope.contract, input);
105
+ if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
106
+ throw new HttpError('Invalid enabled value', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
107
+ }
108
+ return { ...scope, input, ids, variables, signature: JSON.stringify([scope.key, scope.authScope.cacheKey, ids, variables]) };
109
+ }
110
+ type Scope = ReturnType<typeof capture>;
111
+ type Progress = { attempted: ContractId<S>[]; succeeded: ContractId<S>[]; failed: ContractId<S>[] };
112
+ const newProgress = (): Progress => ({ attempted: [], succeeded: [], failed: [] });
113
+ type Invocation = {
114
+ scope: Scope | undefined; origin: ReturnType<typeof captureOrigin>;
115
+ token: object; callbacks: Callbacks<S>; readScope(): Scope; readProgress(): Progress;
116
+ };
117
+ let latest = $state.raw<Scope>();
118
+ let latestToken: object | undefined;
119
+ let active: { scope: Scope; promise: Promise<Result<S>>; token: object; progress: Progress } | undefined;
120
+ function targetCurrent(scope: Scope): boolean {
121
+ if (!mounted || options.enabled === false) return false;
122
+ try {
123
+ return captureScope(scope.input).key === scope.key &&
124
+ context.authProvider === scope.auth && context.routerProvider === scope.router;
125
+ } catch { return false; }
126
+ }
127
+ const sessionCurrent = (scope: Scope) => {
128
+ try { return context.authProvider === scope.auth && scope.authScope.isCurrent(); }
129
+ catch { return false; }
130
+ };
131
+ const isCurrent = (scope: Scope) => targetCurrent(scope) && sessionCurrent(scope);
132
+ function cancelled(scope: Scope | undefined, progress: Progress): HttpError {
133
+ return new HttpError('Batch update scope changed', 409, undefined, {
134
+ code: 'UPDATE_MANY_CANCELLED', details: {
135
+ writeMayHaveSucceeded: progress.attempted.length > 0,
136
+ attemptedIds: [...progress.attempted], failedIds: [...progress.failed],
137
+ ...definedOptions({
138
+ succeededIds: scope && sessionCurrent(scope) ? [...progress.succeeded] : undefined,
139
+ unattemptedIds: scope?.ids.filter(id => !progress.attempted.includes(id)),
140
+ }),
141
+ },
142
+ });
143
+ }
144
+ function copyResult(scope: Scope, data: Result<S>): Result<S> {
145
+ return { data: data.data.map(record => parseContractRecord(scope.contract, record)) };
146
+ }
147
+ function notify(scope: Scope, current: () => boolean, type: 'success' | 'error', message: string): void {
148
+ observe(() => {
149
+ if (!current()) return;
150
+ if (scope.notification) return scope.notification.open({ type, message });
151
+ if (type === 'success') toast.success(message);
152
+ else toast.error(message);
153
+ });
154
+ }
155
+ async function refresh(scope: Scope, attempted: readonly (string | number)[]) {
156
+ if (!attempted.length) return;
157
+ const owner = {
158
+ client, matcher: { ...scope.matcher, resource: scope.contract.name },
159
+ owner: { source: scope.source, authSession: scope.authScope.cacheKey },
160
+ current: () => sessionCurrent(scope),
161
+ };
162
+ // Every started refresh settles even if a sibling fails or changes authentication.
163
+ await Promise.allSettled([
164
+ invalidateOwnedQueries({ ...owner, scopes: ['list', 'many'] }),
165
+ ...attempted.map(id => invalidateOwnedQueries({ ...owner, scopes: ['detail'], id })),
166
+ ]);
167
+ }
168
+ const mutation = createMutation<Result<S>, Failure, Invocation>(() => ({
169
+ retry: false, onMutate: () => undefined, onSuccess: () => {}, onError: () => {}, onSettled: () => {},
170
+ mutationFn: async invocation => {
171
+ let scope: Scope;
172
+ try { scope = invocation.readScope(); }
173
+ catch (cause) {
174
+ const error = failure(cause);
175
+ const current = () => latestToken === invocation.token && originCurrent(invocation.origin);
176
+ if (!current()) throw cancelled(undefined, invocation.readProgress());
177
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error)); });
178
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error)); });
179
+ throw error;
180
+ }
181
+ const progress = invocation.readProgress();
182
+ const { attempted, succeeded, failed } = progress;
183
+ const causes: HttpError[] = [];
184
+ const data: ContractRecord<S>[] = [];
185
+ let rejected: Failure | undefined;
186
+ const current = () => isCurrent(scope) && latestToken === invocation.token;
187
+ const ensureCurrent = () => {
188
+ if (!current() || active?.token !== invocation.token) throw cancelled(scope, progress);
189
+ };
190
+ const request = () => ({
191
+ resource: scope.contract.name, variables: snapshotPlainData(scope.variables),
192
+ ...definedOptions({ meta: scope.meta === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.meta)) }),
193
+ });
194
+ try {
195
+ ensureCurrent();
196
+ if (scope.updateMany) {
197
+ const params = { ...request(), ids: [...scope.ids] };
198
+ attempted.push(...scope.ids);
199
+ const response = await scope.updateMany(params);
200
+ if (!sessionCurrent(scope)) throw cancelled(scope, progress);
201
+ data.push(...decodeUpdateManyReceipt(scope.contract, scope.ids, response, true));
202
+ succeeded.push(...scope.ids);
203
+ } else {
204
+ for (const id of scope.ids) {
205
+ ensureCurrent();
206
+ const params = { ...request(), id };
207
+ attempted.push(id);
208
+ try {
209
+ const response = await scope.update(params);
210
+ if (!sessionCurrent(scope)) throw cancelled(scope, progress);
211
+ data.push(...decodeUpdateManyReceipt(scope.contract, [id], response, false));
212
+ succeeded.push(id);
213
+ } catch (cause) {
214
+ if (!sessionCurrent(scope)) throw cancelled(scope, progress);
215
+ failed.push(id);
216
+ causes.push(failure(cause, true));
217
+ }
218
+ }
219
+ if (failed.length) rejected = succeeded.length
220
+ ? new UpdateManyPartialError(succeeded, failed, causes) : causes[0] ?? failure(undefined, true);
221
+ }
222
+ } catch (cause) {
223
+ rejected = failure(cause, attempted.length > 0);
224
+ } finally {
225
+ for (const id of succeeded) {
226
+ if (!sessionCurrent(scope)) break;
227
+ auditWithProvider({ action: 'update', resource: scope.contract.name, recordId: id,
228
+ ...definedOptions({ meta: scope.meta }) }, scope.audit, () => sessionCurrent(scope));
229
+ }
230
+ if (succeeded.length) observe(() => {
231
+ if (sessionCurrent(scope)) return scope.live?.publish?.({
232
+ type: 'UPDATE', resource: scope.contract.name, payload: { ids: [...succeeded] },
233
+ });
234
+ });
235
+ if (sessionCurrent(scope)) await refresh(scope, attempted);
236
+ }
237
+ ensureCurrent();
238
+ if (rejected) {
239
+ const error = rejected;
240
+ const authError = causes.find(cause => cause.statusCode === 401 || cause.statusCode === 403) ?? error;
241
+ observe(async () => {
242
+ await handleAuthError(failure(authError, true), context,
243
+ () => targetCurrent(scope) && latestToken === invocation.token,
244
+ () => clearAuthQueries(client, scope.auth), scope.authScope,
245
+ () => mounted && latestToken !== invocation.token);
246
+ });
247
+ notify(scope, current, 'error', i18n.t('common.operationFailed'));
248
+ observe(() => { if (current()) return invocation.callbacks.onError?.(copyFailure(error)); });
249
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(undefined, copyFailure(error)); });
250
+ throw error;
251
+ }
252
+ const result = { data };
253
+ notify(scope, current, 'success', i18n.t('common.updateSuccess'));
254
+ observe(() => { if (current()) return invocation.callbacks.onSuccess?.(copyResult(scope, result)); });
255
+ observe(() => { if (current()) return invocation.callbacks.onSettled?.(copyResult(scope, result), null); });
256
+ ensureCurrent();
257
+ return copyResult(scope, result);
258
+ },
259
+ }));
260
+ $effect.pre(() => {
261
+ if (latest && !isCurrent(latest)) {
262
+ latest = undefined;
263
+ active = undefined;
264
+ mutation.reset();
265
+ }
266
+ });
267
+ $effect(() => () => { mounted = false; active = undefined; latestToken = undefined; });
268
+
269
+ function detachedPromise(promise: Promise<Result<S>>, scope: Scope, token: object, progress: Progress): Promise<Result<S>> {
270
+ return promise.then(data => {
271
+ if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, progress);
272
+ return copyResult(scope, data);
273
+ }, (cause: unknown) => {
274
+ if (!isCurrent(scope) || latestToken !== token) throw cancelled(scope, progress);
275
+ throw cause instanceof HttpError ? copyFailure(cause) : failure(cause, progress.attempted.length > 0);
276
+ });
277
+ }
278
+ function mutateAsync(params: ContractUpdateManyParams<S>, callbacks: Callbacks<S> = {}): Promise<Result<S>> {
279
+ const origin = captureOrigin();
280
+ const observer = { onSuccess: callbacks.onSuccess, onError: callbacks.onError, onSettled: callbacks.onSettled };
281
+ let scope: Scope;
282
+ try { scope = capture(params); }
283
+ catch (cause) {
284
+ const error = failure(cause);
285
+ if (active && isCurrent(active.scope)) return Promise.reject(error);
286
+ latest = undefined;
287
+ active = undefined;
288
+ const token = {};
289
+ const progress = newProgress();
290
+ latestToken = token;
291
+ return mutation.mutateAsync(Object.freeze({
292
+ scope: undefined, origin, token, callbacks: definedOptions(observer), readScope: () => { throw error; },
293
+ readProgress: () => progress,
294
+ })).catch((cause: unknown) => {
295
+ if (!originCurrent(origin) || latestToken !== token) throw cancelled(undefined, progress);
296
+ throw cause instanceof HttpError ? copyFailure(cause) : failure(cause);
297
+ });
298
+ }
299
+ if (active && isCurrent(active.scope)) {
300
+ if (active.scope.signature === scope.signature) {
301
+ const owner = active.scope;
302
+ const { token, progress } = active;
303
+ const current = () => isCurrent(owner) && latestToken === token;
304
+ return detachedPromise(active.promise, owner, token, progress).then(data => {
305
+ if (current()) {
306
+ observe(() => observer.onSuccess?.(copyResult(owner, data)));
307
+ observe(() => { if (current()) return observer.onSettled?.(copyResult(owner, data), null); });
308
+ }
309
+ if (!current()) throw cancelled(owner, progress);
310
+ return data;
311
+ }, (cause: unknown) => {
312
+ const error = cause instanceof HttpError ? cause : failure(cause, progress.attempted.length > 0);
313
+ if (current()) {
314
+ observe(() => observer.onError?.(copyFailure(error)));
315
+ observe(() => { if (current()) return observer.onSettled?.(undefined, copyFailure(error)); });
316
+ }
317
+ if (!current()) throw cancelled(owner, progress);
318
+ throw error;
319
+ });
320
+ }
321
+ return Promise.reject(new HttpError('Batch update is already running', 409, undefined, { code: 'UPDATE_MANY_BUSY' }));
322
+ }
323
+ const token = {};
324
+ latestToken = token;
325
+ latest = scope;
326
+ const target = scope;
327
+ const progress = newProgress();
328
+ // Preserve scope and token identity through Svelte's mutation-state projection.
329
+ const promise = mutation.mutateAsync(Object.freeze({
330
+ scope, origin, token, callbacks: definedOptions(observer), readScope: () => target,
331
+ readProgress: () => progress,
332
+ })).finally(() => { if (active?.token === token) active = undefined; });
333
+ active = { scope, token, promise, progress };
334
+ return detachedPromise(promise, target, token, progress);
335
+ }
336
+ // Copy reads while retaining TanStack's success/error discriminated union.
337
+ function detachedState(state: typeof mutation): typeof mutation;
338
+ function detachedState(state: typeof mutation): object {
339
+ return replaceReactiveUnionMembers(state, {
340
+ get data() {
341
+ const scope = state.variables?.scope;
342
+ return state.data && scope ? copyResult(scope, state.data) : undefined;
343
+ },
344
+ get error() { return state.error ? copyFailure(state.error) : null; },
345
+ get failureReason() { return state.failureReason ? copyFailure(state.failureReason) : null; },
346
+ });
347
+ }
348
+ const result = replaceReactiveUnionMembers(detachedState(mutation), {
349
+ get variables() {
350
+ const scope = mutation.variables?.scope;
351
+ return scope ? parseUpdateManyParams(scope.contract, scope.input) : undefined;
352
+ },
353
+ mutateAsync,
354
+ mutate(params: ContractUpdateManyParams<S>, callbacks?: Callbacks<S>): void {
355
+ void mutateAsync(params, callbacks).catch(() => {});
356
+ },
357
+ reset() { active = undefined; latest = undefined; latestToken = undefined; mutation.reset(); },
358
+ });
359
+ const idle: typeof result = {
360
+ context: undefined, data: undefined, error: null, failureCount: 0, failureReason: null,
361
+ isError: false, isIdle: true, isPending: false, isPaused: false, isSuccess: false,
362
+ status: 'idle', submittedAt: 0, variables: undefined,
363
+ mutate: result.mutate, mutateAsync: result.mutateAsync, reset: result.reset,
364
+ };
365
+ const view = createQueryResultView(() => {
366
+ const invocation = mutation.variables;
367
+ if (!mounted || (invocation && (!originCurrent(invocation.origin) || latestToken !== invocation.token ||
368
+ (invocation.scope && !isCurrent(invocation.scope))))) return idle;
369
+ return result;
370
+ });
371
+ const overtime = createOvertimeTracker(() => view.isPending, options.overtimeOptions ?? adminOptions.overtime);
372
+ return { mutation: view, get overtime() { return overtime; } };
373
+ }
package/src/url-sync.ts CHANGED
@@ -16,16 +16,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
16
16
  function isFilter(value: unknown, depth: number, budget: { remaining: number }): value is Filter {
17
17
  if (depth > 20 || budget.remaining-- <= 0 || !isRecord(value)) return false;
18
18
 
19
- if (typeof value.field === 'string') {
20
- return value.field.trim().length > 0
21
- && typeof value.operator === 'string'
22
- && CRUD_OPERATORS.has(value.operator)
19
+ if (typeof value['field'] === 'string') {
20
+ return value['field'].trim().length > 0
21
+ && typeof value['operator'] === 'string'
22
+ && CRUD_OPERATORS.has(value['operator'])
23
23
  && 'value' in value;
24
24
  }
25
25
 
26
- return (value.operator === 'and' || value.operator === 'or')
27
- && Array.isArray(value.value)
28
- && value.value.every((entry) => isFilter(entry, depth + 1, budget));
26
+ return (value['operator'] === 'and' || value['operator'] === 'or')
27
+ && Array.isArray(value['value'])
28
+ && value['value'].every((entry) => isFilter(entry, depth + 1, budget));
29
29
  }
30
30
 
31
31
  function parseFilters(value: string): Filter[] | undefined {
@@ -46,8 +46,8 @@ function positiveIntegerParam(value: string | undefined, max: number): string |
46
46
  }
47
47
 
48
48
  /**
49
- * 保留从列表进入 CRUD 页面时可安全恢复的查询状态。
50
- * 未知参数不会跨页面传播,避免把 detail、tenantId 或敏感参数带入记录路由。
49
+ * Preserves query state that can be safely restored when navigating from a list to CRUD pages.
50
+ * Unknown parameters do not propagate across pages, preventing detail, tenantId, or sensitive values from entering record routes.
51
51
  */
52
52
  export function sanitizeListQueryParams(params: Record<string, string>): Record<string, string> {
53
53
  const result: Record<string, string> = {};
@@ -128,7 +128,8 @@ export function readURLState(adminContext: AdminContextAccessor = captureAdminCo
128
128
  }
129
129
 
130
130
  export function writeURLState(
131
- state: URLState,
131
+ // An own undefined field clears that URL parameter; an absent field leaves it unchanged.
132
+ state: { [K in keyof URLState]?: URLState[K] | undefined },
132
133
  adminContext: AdminContextAccessor = captureAdminContext(),
133
134
  type: 'push' | 'replace' = 'replace',
134
135
  ): void {
package/src/useCan.ts CHANGED
@@ -1,14 +1,30 @@
1
1
  // useCan — reactive permission check hook with TanStack Query integration
2
2
 
3
- import { createQuery } from '@tanstack/svelte-query';
3
+ import { hashKey, useQueryClient, type QueryClient } from '@tanstack/svelte-query';
4
+ import { onDestroy } from 'svelte';
5
+ import { definedOptions } from './defined-options';
4
6
  import { captureAdminContext } from './context.svelte';
5
- import type { Action, CanResult } from './permissions.svelte';
7
+ import type { Action, CanParams, CanResult } from './permissions.svelte';
8
+ import { accessControlSource, snapshotCanParams, decodeCanResult, prepareCanCheck } from './access-control-contract';
9
+ import { createSessionQuery } from './session-query.svelte';
10
+ import { supersededQuerySession } from './query-session.svelte';
11
+ import { HttpError } from './types';
12
+ import { captureAuthLiveScope } from './auth-hooks.svelte';
13
+
14
+ interface AccessQueryOwner {
15
+ readonly key: string;
16
+ readonly current: () => boolean;
17
+ readonly execute: () => Promise<CanResult>;
18
+ }
19
+ const accessQueryOwners = new WeakMap<QueryClient, Set<AccessQueryOwner>>();
6
20
 
7
21
  export interface UseCanOptions {
8
22
  resource: string;
9
23
  action: Action;
10
- params?: Record<string, unknown>;
11
- meta?: Record<string, unknown>;
24
+ /** Fixed record target; overrides an ID in additional permission params after validation. */
25
+ id?: string | number;
26
+ params?: NonNullable<CanParams['params']>;
27
+ meta?: Record<string,unknown>;
12
28
  queryOptions?: {
13
29
  enabled?: boolean;
14
30
  staleTime?: number;
@@ -17,7 +33,7 @@ export interface UseCanOptions {
17
33
 
18
34
  export interface UseCanResult {
19
35
  readonly allowed: boolean;
20
- readonly reason: string | undefined;
36
+ readonly reason: string|undefined;
21
37
  readonly isLoading: boolean;
22
38
  }
23
39
 
@@ -32,38 +48,87 @@ export interface UseCanResult {
32
48
  * ```
33
49
  */
34
50
  export function useCan(options: () => UseCanOptions): UseCanResult {
35
- const adminContext = captureAdminContext();
36
- const query = createQuery<CanResult>(() => {
37
- const opts = options();
38
- const provider = adminContext.accessControlProvider;
51
+ const adminContext=captureAdminContext();
52
+ const client = useQueryClient();
53
+ const peers = accessQueryOwners.get(client) ?? new Set<AccessQueryOwner>();
54
+ accessQueryOwners.set(client, peers);
55
+ let disposed = false;
56
+ let release = () => {};
57
+ onDestroy(() => { disposed = true; release(); });
58
+
59
+ function captureTarget() {
60
+ const opts=options();
61
+ const provider=adminContext.accessControlProvider;
62
+ const { enabled = true, staleTime = 5 * 60 * 1000 } = opts.queryOptions ?? {};
63
+ if (typeof enabled !== 'boolean' || typeof staleTime !== 'number' || Number.isNaN(staleTime) || staleTime < 0) {
64
+ throw new HttpError('Invalid access control query settings', 422, undefined, { code: 'INVALID_ACCESS_CONTROL_INPUT' });
65
+ }
66
+ const explicit=snapshotCanParams(definedOptions({
67
+ resource: opts.resource, action: opts.action, params: opts.params, meta: opts.meta,
68
+ }));
69
+ const params=snapshotCanParams({
70
+ ...explicit,
71
+ ...definedOptions({
72
+ params: opts.id === undefined ? explicit.params : { ...explicit.params, id: opts.id },
73
+ meta: adminContext.getProviderMeta(opts.resource, explicit.meta),
74
+ }),
75
+ });
39
76
  return {
40
- queryKey: adminContext.queryKeys(opts.resource).access.can(opts.resource, {
41
- action: opts.action,
42
- params: opts.params,
43
- meta: opts.meta,
77
+ resource: params.resource, params, provider, enabled, staleTime,
78
+ queryKey: adminContext.queryKeys(opts.resource).access.can(opts.resource,{
79
+ ...params,
80
+ source: accessControlSource(provider),
44
81
  }),
82
+ };
83
+ }
84
+
85
+ const query=createSessionQuery<CanResult>(adminContext, () => {
86
+ const target = captureTarget();
87
+ const signature = hashKey(target.queryKey);
88
+ const authProvider = adminContext.authProvider;
89
+ const auth = captureAuthLiveScope(authProvider);
90
+ const key = hashKey([target.queryKey, auth.cacheKey]);
91
+ const execute = prepareCanCheck(target.provider, target.params);
92
+ const owner: AccessQueryOwner = {
93
+ key, execute,
94
+ current() {
95
+ if (disposed || adminContext.authProvider !== authProvider || !auth.isCurrent()) return false;
96
+ try {
97
+ const current = captureTarget();
98
+ return current.enabled && current.provider === target.provider && hashKey(current.queryKey) === signature;
99
+ } catch {
100
+ return false;
101
+ }
102
+ },
103
+ };
104
+ release();
105
+ if (!disposed) peers.add(owner);
106
+ release = () => { peers.delete(owner); };
107
+ return {
108
+ resource: target.resource, queryKey: target.queryKey,
109
+ successNotification: false, errorNotification: false,
110
+ allowRetiredQueryFn: true,
111
+ isTargetCurrent: () => !disposed && options().queryOptions?.enabled !== false,
45
112
  queryFn: async () => {
46
- if (!provider) return { can: true };
47
- const result = await provider.can({
48
- resource: opts.resource,
49
- action: opts.action,
50
- params: opts.params,
51
- meta: adminContext.getProviderMeta(opts.resource, opts.meta),
52
- });
53
- return Array.isArray(result) ? (result[0] ?? { can: false }) : result;
113
+ // Native cache refreshes may retain an unmounted peer's query function.
114
+ for (const peer of peers) {
115
+ if (peer.key === key && peer.current()) return peer.execute();
116
+ }
117
+ throw supersededQuerySession();
54
118
  },
55
- enabled: opts.queryOptions?.enabled ?? true,
56
- staleTime: opts.queryOptions?.staleTime ?? 5 * 60 * 1000,
119
+ decode: decodeCanResult,
120
+ enabled: target.enabled, staleTime: target.staleTime,
57
121
  };
58
122
  });
59
123
 
60
- return {
61
- get allowed() {
62
- const p = adminContext.accessControlProvider;
63
- if (!p) return true;
64
- return (query.data as CanResult | undefined)?.can ?? false;
124
+ return Object.freeze({
125
+ get allowed() {
126
+ const auth = captureAuthLiveScope(adminContext.authProvider);
127
+ if (adminContext.accessControlProvider === null) return query.isEnabled && auth.isCurrent();
128
+ if (!auth.isCurrent()) return false;
129
+ return query.isEnabled && query.isSuccess && query.data?.can === true;
65
130
  },
66
- get reason() { return (query.data as CanResult | undefined)?.reason; },
67
- get isLoading() { return query.isLoading; },
68
- };
131
+ get reason() { return query.isEnabled && query.isSuccess ? query.data?.reason : undefined; },
132
+ get isLoading() { return adminContext.accessControlProvider !== null && query.isLoading; },
133
+ });
69
134
  }
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
1
  // useParsed — parse current URL hash into structured route info
3
2
 
4
3
  import { registerRouterSync } from './router';
@@ -6,10 +5,10 @@ import { captureAdminContext } from './context.svelte';
6
5
  import { parseResourceActionSegments } from './route-parsing';
7
6
 
8
7
  interface ParsedRoute {
9
- resource?: string;
10
- resourcePath?: string;
11
- action?: 'list' | 'create' | 'edit' | 'show' | 'clone';
12
- id?: string;
8
+ resource: string | undefined;
9
+ resourcePath: string | undefined;
10
+ action: 'list' | 'create' | 'edit' | 'show' | 'clone' | undefined;
11
+ id: string | undefined;
13
12
  /** Query params parsed from the URL search string (e.g. ?page=2). */
14
13
  params: Record<string, string>;
15
14
  /** Parent path params derived from nested routes (e.g. /teams/123/users -> teamId). */
@@ -25,7 +24,7 @@ export function syncGlobalPath(): void {
25
24
  routeVersion++;
26
25
  }
27
26
 
28
- /** 简单的英文名词单数化,处理常见复数模式(-ies, -ses, -es, -s) */
27
+ /** Basic English noun singularization for common plural suffixes: -ies, -ses, -es, and -s. */
29
28
  function singularize(word: string): string {
30
29
  if (word.endsWith('ies') && word.length > 3) return word.slice(0, -3) + 'y';
31
30
  if (word.endsWith('ses') && word.length > 3) return word.slice(0, -2);
@@ -37,8 +36,8 @@ function singularize(word: string): string {
37
36
  registerRouterSync(syncGlobalPath);
38
37
 
39
38
  if (typeof window !== 'undefined') {
40
- if (!(window as any).__svadminParsedInit) {
41
- (window as any).__svadminParsedInit = true;
39
+ if (!('__svadminParsedInit' in window) || window.__svadminParsedInit !== true) {
40
+ Object.defineProperty(window, '__svadminParsedInit', { value: true, configurable: true });
42
41
  window.addEventListener('hashchange', syncGlobalPath);
43
42
  window.addEventListener('popstate', syncGlobalPath);
44
43
  }
@@ -68,10 +67,13 @@ export function useParsed(): ParsedRoute {
68
67
 
69
68
  const parsed = $derived.by(() => {
70
69
  const p = activePath;
71
- const result: ParsedRoute = { params: {}, parentParams: {} };
70
+ const result: ParsedRoute = {
71
+ resource: undefined, resourcePath: undefined, action: undefined, id: undefined,
72
+ params: {}, parentParams: {},
73
+ };
72
74
 
73
75
  // Parse query params from hash
74
- const [pathname, queryString] = p.split('?');
76
+ const [pathname = '/', queryString] = p.split('?');
75
77
  if (queryString) {
76
78
  const sp = new URLSearchParams(queryString);
77
79
  for (const [k, v] of sp.entries()) {
@@ -86,7 +88,8 @@ export function useParsed(): ParsedRoute {
86
88
  const resourceNames = resources.map(r => r.name);
87
89
  let resourceIndex = -1;
88
90
  for (let i = segments.length - 1; i >= 0; i--) {
89
- if (resourceNames.includes(segments[i])) {
91
+ const segment = segments[i];
92
+ if (segment !== undefined && resourceNames.includes(segment)) {
90
93
  resourceIndex = i;
91
94
  break;
92
95
  }
@@ -101,10 +104,11 @@ export function useParsed(): ParsedRoute {
101
104
  // injection only applies to path-derived parent params, never to
102
105
  // arbitrary query params like ?tenantId=1.
103
106
  for (let i = 0; i < resourceIndex; i += 2) {
104
- if (segments[i] && segments[i + 1]) {
105
- const parentName = segments[i];
107
+ const parentName = segments[i];
108
+ const parentId = segments[i + 1];
109
+ if (parentName && parentId) {
106
110
  const singular = singularize(parentName);
107
- result.parentParams[`${singular}Id`] = segments[i + 1];
111
+ result.parentParams[`${singular}Id`] = parentId;
108
112
  }
109
113
  }
110
114