@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.
- 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 +64 -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/inferencer.ts +9 -0
- 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 +220 -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 +103 -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 +190 -180
- 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 +68 -110
- 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,191 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import { createSessionQuery } from './session-query.svelte';
|
|
3
|
+
import { captureAuthLiveScope } from './auth-hooks.svelte';
|
|
4
|
+
import { definedOptions } from './defined-options';
|
|
5
|
+
import { captureContractContext, type ContractOptions } from './contract-context.svelte';
|
|
6
|
+
import { getAdminOptions } from './options.svelte';
|
|
7
|
+
import { captureQueryProvider, snapshotListParams, snapshotManyParams } from './query-snapshot';
|
|
8
|
+
import { decodeListResult, decodeManyResult, rejectProviderResponse, type RecordDecoder } from './record-decoder';
|
|
9
|
+
import { createOvertimeTracker } from './hook-utils.svelte';
|
|
10
|
+
import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
|
|
11
|
+
import { createSelectProjection, mergeSelectOptions, type SelectOption } from './select-options';
|
|
12
|
+
import { HttpError, type BaseRecord, type KnownResources, type Pagination, type Sort, type Filter } from './types';
|
|
13
|
+
|
|
14
|
+
export interface UseSelectOptions<TData extends BaseRecord = BaseRecord> extends ContractOptions {
|
|
15
|
+
resource: KnownResources;
|
|
16
|
+
/** @internal Distinguishes consumers that use different projection semantics. */
|
|
17
|
+
projectionKey?: string;
|
|
18
|
+
optionLabel?: string | ((item: TData) => string);
|
|
19
|
+
optionValue?: string | ((item: TData) => string | number);
|
|
20
|
+
sorters?: Sort[];
|
|
21
|
+
filters?: Filter[];
|
|
22
|
+
defaultValue?: (string | number)[];
|
|
23
|
+
fetchSize?: number;
|
|
24
|
+
pagination?: Pagination;
|
|
25
|
+
debounce?: number;
|
|
26
|
+
queryOptions?: { staleTime?: number; enabled?: boolean };
|
|
27
|
+
defaultValueQueryOptions?: { staleTime?: number; enabled?: boolean };
|
|
28
|
+
meta?: Record<string, unknown>;
|
|
29
|
+
dataProviderName?: string;
|
|
30
|
+
onSearch?: (value: string) => Filter[];
|
|
31
|
+
successNotification?: NotificationConfig;
|
|
32
|
+
errorNotification?: NotificationConfig;
|
|
33
|
+
overtimeOptions?: OvertimeOptions;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createSelectQuery<TData extends BaseRecord>(
|
|
37
|
+
options: UseSelectOptions<TData>, getDecoder: () => RecordDecoder<TData>,
|
|
38
|
+
) {
|
|
39
|
+
const context = captureContractContext(options);
|
|
40
|
+
const adminOptions = getAdminOptions();
|
|
41
|
+
let disposed = false;
|
|
42
|
+
const scope = $derived.by(() => {
|
|
43
|
+
const resource = options.resource;
|
|
44
|
+
const contract = options.contract;
|
|
45
|
+
const captured = captureQueryProvider(context, {
|
|
46
|
+
resource, ...definedOptions({
|
|
47
|
+
dataProviderName: options.dataProviderName, contract, meta: options.meta,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
const params = snapshotListParams({
|
|
51
|
+
resource, ...definedOptions({
|
|
52
|
+
pagination: { ...options.pagination, current: options.pagination?.current ?? 1,
|
|
53
|
+
pageSize: options.fetchSize ?? options.pagination?.pageSize ?? 999 },
|
|
54
|
+
filters: options.filters, sorters: options.sorters, meta: captured.meta,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
const authProvider = context.authProvider;
|
|
58
|
+
return {
|
|
59
|
+
captured,
|
|
60
|
+
params,
|
|
61
|
+
decode: getDecoder(),
|
|
62
|
+
keys: context.queryKeys(resource, options.dataProviderName),
|
|
63
|
+
optionLabel: options.optionLabel,
|
|
64
|
+
optionValue: options.optionValue,
|
|
65
|
+
projectionKey: options.projectionKey ?? [
|
|
66
|
+
typeof options.optionLabel === 'string' ? options.optionLabel : 'callback',
|
|
67
|
+
typeof options.optionValue === 'string' ? options.optionValue : 'callback',
|
|
68
|
+
].join(':'),
|
|
69
|
+
onSearch: options.onSearch,
|
|
70
|
+
checkIds: contract !== undefined,
|
|
71
|
+
authProvider,
|
|
72
|
+
auth: captureAuthLiveScope(authProvider),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
const sessionCurrent = (origin: typeof scope) => !disposed &&
|
|
76
|
+
origin.authProvider === context.authProvider && origin.auth.isCurrent();
|
|
77
|
+
let search = $state.raw<{ scope: typeof scope; value: string } | undefined>();
|
|
78
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
const request = $derived.by(() => {
|
|
80
|
+
const current = scope;
|
|
81
|
+
const text = search?.scope === current && sessionCurrent(current) ? search.value : '';
|
|
82
|
+
try {
|
|
83
|
+
// Validate callback output before spreading it or giving it to the provider.
|
|
84
|
+
const extra = snapshotListParams({
|
|
85
|
+
resource: current.params.resource,
|
|
86
|
+
filters: text && current.onSearch ? untrack(() => current.onSearch?.(text)) : [],
|
|
87
|
+
});
|
|
88
|
+
return { params: snapshotListParams({
|
|
89
|
+
...current.params, filters: [...(current.params.filters ?? []), ...(extra.filters ?? [])],
|
|
90
|
+
}), text, error: undefined };
|
|
91
|
+
} catch {
|
|
92
|
+
return { params: current.params, text,
|
|
93
|
+
error: new HttpError('Invalid select search', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' }) };
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const query = createSessionQuery<
|
|
97
|
+
{ data: TData[]; total: number },
|
|
98
|
+
{ data: TData[]; total: number; options: SelectOption[] }
|
|
99
|
+
>(context, () => {
|
|
100
|
+
const origin = scope;
|
|
101
|
+
const { captured, decode, keys } = origin;
|
|
102
|
+
const { params, text, error } = request;
|
|
103
|
+
const label = origin.optionLabel ?? 'title';
|
|
104
|
+
const value = origin.optionValue ?? 'id';
|
|
105
|
+
const decodeResult = (input: unknown) => {
|
|
106
|
+
const result = decodeListResult(input, decode);
|
|
107
|
+
if (origin.checkIds && new Set(result.data.map(record => record['id'])).size !== result.data.length) return rejectProviderResponse();
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
resource: params.resource, successNotification: options.successNotification, errorNotification: options.errorNotification,
|
|
112
|
+
decode: createSelectProjection(decodeResult, label, value, decode, () => sessionCurrent(origin)),
|
|
113
|
+
queryKey: keys.data.select(params.resource, {
|
|
114
|
+
...params, source: captured.source, search: text, invalid: !!error, projection: origin.projectionKey,
|
|
115
|
+
}),
|
|
116
|
+
queryFn: async () => {
|
|
117
|
+
if (error) throw error;
|
|
118
|
+
return decodeResult(await captured.provider.getList(snapshotListParams(params)));
|
|
119
|
+
},
|
|
120
|
+
enabled: options.queryOptions?.enabled ?? true,
|
|
121
|
+
...definedOptions({ staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime }),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
const defaultValueQuery = createSessionQuery<
|
|
125
|
+
{ data: TData[] }, { data: TData[]; options: SelectOption[] }
|
|
126
|
+
>(context, () => {
|
|
127
|
+
const origin = scope;
|
|
128
|
+
const { captured, decode, keys, params: base } = origin;
|
|
129
|
+
const params = snapshotManyParams({
|
|
130
|
+
resource: base.resource, ids: options.defaultValue ?? [], ...definedOptions({ meta: base.meta }),
|
|
131
|
+
});
|
|
132
|
+
const label = origin.optionLabel ?? 'title';
|
|
133
|
+
const value = origin.optionValue ?? 'id';
|
|
134
|
+
const expected = new Set<unknown>(params.ids);
|
|
135
|
+
const decodeResult = (input: unknown) => {
|
|
136
|
+
const result = decodeManyResult(input, decode);
|
|
137
|
+
if (origin.checkIds && (expected.size !== params.ids.length || result.data.length !== expected.size ||
|
|
138
|
+
new Set(result.data.map(record => record['id'])).size !== result.data.length ||
|
|
139
|
+
result.data.some(record => !expected.has(record['id'])))) return rejectProviderResponse();
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
resource: params.resource, successNotification: false, errorNotification: options.errorNotification,
|
|
144
|
+
decode: createSelectProjection(decodeResult, label, value, decode, () => sessionCurrent(origin)),
|
|
145
|
+
queryKey: keys.data.selectDefaults(params.resource, { ...params, source: captured.source, projection: origin.projectionKey }),
|
|
146
|
+
queryFn: async () => {
|
|
147
|
+
if (!params.ids.length) return { data: [] };
|
|
148
|
+
const getMany = captured.provider.getMany;
|
|
149
|
+
if (!getMany) throw new Error('Captured query provider must support getMany');
|
|
150
|
+
return decodeResult(await getMany.call(captured.provider, snapshotManyParams(params)));
|
|
151
|
+
},
|
|
152
|
+
enabled: (options.queryOptions?.enabled ?? true) &&
|
|
153
|
+
(options.defaultValueQueryOptions?.enabled ?? true) && params.ids.length > 0,
|
|
154
|
+
staleTime: options.defaultValueQueryOptions?.staleTime ?? Infinity,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
const overtime = createOvertimeTracker(
|
|
158
|
+
() => query.isLoading || defaultValueQuery.isLoading, options.overtimeOptions ?? adminOptions.overtime,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
function onSearchChange(value: string) {
|
|
162
|
+
if (typeof value !== 'string') throw new TypeError('Select search must be a string');
|
|
163
|
+
const delay = options.debounce ?? 300;
|
|
164
|
+
if (!Number.isFinite(delay) || delay < 0) throw new TypeError('Select debounce must be a finite nonnegative number');
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
const current = scope;
|
|
167
|
+
if (!sessionCurrent(current)) return;
|
|
168
|
+
if (!value || delay === 0) {
|
|
169
|
+
search = { scope: current, value };
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
timer = setTimeout(() => {
|
|
173
|
+
if (scope === current && sessionCurrent(current)) search = { scope: current, value };
|
|
174
|
+
}, delay);
|
|
175
|
+
}
|
|
176
|
+
$effect(() => {
|
|
177
|
+
void scope;
|
|
178
|
+
return () => clearTimeout(timer);
|
|
179
|
+
});
|
|
180
|
+
$effect(() => () => { disposed = true; });
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
query, defaultValueQuery, onSearchChange,
|
|
184
|
+
get options() { return mergeSelectOptions(query.data?.options ?? [], defaultValueQuery.data?.options ?? []); },
|
|
185
|
+
get overtime() { return overtime; },
|
|
186
|
+
get isLoading() { return query.isLoading || defaultValueQuery.isLoading; },
|
|
187
|
+
get isFetching() { return query.isFetching || defaultValueQuery.isFetching; },
|
|
188
|
+
get isError() { return query.isError || defaultValueQuery.isError; },
|
|
189
|
+
async refetch() { await Promise.all([query.refetch(), defaultValueQuery.refetch()]); },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createQuery, hashKey, type QueryObserverResult, type RefetchOptions } from '@tanstack/svelte-query';
|
|
2
|
+
import { tick, untrack } from 'svelte';
|
|
3
|
+
import type { AdminContextAccessor } from './context.svelte';
|
|
4
|
+
import { definedOptions } from './defined-options';
|
|
5
|
+
import {
|
|
6
|
+
createQuerySession, createQueryResultView, pendingQueryResult, supersededQuerySession,
|
|
7
|
+
type QuerySessionOptions,
|
|
8
|
+
} from './query-session.svelte';
|
|
9
|
+
|
|
10
|
+
interface SessionQueryOptions<TQuery, T> extends QuerySessionOptions {
|
|
11
|
+
queryFn: (signal?: AbortSignal) => Promise<TQuery>;
|
|
12
|
+
decode: (value: unknown, dataUpdateCount: number) => T;
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
staleTime?: number;
|
|
15
|
+
gcTime?: number;
|
|
16
|
+
refetchOnWindowFocus?: boolean;
|
|
17
|
+
refetchInterval?: number | false;
|
|
18
|
+
refetchIntervalInBackground?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 只有显式提供错误归一化器的内部领域查询才承诺 Error;普通查询保留 unknown 契约。
|
|
22
|
+
export function createSessionQuery<TQuery, T = TQuery>(
|
|
23
|
+
context: AdminContextAccessor,
|
|
24
|
+
getOptions: () => SessionQueryOptions<TQuery, T> & { normalizeError: (error: unknown) => Error },
|
|
25
|
+
): QueryObserverResult<T, Error>;
|
|
26
|
+
export function createSessionQuery<TQuery, T = TQuery>(
|
|
27
|
+
context: AdminContextAccessor,
|
|
28
|
+
getOptions: () => SessionQueryOptions<TQuery, T>,
|
|
29
|
+
): QueryObserverResult<T, unknown>;
|
|
30
|
+
/** Ordinary reads keep their public result shape while cache and effects own an auth revision. */
|
|
31
|
+
export function createSessionQuery<TQuery, T = TQuery>(
|
|
32
|
+
context: AdminContextAccessor,
|
|
33
|
+
getOptions: () => SessionQueryOptions<TQuery, T>,
|
|
34
|
+
): QueryObserverResult<T, Error> {
|
|
35
|
+
const session = createQuerySession(context, getOptions);
|
|
36
|
+
const inFlight = new Map<string, { origin: typeof session.options; request: Promise<TQuery> }>();
|
|
37
|
+
type Origin = typeof session.options;
|
|
38
|
+
const query = createQuery<TQuery, Error>(() => {
|
|
39
|
+
const origin = session.options;
|
|
40
|
+
return {
|
|
41
|
+
queryKey: origin.queryKey,
|
|
42
|
+
queryFn: (queryContext) => {
|
|
43
|
+
const key = hashKey(origin.queryKey);
|
|
44
|
+
const existing = inFlight.get(key);
|
|
45
|
+
if (existing && session.current(existing.origin)) return existing.request;
|
|
46
|
+
if (existing) inFlight.delete(key);
|
|
47
|
+
const request = session.run(origin, () => origin.queryFn(queryContext?.signal));
|
|
48
|
+
inFlight.set(key, { origin, request });
|
|
49
|
+
void request.then(() => {
|
|
50
|
+
if (inFlight.get(key)?.request === request) inFlight.delete(key);
|
|
51
|
+
}, () => {
|
|
52
|
+
if (inFlight.get(key)?.request === request) inFlight.delete(key);
|
|
53
|
+
});
|
|
54
|
+
return request;
|
|
55
|
+
},
|
|
56
|
+
enabled: origin.enabled && origin.auth.available,
|
|
57
|
+
...definedOptions({
|
|
58
|
+
staleTime: origin.staleTime, gcTime: origin.gcTime, refetchOnWindowFocus: origin.refetchOnWindowFocus,
|
|
59
|
+
refetchInterval: origin.refetchInterval, refetchIntervalInBackground: origin.refetchIntervalInBackground,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function project(origin: Origin, result: QueryObserverResult<TQuery, Error>): QueryObserverResult<T, Error> {
|
|
65
|
+
const refetch = async (settings?: RefetchOptions): Promise<QueryObserverResult<T, Error>> => {
|
|
66
|
+
if (!session.current(origin)) return pendingQueryResult(refetch);
|
|
67
|
+
await tick();
|
|
68
|
+
if (!session.current(origin)) return pendingQueryResult(refetch);
|
|
69
|
+
try {
|
|
70
|
+
return project(origin, await query.refetch(settings));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw session.current(origin) ? session.errorFor(origin, error) : supersededQuerySession();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
if (!session.current(origin)) return pendingQueryResult(refetch);
|
|
76
|
+
// 新查询键尚未入缓存时也要订阅结果,否则请求完成后视图不会重新计算。
|
|
77
|
+
const observedData = result.data;
|
|
78
|
+
const observedError = result.error;
|
|
79
|
+
const state = session.client.getQueryState(origin.queryKey);
|
|
80
|
+
// The observer may still expose its previous key until Svelte flushes its effects.
|
|
81
|
+
if (!state || state.data !== observedData || state.error !== observedError) return pendingQueryResult(refetch);
|
|
82
|
+
const failureReason = result.failureReason === null ? null : session.errorFor(origin, result.failureReason);
|
|
83
|
+
try {
|
|
84
|
+
if (result.isSuccess || result.isRefetchError) {
|
|
85
|
+
const data = untrack(() => origin.decode(result.data, state.dataUpdateCount));
|
|
86
|
+
if (!session.current(origin)) return pendingQueryResult(refetch);
|
|
87
|
+
if (result.isSuccess) return { ...result, data, failureReason, refetch };
|
|
88
|
+
return { ...result, data, error: session.errorFor(origin, result.error), failureReason, refetch };
|
|
89
|
+
}
|
|
90
|
+
if (result.isError) return { ...result, error: session.errorFor(origin, result.error), failureReason, refetch };
|
|
91
|
+
return { ...result, failureReason, refetch };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!session.current(origin)) return pendingQueryResult(refetch);
|
|
94
|
+
return {
|
|
95
|
+
...pendingQueryResult(refetch), data: undefined, error: session.errorFor(origin, error), status: 'error',
|
|
96
|
+
isError: true, isPending: false, isLoading: false, isLoadingError: true,
|
|
97
|
+
isRefetchError: false, isSuccess: false, isPlaceholderData: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
session.observe(origin => project(origin, query));
|
|
102
|
+
return createQueryResultView(() => project(session.options, query));
|
|
103
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { TObject, TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { hashKey, useQueryClient } from '@tanstack/svelte-query';
|
|
3
|
+
import { captureAdminContext, type AdminContextAccessor } from './context.svelte';
|
|
4
|
+
import { commandDefinition, prepareCommand, parseCommandInput, parseCommandResponse,
|
|
5
|
+
type CommandContract, type CommandInput, type CommandOutput } from './command-contract';
|
|
6
|
+
import { captureAuthLiveScope, clearAuthQueries, handleAuthError } from './auth-hooks.svelte';
|
|
7
|
+
import { createSessionQuery } from './session-query.svelte';
|
|
8
|
+
import { createQueryResultView, supersededQuerySession } from './query-session.svelte';
|
|
9
|
+
import { definedOptions } from './defined-options';
|
|
10
|
+
import { keys } from './query-keys';
|
|
11
|
+
import { HttpError, type DataProvider } from './types';
|
|
12
|
+
|
|
13
|
+
type Result<O extends TSchema> = { data: CommandOutput<O> };
|
|
14
|
+
interface CommandOptions<I extends TObject, O extends TSchema> {
|
|
15
|
+
command: CommandContract<I, O>;
|
|
16
|
+
dataProviderName?: string;
|
|
17
|
+
}
|
|
18
|
+
interface Callbacks<I extends TObject, O extends TSchema> {
|
|
19
|
+
onSuccess?: (data: Result<O>, input: CommandInput<I>) => void | Promise<void>;
|
|
20
|
+
onError?: (error: HttpError, input: CommandInput<I> | undefined) => void | Promise<void>;
|
|
21
|
+
onSettled?: (data: Result<O> | undefined, error: HttpError | null, input: CommandInput<I> | undefined) => void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
type CommandState<I extends TObject, O extends TSchema> =
|
|
24
|
+
| { readonly status: 'idle'; readonly isIdle: true; readonly isPending: false; readonly isSuccess: false; readonly isError: false;
|
|
25
|
+
readonly data: undefined; readonly error: null; readonly variables: undefined }
|
|
26
|
+
| { readonly status: 'pending'; readonly isIdle: false; readonly isPending: true; readonly isSuccess: false; readonly isError: false;
|
|
27
|
+
readonly data: undefined; readonly error: null; readonly variables: CommandInput<I> }
|
|
28
|
+
| { readonly status: 'success'; readonly isIdle: false; readonly isPending: false; readonly isSuccess: true; readonly isError: false;
|
|
29
|
+
readonly data: Result<O>; readonly error: null; readonly variables: CommandInput<I> }
|
|
30
|
+
| { readonly status: 'error'; readonly isIdle: false; readonly isPending: false; readonly isSuccess: false; readonly isError: true;
|
|
31
|
+
readonly data: undefined; readonly error: HttpError; readonly variables: CommandInput<I> | undefined };
|
|
32
|
+
|
|
33
|
+
const sources = new WeakMap<DataProvider, string>();
|
|
34
|
+
const runtime = `${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
35
|
+
let nextSource = 0;
|
|
36
|
+
function captureProvider(context: AdminContextAccessor, override: string | undefined) {
|
|
37
|
+
const name = override ?? 'default';
|
|
38
|
+
if (typeof name !== 'string' || !name.length) {
|
|
39
|
+
throw new HttpError('Invalid command provider', 422, undefined, { code: 'INVALID_COMMAND_INPUT' });
|
|
40
|
+
}
|
|
41
|
+
const provider = context.providers?.[name];
|
|
42
|
+
if (!provider) throw new HttpError('Data provider unavailable', 400, undefined, { code: 'DATA_PROVIDER_REQUIRED' });
|
|
43
|
+
let source = sources.get(provider);
|
|
44
|
+
if (source === undefined) {
|
|
45
|
+
source = `command:${runtime}:${++nextSource}`;
|
|
46
|
+
sources.set(provider, source);
|
|
47
|
+
}
|
|
48
|
+
return { provider, name, source };
|
|
49
|
+
}
|
|
50
|
+
function requireMethod(command: CommandContract, read: boolean) {
|
|
51
|
+
const definition = commandDefinition(command);
|
|
52
|
+
if ((definition.method === 'get') !== read) {
|
|
53
|
+
throw new HttpError(read ? 'Queries require a GET command' : 'Mutations require a write command', 400, undefined, {
|
|
54
|
+
code: 'INVALID_COMMAND_METHOD',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return definition;
|
|
58
|
+
}
|
|
59
|
+
function observe(callback: () => unknown): void {
|
|
60
|
+
try { void Promise.resolve(callback()).catch(() => {}); }
|
|
61
|
+
catch { /* Observers do not own the command outcome. */ }
|
|
62
|
+
}
|
|
63
|
+
function failure(cause: unknown, dispatched = false): HttpError {
|
|
64
|
+
let status = 502;
|
|
65
|
+
let code = 'COMMAND_FAILED';
|
|
66
|
+
try {
|
|
67
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
68
|
+
const value: unknown = Object.getOwnPropertyDescriptor(cause, 'statusCode')?.value;
|
|
69
|
+
const label: unknown = Object.getOwnPropertyDescriptor(cause, 'code')?.value;
|
|
70
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 400 && value <= 599) status = value;
|
|
71
|
+
if (label === 'INVALID_COMMAND_INPUT' || label === 'INVALID_COMMAND_RESPONSE' ||
|
|
72
|
+
label === 'INVALID_COMMAND_CONTRACT' || label === 'INVALID_COMMAND_METHOD' ||
|
|
73
|
+
label === 'COMMAND_NOT_SUPPORTED' || label === 'DATA_PROVIDER_REQUIRED') code = label;
|
|
74
|
+
}
|
|
75
|
+
} catch { /* Reflection errors remain private. */ }
|
|
76
|
+
return new HttpError('Command failed', status, undefined, { code, details: { writeMayHaveSucceeded: dispatched } });
|
|
77
|
+
}
|
|
78
|
+
function cancelled(dispatched: boolean): HttpError {
|
|
79
|
+
return new HttpError('Command scope changed', 409, undefined, {
|
|
80
|
+
code: 'COMMAND_CANCELLED', details: { writeMayHaveSucceeded: dispatched },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useCustom<I extends TObject, O extends TSchema>(options: CommandOptions<I, O> & {
|
|
85
|
+
input: NoInfer<CommandInput<I>>;
|
|
86
|
+
queryOptions?: { enabled?: boolean; staleTime?: number };
|
|
87
|
+
}) {
|
|
88
|
+
const context = captureAdminContext();
|
|
89
|
+
const query = createSessionQuery<Result<O>>(context, () => {
|
|
90
|
+
const command = options.command;
|
|
91
|
+
const definition = requireMethod(command, true);
|
|
92
|
+
const override = options.dataProviderName;
|
|
93
|
+
const captured = captureProvider(context, override);
|
|
94
|
+
const tenant = context.tenantCacheKey?.__svadminTenant;
|
|
95
|
+
const prepared = prepareCommand(captured.provider, command, options.input);
|
|
96
|
+
const signature = hashKey([prepared.input]);
|
|
97
|
+
const { enabled = true, staleTime } = options.queryOptions ?? {};
|
|
98
|
+
if (typeof enabled !== 'boolean' || (staleTime !== undefined &&
|
|
99
|
+
(typeof staleTime !== 'number' || Number.isNaN(staleTime) || staleTime < 0))) {
|
|
100
|
+
throw new HttpError('Invalid command query settings', 422, undefined, { code: 'INVALID_COMMAND_INPUT' });
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
resource: command.name, successNotification: false, errorNotification: false,
|
|
104
|
+
queryKey: keys(definedOptions({
|
|
105
|
+
provider: captured.name, contract: definition.key, tenant,
|
|
106
|
+
})).custom.call(definition.key, definition.key, definition.method, { input: prepared.input, source: captured.source }),
|
|
107
|
+
queryFn: () => {
|
|
108
|
+
if (command !== options.command || override !== options.dataProviderName ||
|
|
109
|
+
tenant !== context.tenantCacheKey?.__svadminTenant ||
|
|
110
|
+
captured.provider !== captureProvider(context, override).provider ||
|
|
111
|
+
signature !== hashKey([parseCommandInput(command, options.input)])) throw supersededQuerySession();
|
|
112
|
+
return prepared.execute();
|
|
113
|
+
},
|
|
114
|
+
decode: value => parseCommandResponse(command, value),
|
|
115
|
+
enabled, ...definedOptions({ staleTime }),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
return { query };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Command writes own their state and observers; native mutation defaults are not a dispatch boundary. */
|
|
122
|
+
export function useCustomMutation<I extends TObject, O extends TSchema>(options: CommandOptions<I, O>) {
|
|
123
|
+
requireMethod(options.command, false);
|
|
124
|
+
const context = captureAdminContext();
|
|
125
|
+
const client = useQueryClient();
|
|
126
|
+
let mounted = true;
|
|
127
|
+
function origin() {
|
|
128
|
+
const auth = context.authProvider;
|
|
129
|
+
const authScope = captureAuthLiveScope(auth);
|
|
130
|
+
const router = context.routerProvider;
|
|
131
|
+
const tenant = context.tenantCacheKey?.__svadminTenant;
|
|
132
|
+
const command = options.command;
|
|
133
|
+
const override = options.dataProviderName;
|
|
134
|
+
return {
|
|
135
|
+
command, definition: requireMethod(command, false), override,
|
|
136
|
+
auth, authScope, router, tenant, ...captureProvider(context, override),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function capture(owner: ReturnType<typeof origin>, input: CommandInput<I>) {
|
|
140
|
+
const prepared = prepareCommand(owner.provider, owner.command, input);
|
|
141
|
+
return { ...owner, prepared, signature: hashKey([owner.definition.key, owner.source,
|
|
142
|
+
owner.tenant, owner.authScope.cacheKey, prepared.input]) };
|
|
143
|
+
}
|
|
144
|
+
type Origin = ReturnType<typeof origin>;
|
|
145
|
+
type Scope = ReturnType<typeof capture>;
|
|
146
|
+
type Invocation = { scope: Scope; token: object; dispatched: boolean; promise: Promise<Result<O>> };
|
|
147
|
+
let state = $state.raw<CommandState<I, O>>(idle());
|
|
148
|
+
let latest = $state.raw<Scope | undefined>();
|
|
149
|
+
let failedOrigin = $state.raw<Origin | undefined>();
|
|
150
|
+
let latestToken: object | undefined;
|
|
151
|
+
let active: Invocation | undefined;
|
|
152
|
+
let controlEpoch = 0;
|
|
153
|
+
function idle(): CommandState<I, O> {
|
|
154
|
+
return { status: 'idle', isIdle: true, isPending: false, isSuccess: false, isError: false,
|
|
155
|
+
data: undefined, error: null, variables: undefined };
|
|
156
|
+
}
|
|
157
|
+
function originCurrent(owner: Origin): boolean {
|
|
158
|
+
try {
|
|
159
|
+
return mounted && owner.command === options.command && owner.override === options.dataProviderName &&
|
|
160
|
+
owner.auth === context.authProvider && owner.router === context.routerProvider &&
|
|
161
|
+
owner.tenant === context.tenantCacheKey?.__svadminTenant;
|
|
162
|
+
} catch { return false; }
|
|
163
|
+
}
|
|
164
|
+
function targetCurrent(scope: Origin): boolean {
|
|
165
|
+
try {
|
|
166
|
+
return originCurrent(scope) && captureProvider(context, scope.override).provider === scope.provider;
|
|
167
|
+
} catch { return false; }
|
|
168
|
+
}
|
|
169
|
+
const current = (scope: Scope, token: object) =>
|
|
170
|
+
targetCurrent(scope) && scope.authScope.isCurrent() && latestToken === token;
|
|
171
|
+
function copyError(error: HttpError, dispatched: boolean): HttpError {
|
|
172
|
+
return error.code === 'COMMAND_CANCELLED' ? cancelled(dispatched) : failure(error, dispatched);
|
|
173
|
+
}
|
|
174
|
+
function callbacks(value: Callbacks<I, O>): Callbacks<I, O> {
|
|
175
|
+
if (typeof value !== 'object' || value === null) {
|
|
176
|
+
throw new HttpError('Invalid command callbacks', 422, undefined, { code: 'INVALID_COMMAND_INPUT' });
|
|
177
|
+
}
|
|
178
|
+
function read(name: keyof Callbacks<I, O>) {
|
|
179
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, name);
|
|
180
|
+
if (!descriptor) return undefined;
|
|
181
|
+
const callback: unknown = descriptor.value;
|
|
182
|
+
if (!('value' in descriptor) || (callback !== undefined && typeof callback !== 'function')) {
|
|
183
|
+
throw new HttpError('Invalid command callbacks', 422, undefined, { code: 'INVALID_COMMAND_INPUT' });
|
|
184
|
+
}
|
|
185
|
+
return typeof callback === 'function' ? async (...args: unknown[]): Promise<void> => {
|
|
186
|
+
const result: unknown = Reflect.apply(callback, undefined, args);
|
|
187
|
+
await result;
|
|
188
|
+
} : undefined;
|
|
189
|
+
}
|
|
190
|
+
return definedOptions({ onSuccess: read('onSuccess'), onError: read('onError'), onSettled: read('onSettled') });
|
|
191
|
+
}
|
|
192
|
+
function ensure(invocation: Invocation): void {
|
|
193
|
+
if (!current(invocation.scope, invocation.token)) throw cancelled(invocation.dispatched);
|
|
194
|
+
}
|
|
195
|
+
async function run(invocation: Invocation): Promise<Result<O>> {
|
|
196
|
+
const { scope, token } = invocation;
|
|
197
|
+
ensure(invocation);
|
|
198
|
+
let result: Result<O>;
|
|
199
|
+
try {
|
|
200
|
+
invocation.dispatched = true;
|
|
201
|
+
result = await scope.prepared.execute();
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
ensure(invocation);
|
|
204
|
+
const error = failure(cause, invocation.dispatched);
|
|
205
|
+
state = { status: 'error', isIdle: false, isPending: false, isSuccess: false, isError: true,
|
|
206
|
+
data: undefined, error, variables: scope.prepared.input };
|
|
207
|
+
observe(() => handleAuthError(copyError(error, invocation.dispatched), context,
|
|
208
|
+
() => targetCurrent(scope) && latestToken === token,
|
|
209
|
+
() => clearAuthQueries(client, scope.auth), scope.authScope));
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
ensure(invocation);
|
|
213
|
+
state = { status: 'success', isIdle: false, isPending: false, isSuccess: true, isError: false,
|
|
214
|
+
data: result, error: null, variables: scope.prepared.input };
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
function deliver(invocation: Invocation, observers: Callbacks<I, O>): Promise<Result<O>> {
|
|
218
|
+
const { scope } = invocation;
|
|
219
|
+
return invocation.promise.then(result => {
|
|
220
|
+
ensure(invocation);
|
|
221
|
+
observe(() => observers.onSuccess?.(parseCommandResponse(scope.command, result), scope.prepared.input));
|
|
222
|
+
ensure(invocation);
|
|
223
|
+
observe(() => observers.onSettled?.(parseCommandResponse(scope.command, result), null, scope.prepared.input));
|
|
224
|
+
ensure(invocation);
|
|
225
|
+
return parseCommandResponse(scope.command, result);
|
|
226
|
+
}, (cause: unknown) => {
|
|
227
|
+
ensure(invocation);
|
|
228
|
+
const error = failure(cause, invocation.dispatched);
|
|
229
|
+
observe(() => observers.onError?.(copyError(error, invocation.dispatched), scope.prepared.input));
|
|
230
|
+
ensure(invocation);
|
|
231
|
+
observe(() => observers.onSettled?.(undefined, copyError(error, invocation.dispatched), scope.prepared.input));
|
|
232
|
+
ensure(invocation);
|
|
233
|
+
throw error;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function mutateAsync(input: NoInfer<CommandInput<I>>, value: Callbacks<I, O> = {}): Promise<Result<O>> {
|
|
237
|
+
const epoch = controlEpoch;
|
|
238
|
+
let owner: Origin;
|
|
239
|
+
try { owner = origin(); }
|
|
240
|
+
catch (cause) { return Promise.reject(epoch === controlEpoch ? failure(cause) : cancelled(false)); }
|
|
241
|
+
const available = () => epoch === controlEpoch && targetCurrent(owner) && owner.authScope.isCurrent();
|
|
242
|
+
if (!available()) return Promise.reject(cancelled(false));
|
|
243
|
+
let scope: Scope;
|
|
244
|
+
let observers: Callbacks<I, O> = {};
|
|
245
|
+
try {
|
|
246
|
+
observers = callbacks(value);
|
|
247
|
+
scope = capture(owner, input);
|
|
248
|
+
} catch (cause) {
|
|
249
|
+
if (!available()) return Promise.reject(cancelled(false));
|
|
250
|
+
const error = failure(cause);
|
|
251
|
+
const preserved = active && current(active.scope, active.token);
|
|
252
|
+
const token = preserved ? latestToken : {};
|
|
253
|
+
if (!preserved) {
|
|
254
|
+
controlEpoch++;
|
|
255
|
+
active = undefined;
|
|
256
|
+
latest = undefined;
|
|
257
|
+
failedOrigin = owner;
|
|
258
|
+
latestToken = token;
|
|
259
|
+
state = { status: 'error', isIdle: false, isPending: false, isSuccess: false, isError: true,
|
|
260
|
+
data: undefined, error, variables: undefined };
|
|
261
|
+
}
|
|
262
|
+
return Promise.resolve().then(() => {
|
|
263
|
+
const ensure = () => {
|
|
264
|
+
if (!targetCurrent(owner) || !owner.authScope.isCurrent() || latestToken !== token) throw cancelled(false);
|
|
265
|
+
};
|
|
266
|
+
ensure();
|
|
267
|
+
observe(() => observers.onError?.(copyError(error, false), undefined));
|
|
268
|
+
ensure();
|
|
269
|
+
observe(() => observers.onSettled?.(undefined, copyError(error, false), undefined));
|
|
270
|
+
ensure();
|
|
271
|
+
throw copyError(error, false);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (!available()) return Promise.reject(cancelled(false));
|
|
275
|
+
if (active && current(active.scope, active.token)) {
|
|
276
|
+
if (active.scope.signature === scope.signature) return deliver(active, observers);
|
|
277
|
+
return Promise.reject(new HttpError('A command is already running', 409, undefined, { code: 'COMMAND_BUSY' }));
|
|
278
|
+
}
|
|
279
|
+
const token = {};
|
|
280
|
+
controlEpoch++;
|
|
281
|
+
latestToken = token;
|
|
282
|
+
latest = scope;
|
|
283
|
+
failedOrigin = undefined;
|
|
284
|
+
state = { status: 'pending', isIdle: false, isPending: true, isSuccess: false, isError: false,
|
|
285
|
+
data: undefined, error: null, variables: scope.prepared.input };
|
|
286
|
+
const invocation: Invocation = { scope, token, dispatched: false, promise: Promise.resolve().then(() => run(invocation)) };
|
|
287
|
+
invocation.promise = invocation.promise.finally(() => { if (active === invocation) active = undefined; });
|
|
288
|
+
active = invocation;
|
|
289
|
+
return deliver(invocation, observers);
|
|
290
|
+
}
|
|
291
|
+
function reset(): void {
|
|
292
|
+
controlEpoch++;
|
|
293
|
+
active = undefined;
|
|
294
|
+
latest = undefined;
|
|
295
|
+
failedOrigin = undefined;
|
|
296
|
+
latestToken = undefined;
|
|
297
|
+
state = idle();
|
|
298
|
+
}
|
|
299
|
+
$effect.pre(() => {
|
|
300
|
+
const scope = latest ?? failedOrigin;
|
|
301
|
+
if (scope && (!targetCurrent(scope) || !scope.authScope.isCurrent())) {
|
|
302
|
+
controlEpoch++;
|
|
303
|
+
// Auth retirement must not cancel the checked delegate's own logout intent.
|
|
304
|
+
if (!targetCurrent(scope)) latestToken = undefined;
|
|
305
|
+
latest = undefined;
|
|
306
|
+
failedOrigin = undefined;
|
|
307
|
+
active = undefined;
|
|
308
|
+
state = idle();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
$effect(() => () => { mounted = false; reset(); });
|
|
312
|
+
function projection(): CommandState<I, O> {
|
|
313
|
+
const scope = latest;
|
|
314
|
+
const owner = scope ?? failedOrigin;
|
|
315
|
+
if (!mounted || (owner && (!targetCurrent(owner) || !owner.authScope.isCurrent()))) return idle();
|
|
316
|
+
if (state.isIdle) return state;
|
|
317
|
+
const variables = scope ? parseCommandInput(scope.command, state.variables) : undefined;
|
|
318
|
+
if (state.isError) return { ...state, error: failure(state.error, scope !== undefined), variables };
|
|
319
|
+
if (!scope || variables === undefined) return idle();
|
|
320
|
+
if (state.isSuccess) return { ...state, data: parseCommandResponse(scope.command, state.data), variables };
|
|
321
|
+
return { ...state, variables };
|
|
322
|
+
}
|
|
323
|
+
const controls = {
|
|
324
|
+
mutateAsync, reset,
|
|
325
|
+
mutate(input: NoInfer<CommandInput<I>>, observers?: Callbacks<I, O>): void {
|
|
326
|
+
void mutateAsync(input, observers).catch(() => {});
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const mutation = createQueryResultView((): CommandState<I, O> & Readonly<typeof controls> => ({
|
|
330
|
+
...projection(), ...controls,
|
|
331
|
+
}));
|
|
332
|
+
return { mutation };
|
|
333
|
+
}
|