@svadmin/core 0.35.0 → 0.36.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 +1 -1
- package/src/context.svelte.ts +33 -17
- package/src/form-hooks.svelte.ts +39 -37
- package/src/hook-utils.svelte.ts +5 -5
- package/src/hooks.svelte.ts +71 -77
- package/src/index.ts +16 -3
- package/src/live.svelte.ts +5 -5
- package/src/mutation-hooks.svelte.ts +83 -71
- package/src/provider-bundle.ts +0 -17
- package/src/query-hooks.svelte.ts +13 -12
- package/src/query-keys.ts +124 -18
- package/src/task-hooks.svelte.ts +11 -9
- package/src/useCan.ts +5 -4
- package/src/utility-hooks.svelte.ts +6 -7
package/package.json
CHANGED
package/src/context.svelte.ts
CHANGED
|
@@ -9,12 +9,12 @@ import type { AccessControlProvider } from './permissions.svelte';
|
|
|
9
9
|
import type { AuditLogProvider } from './audit';
|
|
10
10
|
import type { AgentProvider, ChatProvider } from './chatProvider.svelte';
|
|
11
11
|
import {
|
|
12
|
-
appendTenantCacheKey,
|
|
13
12
|
createTenantCacheKey,
|
|
14
|
-
queryKeyMatchesTenant,
|
|
15
13
|
resolveTenantProviderMeta,
|
|
16
14
|
withProviderMeta,
|
|
17
15
|
} from './provider-bundle';
|
|
16
|
+
import { keys } from './query-keys';
|
|
17
|
+
import type { QueryMatcher } from './query-keys';
|
|
18
18
|
import type {
|
|
19
19
|
DataProviderInput,
|
|
20
20
|
ProviderBundle,
|
|
@@ -107,10 +107,11 @@ export interface AdminContextAccessor {
|
|
|
107
107
|
readonly tenantCacheKey: TenantCacheKey | undefined;
|
|
108
108
|
getDataProvider(name?: string): DataProvider;
|
|
109
109
|
getDataProviderNames(): string[];
|
|
110
|
+
resolveDataProviderName(resourceName: string, overrideName?: string): string;
|
|
110
111
|
getDataProviderForResource(resourceName: string, overrideName?: string): DataProvider;
|
|
112
|
+
queryKeys(resourceName?: string, overrideName?: string): ReturnType<typeof keys>;
|
|
113
|
+
queryKeyMatcher(resourceName?: string, overrideName?: string): Pick<QueryMatcher, 'provider' | 'tenant'>;
|
|
111
114
|
getProviderMeta(resourceName: string | undefined, meta?: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
112
|
-
withTenantCacheKey(queryKey: readonly unknown[]): readonly unknown[];
|
|
113
|
-
matchesTenantQuery(queryKey: readonly unknown[]): boolean;
|
|
114
115
|
getResource(nameOrIdentifier: string): ResourceDefinition;
|
|
115
116
|
currentPath(): string;
|
|
116
117
|
formatLink(path: string): string;
|
|
@@ -271,22 +272,43 @@ export function captureAdminContext(): AdminContextAccessor {
|
|
|
271
272
|
getDataProviderNames() {
|
|
272
273
|
return accessor.providers ? Object.keys(accessor.providers) : [];
|
|
273
274
|
},
|
|
274
|
-
|
|
275
|
+
resolveDataProviderName(resourceName, overrideName) {
|
|
275
276
|
if (overrideName) {
|
|
276
277
|
try {
|
|
277
|
-
|
|
278
|
+
accessor.getDataProvider(overrideName);
|
|
279
|
+
return overrideName;
|
|
278
280
|
} catch {
|
|
279
|
-
//
|
|
281
|
+
// Fallback to resource/default provider as before.
|
|
280
282
|
}
|
|
281
283
|
}
|
|
284
|
+
|
|
282
285
|
try {
|
|
283
286
|
const resource = accessor.getResource(resourceName);
|
|
284
|
-
const
|
|
285
|
-
if (
|
|
287
|
+
const providerName = resource.provider?.dataProviderName ?? resource.meta?.dataProviderName;
|
|
288
|
+
if (providerName) {
|
|
289
|
+
accessor.getDataProvider(providerName);
|
|
290
|
+
return providerName;
|
|
291
|
+
}
|
|
286
292
|
} catch {
|
|
287
|
-
// Preserve
|
|
293
|
+
// Preserve fallback when a resource is not found or malformed.
|
|
288
294
|
}
|
|
289
|
-
|
|
295
|
+
|
|
296
|
+
return 'default';
|
|
297
|
+
},
|
|
298
|
+
getDataProviderForResource(resourceName, overrideName) {
|
|
299
|
+
return accessor.getDataProvider(accessor.resolveDataProviderName(resourceName, overrideName));
|
|
300
|
+
},
|
|
301
|
+
queryKeys(resourceName, overrideName) {
|
|
302
|
+
return keys({
|
|
303
|
+
provider: accessor.resolveDataProviderName(resourceName ?? '', overrideName),
|
|
304
|
+
tenant: accessor.tenantCacheKey?.__svadminTenant,
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
queryKeyMatcher(resourceName, overrideName) {
|
|
308
|
+
return {
|
|
309
|
+
provider: accessor.resolveDataProviderName(resourceName ?? '', overrideName),
|
|
310
|
+
tenant: accessor.tenantCacheKey?.__svadminTenant,
|
|
311
|
+
};
|
|
290
312
|
},
|
|
291
313
|
getProviderMeta(resourceName, meta) {
|
|
292
314
|
const resource = resourceName
|
|
@@ -304,12 +326,6 @@ export function captureAdminContext(): AdminContextAccessor {
|
|
|
304
326
|
...(tenant ? resolveTenantProviderMeta(tenant, accessor.tenantAdapter) : {}),
|
|
305
327
|
};
|
|
306
328
|
},
|
|
307
|
-
withTenantCacheKey(queryKey) {
|
|
308
|
-
return appendTenantCacheKey(queryKey, accessor.tenantCacheKey);
|
|
309
|
-
},
|
|
310
|
-
matchesTenantQuery(queryKey) {
|
|
311
|
-
return queryKeyMatchesTenant(queryKey, accessor.tenantCacheKey);
|
|
312
|
-
},
|
|
313
329
|
getResource(nameOrIdentifier) {
|
|
314
330
|
const resource = accessor.resources.find(
|
|
315
331
|
(candidate) => candidate.identifier === nameOrIdentifier || candidate.name === nameOrIdentifier,
|
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/svelte-query';
|
|
|
2
2
|
import { useParsed } from './useParsed.svelte';
|
|
3
3
|
import { getAdminOptions } from './options.svelte';
|
|
4
4
|
import { captureAdminContext } from './context.svelte';
|
|
5
|
-
import {
|
|
5
|
+
import { queryKeyMatches } from './query-keys';
|
|
6
6
|
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
7
7
|
import { notifyWithProvider } from './notification.svelte';
|
|
8
8
|
import type { NotificationParams } from './notification.svelte';
|
|
@@ -458,9 +458,9 @@ export function useForm<
|
|
|
458
458
|
// Always create the query — use `enabled` to conditionally activate.
|
|
459
459
|
// This ensures setAction('edit') at runtime will trigger a fetch.
|
|
460
460
|
const query = createQuery(() => ({
|
|
461
|
-
queryKey:
|
|
462
|
-
|
|
463
|
-
|
|
461
|
+
queryKey: adminContext.queryKeys(resource, options.dataProviderName).data.one(resource, currentId ?? '', {
|
|
462
|
+
meta: queryMeta,
|
|
463
|
+
}),
|
|
464
464
|
queryFn: async () => {
|
|
465
465
|
const result = await provider.getOne<BaseRecord>({ resource, id: currentId as string, meta: queryMeta });
|
|
466
466
|
return result;
|
|
@@ -527,15 +527,13 @@ export function useForm<
|
|
|
527
527
|
onSuccess: (data: { data: TData }, _vars: unknown, context: unknown) => {
|
|
528
528
|
const ctx = context as { targetResource?: string } | undefined;
|
|
529
529
|
const res = ctx?.targetResource ?? resource;
|
|
530
|
-
invalidateByScopes(
|
|
530
|
+
invalidateByScopes({
|
|
531
531
|
queryClient,
|
|
532
|
-
res,
|
|
533
|
-
invalidateScopes,
|
|
534
|
-
['list', 'many'],
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
(queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
|
|
538
|
-
);
|
|
532
|
+
resource: res,
|
|
533
|
+
scopes: invalidateScopes,
|
|
534
|
+
defaults: ['list', 'many'],
|
|
535
|
+
matcher: adminContext.queryKeyMatcher(res, options.dataProviderName),
|
|
536
|
+
});
|
|
539
537
|
if (!isManualSubmitIdentityCurrent()) return;
|
|
540
538
|
if (successNotification !== false) sendNotification({ type: 'success', message: typeof successNotification === 'string' ? successNotification : i18n.t('common.createSuccess') });
|
|
541
539
|
const pk = adminContext.getResource(res).primaryKey ?? 'id';
|
|
@@ -584,13 +582,17 @@ export function useForm<
|
|
|
584
582
|
if (mutationMode === 'pessimistic') return { targetId: currentId, targetResource: resource };
|
|
585
583
|
const targetId = currentId;
|
|
586
584
|
const targetResource = resource;
|
|
587
|
-
const
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
585
|
+
const matcher = adminContext.queryKeyMatcher(resource, options.dataProviderName);
|
|
586
|
+
const dataMatch = (queryKey: readonly unknown[], fields: { action?: string; id?: string | number } = {}) => queryKeyMatches(queryKey, {
|
|
587
|
+
...matcher,
|
|
588
|
+
kind: 'data',
|
|
589
|
+
resource,
|
|
590
|
+
...fields,
|
|
591
|
+
});
|
|
592
|
+
await queryClient.cancelQueries({ predicate: (q) => dataMatch(q.queryKey) });
|
|
593
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => dataMatch(q.queryKey) });
|
|
592
594
|
if (optimisticUpdateMap?.list !== false) {
|
|
593
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
595
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'list' }) }, (old: unknown) => {
|
|
594
596
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
595
597
|
const o = old as { data: Record<string, unknown>[] };
|
|
596
598
|
const pk = adminContext.getResource(resource).primaryKey ?? 'id';
|
|
@@ -598,7 +600,7 @@ export function useForm<
|
|
|
598
600
|
});
|
|
599
601
|
}
|
|
600
602
|
if (optimisticUpdateMap?.detail !== false && targetId != null) {
|
|
601
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
603
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
|
|
602
604
|
}
|
|
603
605
|
return { previousQueries, targetId, targetResource };
|
|
604
606
|
},
|
|
@@ -626,15 +628,14 @@ export function useForm<
|
|
|
626
628
|
const ctx = context as { targetId?: string | number; targetResource?: string } | undefined;
|
|
627
629
|
const targetId = ctx?.targetId ?? currentId;
|
|
628
630
|
const res = ctx?.targetResource ?? resource;
|
|
629
|
-
invalidateByScopes(
|
|
631
|
+
invalidateByScopes({
|
|
630
632
|
queryClient,
|
|
631
|
-
res,
|
|
632
|
-
invalidateScopes,
|
|
633
|
-
['list', 'many', 'detail'],
|
|
634
|
-
targetId ?? undefined,
|
|
635
|
-
options.dataProviderName,
|
|
636
|
-
|
|
637
|
-
);
|
|
633
|
+
resource: res,
|
|
634
|
+
scopes: invalidateScopes,
|
|
635
|
+
defaults: ['list', 'many', 'detail'],
|
|
636
|
+
id: targetId ?? undefined,
|
|
637
|
+
matcher: adminContext.queryKeyMatcher(res, options.dataProviderName),
|
|
638
|
+
});
|
|
638
639
|
},
|
|
639
640
|
onError: (error: unknown, _vars: unknown, context: unknown) => {
|
|
640
641
|
const ctx = context as { previousQueries?: [unknown, unknown][] } | undefined;
|
|
@@ -776,15 +777,14 @@ export function useForm<
|
|
|
776
777
|
await provider.update<TData, TVariables>({ resource: safeResource, id: safeId as string | number, variables: submittedValues, meta: safeMeta });
|
|
777
778
|
if (formDestroyed) return;
|
|
778
779
|
const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
|
|
779
|
-
invalidateByScopes(
|
|
780
|
+
invalidateByScopes({
|
|
780
781
|
queryClient,
|
|
781
|
-
safeResource,
|
|
782
|
+
resource: safeResource,
|
|
782
783
|
scopes,
|
|
783
|
-
['resourceAll'],
|
|
784
|
-
safeId ?? undefined,
|
|
785
|
-
options.dataProviderName,
|
|
786
|
-
|
|
787
|
-
);
|
|
784
|
+
defaults: ['resourceAll'],
|
|
785
|
+
id: safeId ?? undefined,
|
|
786
|
+
matcher: adminContext.queryKeyMatcher(safeResource, options.dataProviderName),
|
|
787
|
+
});
|
|
788
788
|
if (!isRecordIdentityCurrent(scheduledIdentity)) return;
|
|
789
789
|
autoSaveStatus = 'saved';
|
|
790
790
|
lastAutoSaveData = submittedValues;
|
|
@@ -814,9 +814,11 @@ export function useForm<
|
|
|
814
814
|
if (autoSaveOpts?.invalidateOnUnmount) {
|
|
815
815
|
$effect(() => {
|
|
816
816
|
return () => queryClient.invalidateQueries({
|
|
817
|
-
predicate: (q) =>
|
|
818
|
-
|
|
819
|
-
|
|
817
|
+
predicate: (q) => queryKeyMatches(q.queryKey, {
|
|
818
|
+
...adminContext.queryKeyMatcher(resource, options.dataProviderName),
|
|
819
|
+
kind: 'data',
|
|
820
|
+
resource,
|
|
821
|
+
}),
|
|
820
822
|
});
|
|
821
823
|
});
|
|
822
824
|
}
|
package/src/hook-utils.svelte.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/svelte-query';
|
|
|
5
5
|
import type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
|
|
6
6
|
import { captureAdminContext } from './context.svelte';
|
|
7
7
|
import type { AdminContextAccessor } from './context.svelte';
|
|
8
|
-
import {
|
|
8
|
+
import { dataQueryMatches } from './query-keys';
|
|
9
9
|
import { notifyWithProvider } from './notification.svelte';
|
|
10
10
|
import type { NotificationProvider } from './types';
|
|
11
11
|
|
|
@@ -108,10 +108,10 @@ export function createLiveSubscription(paramsFn: () => LiveSubscriptionParams):
|
|
|
108
108
|
callback: (event: LiveEvent) => {
|
|
109
109
|
params.onLiveEvent?.(event);
|
|
110
110
|
if (liveMode === 'auto') {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
const matcher = adminContext.queryKeyMatcher(params.resource, params.dataProviderName);
|
|
112
|
+
queryClient.invalidateQueries({
|
|
113
|
+
predicate: (q) => dataQueryMatches(q.queryKey, { ...matcher, resource: params.resource }),
|
|
114
|
+
});
|
|
115
115
|
}
|
|
116
116
|
},
|
|
117
117
|
});
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -17,14 +17,12 @@ export type { InvalidateScope, OvertimeConfig } from './options.svelte';
|
|
|
17
17
|
export { createOvertimeTracker, createLiveSubscription } from './hook-utils.svelte';
|
|
18
18
|
export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook-utils.svelte';
|
|
19
19
|
|
|
20
|
-
// ───
|
|
21
|
-
// These hooks were in the original monolithic file. They are re-created
|
|
22
|
-
// here as thin wrappers or stubs until they get their own modular files.
|
|
20
|
+
// ─── Additional core hooks ─────────────────────────────────────────
|
|
23
21
|
|
|
24
22
|
import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
25
23
|
import { getAdminOptions } from './options.svelte';
|
|
26
24
|
import { captureAdminContext } from './context.svelte';
|
|
27
|
-
import {
|
|
25
|
+
import { queryKeyMatches } from './query-keys';
|
|
28
26
|
import { useParsed } from './useParsed.svelte';
|
|
29
27
|
import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification, checkError } from './hook-utils.svelte';
|
|
30
28
|
import { invalidateByScopes, publishLiveEvent } from './mutation-hooks.svelte';
|
|
@@ -59,15 +57,12 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
|
|
|
59
57
|
const resource = options.resource ?? parsed.resource ?? '';
|
|
60
58
|
const provider = adminContext.getDataProviderForResource(resource, options.dataProviderName);
|
|
61
59
|
return {
|
|
62
|
-
queryKey:
|
|
63
|
-
options.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
options.
|
|
67
|
-
|
|
68
|
-
options.filters,
|
|
69
|
-
options.meta,
|
|
70
|
-
], adminContext.tenantCacheKey),
|
|
60
|
+
queryKey: adminContext.queryKeys(resource, options.dataProviderName).data.infiniteList(resource, {
|
|
61
|
+
pageSize: options.pagination?.pageSize,
|
|
62
|
+
sorters: options.sorters,
|
|
63
|
+
filters: options.filters,
|
|
64
|
+
meta: options.meta,
|
|
65
|
+
}),
|
|
71
66
|
queryFn: async ({ pageParam = 1 }) => {
|
|
72
67
|
const result = await provider.getList<TData>({
|
|
73
68
|
resource,
|
|
@@ -173,9 +168,12 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
173
168
|
const query = createQuery<{ data: TData[]; total: number }>(() => {
|
|
174
169
|
const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
|
|
175
170
|
return {
|
|
176
|
-
queryKey:
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
queryKey: adminContext.queryKeys(resource, dataProviderName).data.select(resource, {
|
|
172
|
+
filters: allFilters,
|
|
173
|
+
sorters,
|
|
174
|
+
pagination,
|
|
175
|
+
meta,
|
|
176
|
+
}),
|
|
179
177
|
queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
|
|
180
178
|
enabled: options.queryOptions?.enabled ?? true,
|
|
181
179
|
staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
@@ -188,9 +186,10 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
188
186
|
? createQuery<{ data: TData[] }>(() => {
|
|
189
187
|
const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
|
|
190
188
|
return {
|
|
191
|
-
queryKey:
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
queryKey: adminContext.queryKeys(resource, dataProviderName).data.selectDefaults(resource, {
|
|
190
|
+
ids: defaultValueIds,
|
|
191
|
+
meta,
|
|
192
|
+
}),
|
|
194
193
|
queryFn: async () => {
|
|
195
194
|
if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta });
|
|
196
195
|
const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta })));
|
|
@@ -287,9 +286,12 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
|
|
|
287
286
|
const query = createQuery<{ data: TData }, TError>(() => {
|
|
288
287
|
const provider = adminContext.getDataProvider(options.dataProviderName);
|
|
289
288
|
return {
|
|
290
|
-
queryKey:
|
|
291
|
-
options.
|
|
292
|
-
|
|
289
|
+
queryKey: adminContext.queryKeys(undefined, options.dataProviderName).custom.call(
|
|
290
|
+
options.url,
|
|
291
|
+
options.url,
|
|
292
|
+
options.method,
|
|
293
|
+
{ config: options.config, meta: options.meta },
|
|
294
|
+
),
|
|
293
295
|
queryFn: async () => {
|
|
294
296
|
if (!provider.custom) throw new Error('DataProvider does not support custom method');
|
|
295
297
|
return provider.custom<TData>({
|
|
@@ -351,15 +353,13 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
|
|
|
351
353
|
},
|
|
352
354
|
onSuccess: (data, params) => {
|
|
353
355
|
if (params.resource && params.invalidates !== false) {
|
|
354
|
-
invalidateByScopes(
|
|
356
|
+
invalidateByScopes({
|
|
355
357
|
queryClient,
|
|
356
|
-
params.resource,
|
|
357
|
-
params.invalidates,
|
|
358
|
-
['list', 'many'],
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
(queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
|
|
362
|
-
);
|
|
358
|
+
resource: params.resource,
|
|
359
|
+
scopes: params.invalidates,
|
|
360
|
+
defaults: ['list', 'many'],
|
|
361
|
+
matcher: adminContext.queryKeyMatcher(params.resource, dataProviderName),
|
|
362
|
+
});
|
|
363
363
|
}
|
|
364
364
|
},
|
|
365
365
|
onError: (error) => {
|
|
@@ -415,15 +415,12 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
415
415
|
},
|
|
416
416
|
onSettled: (_d, _e, params) => {
|
|
417
417
|
const resName = params.resource ?? resource;
|
|
418
|
-
invalidateByScopes(
|
|
418
|
+
invalidateByScopes({
|
|
419
419
|
queryClient,
|
|
420
|
-
resName,
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
params.dataProviderName,
|
|
425
|
-
(queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
|
|
426
|
-
);
|
|
420
|
+
resource: resName,
|
|
421
|
+
defaults: ['list', 'many'],
|
|
422
|
+
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
423
|
+
});
|
|
427
424
|
},
|
|
428
425
|
}));
|
|
429
426
|
|
|
@@ -473,22 +470,21 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
473
470
|
},
|
|
474
471
|
onSettled: (_d, _e, params) => {
|
|
475
472
|
const resName = params.resource ?? resource;
|
|
476
|
-
invalidateByScopes(
|
|
473
|
+
invalidateByScopes({
|
|
477
474
|
queryClient,
|
|
478
|
-
resName,
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
params.dataProviderName,
|
|
483
|
-
(queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
|
|
484
|
-
);
|
|
475
|
+
resource: resName,
|
|
476
|
+
defaults: ['list', 'many', 'detail'],
|
|
477
|
+
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
478
|
+
});
|
|
485
479
|
for (const id of params.ids) {
|
|
486
480
|
queryClient.invalidateQueries({
|
|
487
|
-
predicate: (q) =>
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
481
|
+
predicate: (q) => queryKeyMatches(q.queryKey, {
|
|
482
|
+
...adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
483
|
+
kind: 'data',
|
|
484
|
+
resource: resName,
|
|
485
|
+
action: 'one',
|
|
486
|
+
id,
|
|
487
|
+
}),
|
|
492
488
|
});
|
|
493
489
|
}
|
|
494
490
|
},
|
|
@@ -539,22 +535,21 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
539
535
|
},
|
|
540
536
|
onSettled: (_d, _e, params) => {
|
|
541
537
|
const resName = params.resource ?? resource;
|
|
542
|
-
invalidateByScopes(
|
|
538
|
+
invalidateByScopes({
|
|
543
539
|
queryClient,
|
|
544
|
-
resName,
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
params.dataProviderName,
|
|
549
|
-
(queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
|
|
550
|
-
);
|
|
540
|
+
resource: resName,
|
|
541
|
+
defaults: ['list', 'many'],
|
|
542
|
+
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
543
|
+
});
|
|
551
544
|
for (const id of params.ids) {
|
|
552
545
|
queryClient.removeQueries({
|
|
553
|
-
predicate: (q) =>
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
546
|
+
predicate: (q) => queryKeyMatches(q.queryKey, {
|
|
547
|
+
...adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
548
|
+
kind: 'data',
|
|
549
|
+
resource: resName,
|
|
550
|
+
action: 'one',
|
|
551
|
+
id,
|
|
552
|
+
}),
|
|
558
553
|
});
|
|
559
554
|
}
|
|
560
555
|
},
|
|
@@ -572,23 +567,22 @@ export function useInvalidate() {
|
|
|
572
567
|
if (params.invalidates === false) return;
|
|
573
568
|
if (params.invalidates === 'all' || !params.resource) {
|
|
574
569
|
queryClient.invalidateQueries({
|
|
575
|
-
predicate: (q) =>
|
|
576
|
-
|
|
570
|
+
predicate: (q) => queryKeyMatches(q.queryKey, {
|
|
571
|
+
...adminContext.queryKeyMatcher(undefined, params.dataProviderName),
|
|
572
|
+
kind: 'data',
|
|
573
|
+
}),
|
|
577
574
|
});
|
|
578
575
|
return;
|
|
579
576
|
}
|
|
580
|
-
const scopes = params.invalidates || ['resourceAll'];
|
|
581
577
|
const res = params.resource;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res && q.queryKey[2] === 'many' });
|
|
591
|
-
}
|
|
578
|
+
invalidateByScopes({
|
|
579
|
+
queryClient,
|
|
580
|
+
resource: res,
|
|
581
|
+
scopes: params.invalidates || ['resourceAll'],
|
|
582
|
+
defaults: ['resourceAll'],
|
|
583
|
+
id: params.id,
|
|
584
|
+
matcher: adminContext.queryKeyMatcher(res, params.dataProviderName),
|
|
585
|
+
});
|
|
592
586
|
};
|
|
593
587
|
}
|
|
594
588
|
|
package/src/index.ts
CHANGED
|
@@ -13,16 +13,30 @@ export {
|
|
|
13
13
|
} from './context.svelte';
|
|
14
14
|
export type { DataProviderInput, AdminContextSource, AdminContextValue, AdminContextAccessor } from './context.svelte';
|
|
15
15
|
export {
|
|
16
|
-
appendTenantCacheKey,
|
|
17
16
|
createProviderBundle,
|
|
18
17
|
createTenantCacheKey,
|
|
19
18
|
defaultTenantAdapter,
|
|
20
19
|
isTenantCacheKey,
|
|
21
|
-
queryKeyMatchesTenant,
|
|
22
20
|
resolveTenantProviderMeta,
|
|
23
21
|
withProviderMeta,
|
|
24
22
|
withTenantDataProvider,
|
|
25
23
|
} from './provider-bundle';
|
|
24
|
+
export {
|
|
25
|
+
dataQueryMatches,
|
|
26
|
+
isQueryKey,
|
|
27
|
+
keys,
|
|
28
|
+
parseQueryKey,
|
|
29
|
+
queryKeyMatches,
|
|
30
|
+
queryKeys,
|
|
31
|
+
} from './query-keys';
|
|
32
|
+
export type {
|
|
33
|
+
DataQueryMatcher,
|
|
34
|
+
QueryDescriptor,
|
|
35
|
+
QueryKey,
|
|
36
|
+
QueryKeysBuilder,
|
|
37
|
+
QueryKeysContext,
|
|
38
|
+
QueryMatcher,
|
|
39
|
+
} from './query-keys';
|
|
26
40
|
export type {
|
|
27
41
|
ProviderBundle,
|
|
28
42
|
ProviderMetaInput,
|
|
@@ -80,7 +94,6 @@ export type { AdminOptions, TextTransformers, OvertimeConfig } from './options.s
|
|
|
80
94
|
export { checkError } from './hook-utils.svelte';
|
|
81
95
|
export type { NotificationConfig, OvertimeOptions, OvertimeResult, LiveSubscriptionParams } from './hook-utils.svelte';
|
|
82
96
|
|
|
83
|
-
export * from './query-keys';
|
|
84
97
|
export { HttpError, UndoError } from './types';
|
|
85
98
|
export type {
|
|
86
99
|
DataProvider, AuthProvider, NotificationProvider, MutationMode,
|
package/src/live.svelte.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
4
4
|
import { captureAdminContext } from './context.svelte';
|
|
5
|
-
import {
|
|
5
|
+
import { dataQueryMatches } from './query-keys';
|
|
6
6
|
|
|
7
7
|
// ─── Types ──────────────────────────────────────────────────────
|
|
8
8
|
|
|
@@ -53,10 +53,10 @@ export function useLive(
|
|
|
53
53
|
callback: (event) => {
|
|
54
54
|
options?.onLiveEvent?.(event);
|
|
55
55
|
if (liveMode === 'auto') {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
const matcher = adminContext.queryKeyMatcher(res, options?.dataProviderName);
|
|
57
|
+
queryClient.invalidateQueries({
|
|
58
|
+
predicate: (q) => dataQueryMatches(q.queryKey, { ...matcher, resource: res }),
|
|
59
|
+
});
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
1
|
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
3
2
|
import { getAdminOptions } from './options.svelte';
|
|
4
3
|
import { captureAdminContext } from './context.svelte';
|
|
5
|
-
import {
|
|
4
|
+
import { dataQueryMatches } from './query-keys';
|
|
5
|
+
import type { QueryMatcher } from './query-keys';
|
|
6
6
|
import type { AdminContextAccessor } from './context.svelte';
|
|
7
7
|
import { useParsed } from './useParsed.svelte';
|
|
8
8
|
import { auditWithProvider } from './audit';
|
|
@@ -39,39 +39,45 @@ export function publishLiveEvent(
|
|
|
39
39
|
* - update: ['list', 'many', 'detail']
|
|
40
40
|
* - delete: ['list', 'many']
|
|
41
41
|
*/
|
|
42
|
-
export
|
|
43
|
-
queryClient: ReturnType<typeof useQueryClient
|
|
44
|
-
resource: string
|
|
45
|
-
scopes
|
|
46
|
-
defaults: string[]
|
|
47
|
-
id?: string | number
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
42
|
+
export interface InvalidateByScopesRequest {
|
|
43
|
+
queryClient: ReturnType<typeof useQueryClient>;
|
|
44
|
+
resource: string;
|
|
45
|
+
scopes?: string[] | false;
|
|
46
|
+
defaults: string[];
|
|
47
|
+
id?: string | number;
|
|
48
|
+
matcher: Pick<QueryMatcher, 'provider' | 'tenant'>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function invalidateByScopes(request: InvalidateByScopesRequest): void {
|
|
52
|
+
const { queryClient, resource, scopes, defaults, id, matcher } = request;
|
|
51
53
|
if (scopes === false) return;
|
|
52
54
|
const effectiveScopes = (scopes && scopes.length > 0) ? scopes : defaults;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
const dataMatch = (queryKey: readonly unknown[], fields: QueryMatcher = {}) => dataQueryMatches(queryKey, {
|
|
56
|
+
...matcher,
|
|
57
|
+
resource,
|
|
58
|
+
...fields,
|
|
59
|
+
});
|
|
56
60
|
for (const scope of effectiveScopes) {
|
|
57
|
-
if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) =>
|
|
58
|
-
else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) =>
|
|
59
|
-
else if ((scope === 'detail' || scope === 'one') && id != null) queryClient.invalidateQueries({ predicate: (q) =>
|
|
60
|
-
else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) =>
|
|
61
|
-
else if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) =>
|
|
61
|
+
if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => ['list', 'infiniteList', 'select', 'selectDefaults'].some((action) => dataMatch(q.queryKey, { action })) });
|
|
62
|
+
else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => dataMatch(q.queryKey, { action: 'many' }) });
|
|
63
|
+
else if ((scope === 'detail' || scope === 'one') && id != null) queryClient.invalidateQueries({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id }) });
|
|
64
|
+
else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) => dataMatch(q.queryKey, { action: 'one' }) });
|
|
65
|
+
else if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => dataMatch(q.queryKey) });
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
export function deepMerge(target:
|
|
69
|
+
export function deepMerge(target: unknown, source: unknown): unknown {
|
|
66
70
|
if (!target || typeof target !== 'object') return source;
|
|
67
71
|
if (!source || typeof source !== 'object') return target;
|
|
68
72
|
if (Array.isArray(source) || source instanceof Date) return source;
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
const targetRecord = target as Record<string, unknown>;
|
|
74
|
+
const sourceRecord = source as Record<string, unknown>;
|
|
75
|
+
const result: Record<string, unknown> = { ...targetRecord };
|
|
76
|
+
for (const [key, value] of Object.entries(sourceRecord)) {
|
|
77
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
78
|
+
result[key] = deepMerge(targetRecord[key] ?? {}, value);
|
|
73
79
|
} else {
|
|
74
|
-
result[key] =
|
|
80
|
+
result[key] = value;
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
83
|
return result;
|
|
@@ -157,15 +163,13 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
157
163
|
);
|
|
158
164
|
publishLiveEvent(resName, 'created', newId != null ? [newId as string | number] : undefined, adminContext);
|
|
159
165
|
// Invalidation in onSuccess for create (refine pattern — no optimistic data to reconcile on error)
|
|
160
|
-
invalidateByScopes(
|
|
166
|
+
invalidateByScopes({
|
|
161
167
|
queryClient,
|
|
162
|
-
resName,
|
|
163
|
-
params.invalidates,
|
|
164
|
-
['list', 'many'],
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
(queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
|
|
168
|
-
);
|
|
168
|
+
resource: resName,
|
|
169
|
+
scopes: params.invalidates,
|
|
170
|
+
defaults: ['list', 'many'],
|
|
171
|
+
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
172
|
+
});
|
|
169
173
|
if (typeof options.mutationOptions?.onSuccess === 'function') {
|
|
170
174
|
(options.mutationOptions.onSuccess as (...a: unknown[]) => unknown)(data, params, context);
|
|
171
175
|
}
|
|
@@ -263,13 +267,16 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
263
267
|
if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
|
|
264
268
|
const resName = params.resource ?? defaultResource;
|
|
265
269
|
const targetId = params.id ?? defaultId;
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
270
|
+
const matcher = adminContext.queryKeyMatcher(resName, params.dataProviderName);
|
|
271
|
+
const dataMatch = (queryKey: readonly unknown[], fields: QueryMatcher = {}) => dataQueryMatches(queryKey, {
|
|
272
|
+
...matcher,
|
|
273
|
+
resource: resName,
|
|
274
|
+
...fields,
|
|
275
|
+
});
|
|
269
276
|
|
|
270
|
-
await queryClient.cancelQueries({ predicate: (q) =>
|
|
277
|
+
await queryClient.cancelQueries({ predicate: (q) => dataMatch(q.queryKey) });
|
|
271
278
|
|
|
272
|
-
const previousQueries = queryClient.getQueriesData({ predicate: (q) =>
|
|
279
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => dataMatch(q.queryKey) });
|
|
273
280
|
|
|
274
281
|
const pk = adminContext.getResource(resName).primaryKey ?? 'id';
|
|
275
282
|
const updater = params.optimisticUpdateMap ?? { list: true, many: true, detail: true };
|
|
@@ -277,14 +284,17 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
277
284
|
if (updater.detail !== false) {
|
|
278
285
|
const detailFn = updater.detail;
|
|
279
286
|
if (typeof detailFn === 'function') {
|
|
280
|
-
queryClient.setQueriesData(
|
|
287
|
+
queryClient.setQueriesData(
|
|
288
|
+
{ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) },
|
|
289
|
+
(old: unknown) => detailFn(old, params.variables, targetId),
|
|
290
|
+
);
|
|
281
291
|
} else {
|
|
282
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
292
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, params.variables) } : old);
|
|
283
293
|
}
|
|
284
294
|
}
|
|
285
295
|
|
|
286
296
|
if (updater.list !== false) {
|
|
287
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
297
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'list' }) }, (old: unknown) => {
|
|
288
298
|
if (typeof updater.list === 'function') return updater.list(old, params.variables, targetId);
|
|
289
299
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
290
300
|
const o = old as { data: Record<string, unknown>[] };
|
|
@@ -293,7 +303,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
293
303
|
}
|
|
294
304
|
|
|
295
305
|
if (updater.many !== false) {
|
|
296
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
306
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'many' }) }, (old: unknown) => {
|
|
297
307
|
if (typeof updater.many === 'function') return updater.many(old, params.variables, targetId);
|
|
298
308
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
299
309
|
const o = old as { data: Record<string, unknown>[] };
|
|
@@ -333,15 +343,14 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
333
343
|
if (error instanceof UndoError) return;
|
|
334
344
|
const resName = params.resource ?? defaultResource;
|
|
335
345
|
const targetId = params.id ?? defaultId;
|
|
336
|
-
invalidateByScopes(
|
|
346
|
+
invalidateByScopes({
|
|
337
347
|
queryClient,
|
|
338
|
-
resName,
|
|
339
|
-
params.invalidates,
|
|
340
|
-
['list', 'many', 'detail'],
|
|
341
|
-
targetId != null ? targetId : undefined,
|
|
342
|
-
params.dataProviderName,
|
|
343
|
-
|
|
344
|
-
);
|
|
348
|
+
resource: resName,
|
|
349
|
+
scopes: params.invalidates,
|
|
350
|
+
defaults: ['list', 'many', 'detail'],
|
|
351
|
+
id: targetId != null ? targetId : undefined,
|
|
352
|
+
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
353
|
+
});
|
|
345
354
|
if (typeof options.mutationOptions?.onSettled === 'function') {
|
|
346
355
|
(options.mutationOptions.onSettled as (...a: unknown[]) => unknown)(_data, error, params, undefined);
|
|
347
356
|
}
|
|
@@ -442,26 +451,29 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
442
451
|
if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
|
|
443
452
|
const resName = params.resource ?? defaultResource;
|
|
444
453
|
const targetId = params.id ?? defaultId;
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
|
|
454
|
+
const matcher = adminContext.queryKeyMatcher(resName, params.dataProviderName);
|
|
455
|
+
const dataMatch = (queryKey: readonly unknown[], fields: QueryMatcher = {}) => dataQueryMatches(queryKey, {
|
|
456
|
+
...matcher,
|
|
457
|
+
resource: resName,
|
|
458
|
+
...fields,
|
|
459
|
+
});
|
|
448
460
|
|
|
449
|
-
await queryClient.cancelQueries({ predicate: (q) =>
|
|
461
|
+
await queryClient.cancelQueries({ predicate: (q) => dataMatch(q.queryKey) });
|
|
450
462
|
|
|
451
|
-
const previousQueries = queryClient.getQueriesData({ predicate: (q) =>
|
|
463
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (q) => dataMatch(q.queryKey) });
|
|
452
464
|
|
|
453
465
|
const pk = adminContext.getResource(resName).primaryKey ?? 'id';
|
|
454
466
|
|
|
455
|
-
if (targetId != null) queryClient.removeQueries({ predicate: (q) =>
|
|
467
|
+
if (targetId != null) queryClient.removeQueries({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) });
|
|
456
468
|
|
|
457
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
469
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'list' }) }, (old: unknown) => {
|
|
458
470
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
459
471
|
const o = old as { data: Record<string, unknown>[]; total?: number };
|
|
460
472
|
const filtered = o.data.filter((item) => String(item[pk]) !== String(targetId));
|
|
461
473
|
return { ...o, data: filtered, total: (o.total ?? o.data.length) - (o.data.length - filtered.length) };
|
|
462
474
|
});
|
|
463
475
|
|
|
464
|
-
queryClient.setQueriesData({ predicate: (q) =>
|
|
476
|
+
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'many' }) }, (old: unknown) => {
|
|
465
477
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
466
478
|
const o = old as { data: Record<string, unknown>[] };
|
|
467
479
|
return { ...o, data: o.data.filter((item) => String(item[pk]) !== String(targetId)) };
|
|
@@ -477,11 +489,12 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
477
489
|
// Remove detail cache entry (refine pattern — no stale show page)
|
|
478
490
|
if (targetId != null) {
|
|
479
491
|
queryClient.removeQueries({
|
|
480
|
-
predicate: (q) =>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
492
|
+
predicate: (q) => dataQueryMatches(q.queryKey, {
|
|
493
|
+
...adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
494
|
+
resource: resName,
|
|
495
|
+
action: 'one',
|
|
496
|
+
id: targetId,
|
|
497
|
+
}),
|
|
485
498
|
});
|
|
486
499
|
}
|
|
487
500
|
|
|
@@ -511,15 +524,14 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
511
524
|
if (error instanceof UndoError) return;
|
|
512
525
|
const resName = params.resource ?? defaultResource;
|
|
513
526
|
const targetId = params.id ?? defaultId;
|
|
514
|
-
invalidateByScopes(
|
|
527
|
+
invalidateByScopes({
|
|
515
528
|
queryClient,
|
|
516
|
-
resName,
|
|
517
|
-
params.invalidates,
|
|
518
|
-
['list', 'many'],
|
|
519
|
-
targetId != null ? targetId : undefined,
|
|
520
|
-
params.dataProviderName,
|
|
521
|
-
|
|
522
|
-
);
|
|
529
|
+
resource: resName,
|
|
530
|
+
scopes: params.invalidates,
|
|
531
|
+
defaults: ['list', 'many'],
|
|
532
|
+
id: targetId != null ? targetId : undefined,
|
|
533
|
+
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
534
|
+
});
|
|
523
535
|
if (typeof options.mutationOptions?.onSettled === 'function') {
|
|
524
536
|
(options.mutationOptions.onSettled as (...a: unknown[]) => unknown)(_data, error, params, undefined);
|
|
525
537
|
}
|
package/src/provider-bundle.ts
CHANGED
|
@@ -116,23 +116,6 @@ export function isTenantCacheKey(value: unknown): value is TenantCacheKey {
|
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
export function appendTenantCacheKey(
|
|
120
|
-
queryKey: readonly unknown[],
|
|
121
|
-
tenantCacheKey: TenantCacheKey | undefined,
|
|
122
|
-
): readonly unknown[] {
|
|
123
|
-
return tenantCacheKey ? [...queryKey, tenantCacheKey] : queryKey;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function queryKeyMatchesTenant(
|
|
127
|
-
queryKey: readonly unknown[],
|
|
128
|
-
tenantCacheKey: TenantCacheKey | undefined,
|
|
129
|
-
): boolean {
|
|
130
|
-
const candidate = queryKey.at(-1);
|
|
131
|
-
const keyTenant = isTenantCacheKey(candidate) ? candidate : undefined;
|
|
132
|
-
if (!tenantCacheKey) return keyTenant === undefined;
|
|
133
|
-
return keyTenant?.__svadminTenant === tenantCacheKey.__svadminTenant;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
119
|
export interface ProviderMetaInput {
|
|
137
120
|
readonly resource?: string;
|
|
138
121
|
readonly meta?: Record<string, unknown>;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createQuery } from '@tanstack/svelte-query';
|
|
2
2
|
import { getAdminOptions } from './options.svelte';
|
|
3
3
|
import { captureAdminContext } from './context.svelte';
|
|
4
|
-
import { appendTenantCacheKey } from './provider-bundle';
|
|
5
4
|
import { useParsed } from './useParsed.svelte';
|
|
6
5
|
import {
|
|
7
6
|
checkError,
|
|
@@ -110,9 +109,12 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
|
|
|
110
109
|
const filters = [...parentFilters, ...(explicitFilters ?? [])];
|
|
111
110
|
|
|
112
111
|
return {
|
|
113
|
-
queryKey:
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
queryKey: adminContext.queryKeys(resource, opts.dataProviderName).data.list(resource, {
|
|
113
|
+
pagination,
|
|
114
|
+
sorters,
|
|
115
|
+
filters,
|
|
116
|
+
meta,
|
|
117
|
+
}),
|
|
116
118
|
queryFn: async () => provider.getList<TData>({
|
|
117
119
|
resource,
|
|
118
120
|
pagination,
|
|
@@ -210,10 +212,9 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
|
|
|
210
212
|
const queryOptions = opts.queryOptions;
|
|
211
213
|
|
|
212
214
|
return {
|
|
213
|
-
queryKey:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
),
|
|
215
|
+
queryKey: adminContext.queryKeys(resource, opts.dataProviderName).data.one(resource, id ?? '', {
|
|
216
|
+
meta: cloneQueryKeyPart(opts.meta),
|
|
217
|
+
}),
|
|
217
218
|
queryFn: async () => {
|
|
218
219
|
if (id == null) throw new Error('useOne requires an id');
|
|
219
220
|
const result = await provider.getOne<TData>({
|
|
@@ -330,10 +331,10 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
|
|
|
330
331
|
const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
|
|
331
332
|
|
|
332
333
|
return {
|
|
333
|
-
queryKey:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
),
|
|
334
|
+
queryKey: adminContext.queryKeys(resource, dataProviderName).data.many(resource, {
|
|
335
|
+
ids: cloneQueryKeyPart(ids),
|
|
336
|
+
meta: cloneQueryKeyPart(meta),
|
|
337
|
+
}),
|
|
337
338
|
queryFn: async () => {
|
|
338
339
|
if (!ids.length) return { data: [] };
|
|
339
340
|
if (provider.getMany) {
|
package/src/query-keys.ts
CHANGED
|
@@ -1,21 +1,127 @@
|
|
|
1
|
-
export const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
export const NAMESPACE = 'svadmin';
|
|
2
|
+
export const VERSION = 2;
|
|
3
|
+
|
|
4
|
+
export type QueryKind = 'data' | 'task' | 'access' | 'custom';
|
|
5
|
+
export type QueryDataAction = 'list' | 'infiniteList' | 'one' | 'many' | 'select' | 'selectDefaults';
|
|
6
|
+
export type QueryTaskAction = 'list' | 'one';
|
|
7
|
+
type TenantId = string | number;
|
|
8
|
+
|
|
9
|
+
type BaseDescriptor = {
|
|
10
|
+
readonly namespace: typeof NAMESPACE;
|
|
11
|
+
readonly version: typeof VERSION;
|
|
12
|
+
readonly provider: string;
|
|
13
|
+
readonly tenant?: TenantId;
|
|
14
|
+
readonly resource?: string;
|
|
15
|
+
readonly id?: string | number;
|
|
16
|
+
readonly params?: unknown;
|
|
17
|
+
readonly method?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type QueryDescriptorData = BaseDescriptor & { readonly kind: 'data'; readonly action: QueryDataAction };
|
|
21
|
+
export type QueryDescriptorTask = BaseDescriptor & { readonly kind: 'task'; readonly action: QueryTaskAction };
|
|
22
|
+
export type QueryDescriptorAccess = BaseDescriptor & { readonly kind: 'access'; readonly action?: 'can' };
|
|
23
|
+
export type QueryDescriptorCustom = BaseDescriptor & { readonly kind: 'custom'; readonly action?: string };
|
|
24
|
+
export type QueryDescriptor = QueryDescriptorData | QueryDescriptorTask | QueryDescriptorAccess | QueryDescriptorCustom;
|
|
25
|
+
export type QueryKey = readonly [descriptor: QueryDescriptor];
|
|
26
|
+
|
|
27
|
+
export type QueryKeysContext = { readonly provider?: string; readonly tenant?: TenantId; readonly resource?: string; readonly action?: string; readonly id?: string | number; readonly params?: unknown; readonly method?: string };
|
|
28
|
+
export type QueryMatcher = { readonly provider?: string; readonly tenant?: TenantId; readonly resource?: string; readonly action?: string; readonly id?: string | number; readonly method?: string; readonly kind?: QueryKind };
|
|
29
|
+
export type DataQueryMatcher = Omit<QueryMatcher, 'kind'> & { readonly resource: string };
|
|
30
|
+
export type QueryKeysBuilder = {
|
|
31
|
+
data: { list(resource: string, params?: unknown): QueryKey; infiniteList(resource: string, params?: unknown): QueryKey; one(resource: string, id: string | number, params?: unknown): QueryKey; many(resource: string, params: unknown): QueryKey; select(resource: string, params?: unknown): QueryKey; selectDefaults(resource: string, params?: unknown): QueryKey; };
|
|
32
|
+
task: { list(params?: unknown): QueryKey; one(id: string | number): QueryKey };
|
|
33
|
+
access: { can(resource?: string, params?: unknown): QueryKey };
|
|
34
|
+
custom: { call(resource: string, id: string | number, method?: string, params?: unknown): QueryKey };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function withDefaultProvider(value: string | undefined): string { return value === undefined ? 'default' : value; }
|
|
38
|
+
function assertProvider(provider: string): void { if (!provider.length) throw new Error('provider must be a non-empty string'); }
|
|
39
|
+
|
|
40
|
+
function makeDescriptor(value: { kind: 'data'; provider?: string; tenant?: TenantId; resource?: string; action: QueryDataAction; id?: string | number; params?: unknown; method?: string; }): QueryDescriptorData;
|
|
41
|
+
function makeDescriptor(value: { kind: 'task'; provider?: string; tenant?: TenantId; resource?: string; action: QueryTaskAction; id?: string | number; params?: unknown; method?: string; }): QueryDescriptorTask;
|
|
42
|
+
function makeDescriptor(value: { kind: 'access'; provider?: string; tenant?: TenantId; resource?: string; action?: 'can'; id?: string | number; params?: unknown; method?: string; }): QueryDescriptorAccess;
|
|
43
|
+
function makeDescriptor(value: { kind: 'custom'; provider?: string; tenant?: TenantId; resource?: string; id?: string | number; action?: string; params?: unknown; method?: string; }): QueryDescriptorCustom;
|
|
44
|
+
function makeDescriptor(value: { kind: QueryKind; provider?: string; tenant?: TenantId; resource?: string; action?: string; id?: string | number; params?: unknown; method?: string; }): QueryDescriptor {
|
|
45
|
+
const provider = withDefaultProvider(value.provider);
|
|
46
|
+
assertProvider(provider);
|
|
47
|
+
|
|
48
|
+
const base = { namespace: NAMESPACE, version: VERSION, provider, tenant: value.tenant, resource: value.resource, id: value.id, params: value.params, method: value.method } as const;
|
|
49
|
+
if (value.kind === 'data') return { ...base, kind: 'data', action: value.action } as QueryDescriptorData;
|
|
50
|
+
if (value.kind === 'task') return { ...base, kind: 'task', action: value.action as QueryTaskAction } as QueryDescriptorTask;
|
|
51
|
+
if (value.kind === 'access') return { ...base, kind: 'access', action: value.action as 'can' | undefined } as QueryDescriptorAccess;
|
|
52
|
+
return { ...base, kind: 'custom', action: value.action } as QueryDescriptorCustom;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function keys(context: QueryKeysContext = {}): QueryKeysBuilder {
|
|
56
|
+
const base = { provider: withDefaultProvider(context.provider), tenant: context.tenant, resource: context.resource, id: context.id, params: context.params, method: context.method };
|
|
57
|
+
return {
|
|
58
|
+
data: {
|
|
59
|
+
list: (resource, params = base.params) => [makeDescriptor({ kind: 'data', provider: base.provider, tenant: base.tenant, resource, action: 'list', id: base.id, params, method: base.method })],
|
|
60
|
+
infiniteList: (resource, params = base.params) => [makeDescriptor({ kind: 'data', provider: base.provider, tenant: base.tenant, resource, action: 'infiniteList', id: base.id, params, method: base.method })],
|
|
61
|
+
one: (resource, id, params = base.params) => [makeDescriptor({ kind: 'data', provider: base.provider, tenant: base.tenant, resource, action: 'one', id, params, method: base.method })],
|
|
62
|
+
many: (resource, params) => [makeDescriptor({ kind: 'data', provider: base.provider, tenant: base.tenant, resource, action: 'many', params, method: base.method })],
|
|
63
|
+
select: (resource, params = base.params) => [makeDescriptor({ kind: 'data', provider: base.provider, tenant: base.tenant, resource, action: 'select', id: base.id, params, method: base.method })],
|
|
64
|
+
selectDefaults: (resource, params = base.params) => [makeDescriptor({ kind: 'data', provider: base.provider, tenant: base.tenant, resource, action: 'selectDefaults', id: base.id, params, method: base.method })],
|
|
65
|
+
},
|
|
66
|
+
task: {
|
|
67
|
+
list: (params = base.params) => [makeDescriptor({ kind: 'task', provider: base.provider, tenant: base.tenant, resource: base.resource, action: 'list', id: base.id, params, method: base.method })],
|
|
68
|
+
one: (id) => [makeDescriptor({ kind: 'task', provider: base.provider, tenant: base.tenant, resource: base.resource, action: 'one', id, method: base.method })],
|
|
69
|
+
},
|
|
70
|
+
access: { can: (resource = base.resource, params = base.params) => [makeDescriptor({ kind: 'access', provider: base.provider, tenant: base.tenant, resource, action: 'can', id: base.id, params, method: base.method })] },
|
|
71
|
+
custom: { call: (resource, id, method, params) => [makeDescriptor({ kind: 'custom', provider: base.provider, tenant: base.tenant, resource, id, method, params, action: 'call' })] },
|
|
18
72
|
};
|
|
73
|
+
}
|
|
19
74
|
|
|
20
|
-
|
|
75
|
+
export const queryKeys: QueryKeysBuilder = {
|
|
76
|
+
data: {
|
|
77
|
+
list: (resource, params) => keys().data.list(resource, params),
|
|
78
|
+
infiniteList: (resource, params) => keys().data.infiniteList(resource, params),
|
|
79
|
+
one: (resource, id, params) => keys().data.one(resource, id, params),
|
|
80
|
+
many: (resource, params) => keys().data.many(resource, params),
|
|
81
|
+
select: (resource, params) => keys().data.select(resource, params),
|
|
82
|
+
selectDefaults: (resource, params) => keys().data.selectDefaults(resource, params),
|
|
83
|
+
},
|
|
84
|
+
task: { list: (params) => keys().task.list(params), one: (id) => keys().task.one(id) },
|
|
85
|
+
access: { can: (resource, params) => keys().access.can(resource, params) },
|
|
86
|
+
custom: { call: (resource, id, method, params) => keys().custom.call(resource, id, method, params) },
|
|
21
87
|
};
|
|
88
|
+
|
|
89
|
+
export function parseQueryKey(value: unknown): QueryDescriptor | undefined {
|
|
90
|
+
if (!Array.isArray(value) || value.length !== 1) return;
|
|
91
|
+
const candidate = value[0];
|
|
92
|
+
if (!candidate || typeof candidate !== 'object') return;
|
|
93
|
+
const descriptor = candidate as QueryDescriptor;
|
|
94
|
+
if (descriptor.namespace !== NAMESPACE || descriptor.version !== VERSION || typeof descriptor.provider !== 'string' || !descriptor.provider.length) return;
|
|
95
|
+
if (descriptor.tenant !== undefined && typeof descriptor.tenant !== 'string' && typeof descriptor.tenant !== 'number') return;
|
|
96
|
+
if (descriptor.resource !== undefined && typeof descriptor.resource !== 'string') return;
|
|
97
|
+
if (descriptor.id !== undefined && typeof descriptor.id !== 'string' && typeof descriptor.id !== 'number') return;
|
|
98
|
+
if (descriptor.method !== undefined && typeof descriptor.method !== 'string') return;
|
|
99
|
+
if (descriptor.kind === 'data' && ['list', 'infiniteList', 'one', 'many', 'select', 'selectDefaults'].includes(descriptor.action)) return descriptor;
|
|
100
|
+
if (descriptor.kind === 'task' && ['list', 'one'].includes(descriptor.action)) return descriptor;
|
|
101
|
+
if (descriptor.kind === 'access' && (descriptor.action === undefined || descriptor.action === 'can')) return descriptor;
|
|
102
|
+
if (descriptor.kind === 'custom' && (descriptor.action === undefined || typeof descriptor.action === 'string')) return descriptor;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isQueryKey(value: unknown): value is QueryKey { return parseQueryKey(value) !== undefined; }
|
|
106
|
+
|
|
107
|
+
function has(value: QueryMatcher, key: keyof QueryMatcher): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
|
|
108
|
+
function matchField(descriptor: QueryDescriptor, matcher: QueryMatcher, key: keyof QueryMatcher): boolean {
|
|
109
|
+
if (!has(matcher, key)) return true;
|
|
110
|
+
if (matcher[key] === undefined) return !(key in descriptor) || (descriptor as Record<string, unknown>)[key] === undefined;
|
|
111
|
+
return (descriptor as Record<string, unknown>)[key] === matcher[key];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function queryKeyMatches(queryKey: readonly unknown[], matcher: QueryMatcher = {}): boolean {
|
|
115
|
+
const parsed = parseQueryKey(queryKey);
|
|
116
|
+
if (!parsed) return false;
|
|
117
|
+
if (matcher.kind !== undefined && matcher.kind !== parsed.kind) return false;
|
|
118
|
+
if (has(matcher, 'provider')) {
|
|
119
|
+
if (matcher.provider === undefined) return parsed.provider === 'default';
|
|
120
|
+
if (matcher.provider !== parsed.provider) return false;
|
|
121
|
+
}
|
|
122
|
+
return matchField(parsed, matcher, 'tenant') && matchField(parsed, matcher, 'resource') && matchField(parsed, matcher, 'action') && matchField(parsed, matcher, 'id') && matchField(parsed, matcher, 'method');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function dataQueryMatches(queryKey: readonly unknown[], matcher: DataQueryMatcher): boolean {
|
|
126
|
+
return queryKeyMatches(queryKey, { ...matcher, kind: 'data' });
|
|
127
|
+
}
|
package/src/task-hooks.svelte.ts
CHANGED
|
@@ -3,10 +3,9 @@ import { getAdminOptions } from './options.svelte';
|
|
|
3
3
|
import { captureAdminContext } from './context.svelte';
|
|
4
4
|
import type { AdminContextAccessor } from './context.svelte';
|
|
5
5
|
import {
|
|
6
|
-
appendTenantCacheKey,
|
|
7
|
-
queryKeyMatchesTenant,
|
|
8
6
|
resolveTenantProviderMeta,
|
|
9
7
|
} from './provider-bundle';
|
|
8
|
+
import { queryKeyMatches } from './query-keys';
|
|
10
9
|
import { checkError, createOvertimeTracker, fireErrorNotification, fireSuccessNotification } from './hook-utils.svelte';
|
|
11
10
|
import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
|
|
12
11
|
import { useTranslation } from './i18n.svelte';
|
|
@@ -80,8 +79,11 @@ export function useSubmitTask<
|
|
|
80
79
|
provider: adminContext.notificationProvider,
|
|
81
80
|
});
|
|
82
81
|
void queryClient.invalidateQueries({
|
|
83
|
-
predicate: (query) => query.queryKey
|
|
84
|
-
|
|
82
|
+
predicate: (query) => queryKeyMatches(query.queryKey, {
|
|
83
|
+
...adminContext.queryKeyMatcher(),
|
|
84
|
+
kind: 'task',
|
|
85
|
+
action: 'list',
|
|
86
|
+
}),
|
|
85
87
|
});
|
|
86
88
|
options.mutationOptions?.onSuccess?.(data, params, context);
|
|
87
89
|
},
|
|
@@ -128,7 +130,7 @@ export function useTask<TTask extends TaskRecord = TaskRecord, TError = HttpErro
|
|
|
128
130
|
const provider = normalizeTaskProvider<TTask>(options.taskProvider, adminContext);
|
|
129
131
|
const queryOptions = options.queryOptions;
|
|
130
132
|
return {
|
|
131
|
-
queryKey:
|
|
133
|
+
queryKey: adminContext.queryKeys().task.one(options.taskId ?? ''),
|
|
132
134
|
queryFn: async () => {
|
|
133
135
|
if (!options.taskId) throw new Error('useTask requires a taskId');
|
|
134
136
|
return provider.get(options.taskId);
|
|
@@ -207,10 +209,10 @@ export function useTaskList<TTask extends TaskRecord = TaskRecord, TError = Http
|
|
|
207
209
|
const provider = normalizeTaskProvider<TTask>(options.taskProvider, adminContext);
|
|
208
210
|
const queryOptions = options.queryOptions;
|
|
209
211
|
return {
|
|
210
|
-
queryKey:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
),
|
|
212
|
+
queryKey: adminContext.queryKeys().task.list({
|
|
213
|
+
list: options.dlq ? 'dlq' : 'default',
|
|
214
|
+
params: options.params,
|
|
215
|
+
}),
|
|
214
216
|
queryFn: async () => {
|
|
215
217
|
if (options.dlq) {
|
|
216
218
|
if (!provider.listDlq) throw new Error('TaskProvider does not implement listDlq');
|
package/src/useCan.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query';
|
|
4
4
|
import { captureAdminContext } from './context.svelte';
|
|
5
|
-
import { appendTenantCacheKey } from './provider-bundle';
|
|
6
5
|
import type { Action, CanResult } from './permissions.svelte';
|
|
7
6
|
|
|
8
7
|
export interface UseCanOptions {
|
|
@@ -38,9 +37,11 @@ export function useCan(options: () => UseCanOptions): UseCanResult {
|
|
|
38
37
|
const opts = options();
|
|
39
38
|
const provider = adminContext.accessControlProvider;
|
|
40
39
|
return {
|
|
41
|
-
queryKey:
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
queryKey: adminContext.queryKeys(opts.resource).access.can(opts.resource, {
|
|
41
|
+
action: opts.action,
|
|
42
|
+
params: opts.params,
|
|
43
|
+
meta: opts.meta,
|
|
44
|
+
}),
|
|
44
45
|
queryFn: async () => {
|
|
45
46
|
if (!provider) return { can: true };
|
|
46
47
|
const result = await provider.can({
|
|
@@ -2,7 +2,7 @@ import { useForm, type UseFormOptions } from './form-hooks.svelte';
|
|
|
2
2
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
3
3
|
import { useParsed } from './useParsed.svelte';
|
|
4
4
|
import { captureAdminContext } from './context.svelte';
|
|
5
|
-
import {
|
|
5
|
+
import { dataQueryMatches } from './query-keys';
|
|
6
6
|
import { getAdminOptions } from './options.svelte';
|
|
7
7
|
import { t } from './i18n.svelte';
|
|
8
8
|
import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
|
|
@@ -59,12 +59,11 @@ export function useModalForm<
|
|
|
59
59
|
visible = false;
|
|
60
60
|
formState.reset();
|
|
61
61
|
if (options.autoSave?.invalidateOnClose) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && q.queryKey[2] === 'one' });
|
|
62
|
+
const resource = formState.resource;
|
|
63
|
+
const matcher = adminContext.queryKeyMatcher(resource, options.dataProviderName);
|
|
64
|
+
queryClient.invalidateQueries({
|
|
65
|
+
predicate: (q) => dataQueryMatches(q.queryKey, { ...matcher, resource }),
|
|
66
|
+
});
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
69
|
|