@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,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
|
|
20
|
-
return value
|
|
21
|
-
&& typeof value
|
|
22
|
-
&& CRUD_OPERATORS.has(value
|
|
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
|
|
27
|
-
&& Array.isArray(value
|
|
28
|
-
&& value
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
11
|
-
|
|
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
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
params
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
staleTime:
|
|
119
|
+
decode: decodeCanResult,
|
|
120
|
+
enabled: target.enabled, staleTime: target.staleTime,
|
|
57
121
|
};
|
|
58
122
|
});
|
|
59
123
|
|
|
60
|
-
return {
|
|
61
|
-
get allowed() {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
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
|
|
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
|
}
|
package/src/useParsed.svelte.ts
CHANGED
|
@@ -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
|
|
10
|
-
resourcePath
|
|
11
|
-
action
|
|
12
|
-
id
|
|
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
|
-
/**
|
|
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
|
|
41
|
-
(window
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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`] =
|
|
111
|
+
result.parentParams[`${singular}Id`] = parentId;
|
|
108
112
|
}
|
|
109
113
|
}
|
|
110
114
|
|