@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- getDataProviderForResource(resourceName, overrideName) {
275
+ resolveDataProviderName(resourceName, overrideName) {
275
276
  if (overrideName) {
276
277
  try {
277
- return accessor.getDataProvider(overrideName);
278
+ accessor.getDataProvider(overrideName);
279
+ return overrideName;
278
280
  } catch {
279
- // Preserve the historical fallback to the resource/default provider.
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 dataProviderName = resource.provider?.dataProviderName ?? resource.meta?.dataProviderName;
285
- if (dataProviderName) return accessor.getDataProvider(dataProviderName);
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 the historical fallback when a resource is not registered.
293
+ // Preserve fallback when a resource is not found or malformed.
288
294
  }
289
- return accessor.getDataProvider();
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,
@@ -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 { appendTenantCacheKey, queryKeyMatchesTenant } from './provider-bundle';
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: appendTenantCacheKey([
462
- options.dataProviderName, resource, 'one', currentId, queryMeta,
463
- ], adminContext.tenantCacheKey),
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
- undefined,
536
- options.dataProviderName,
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 dpN = options.dataProviderName;
588
- const dp = (q: { queryKey: readonly unknown[] }) =>
589
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpN;
590
- await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resource });
591
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resource });
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) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
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) => dp(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
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
- (queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
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
- (queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
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) => queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey)
818
- && q.queryKey[0] === options.dataProviderName
819
- && q.queryKey[1] === resource,
817
+ predicate: (q) => queryKeyMatches(q.queryKey, {
818
+ ...adminContext.queryKeyMatcher(resource, options.dataProviderName),
819
+ kind: 'data',
820
+ resource,
821
+ }),
820
822
  });
821
823
  });
822
824
  }
@@ -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 { queryKeyMatchesTenant } from './provider-bundle';
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 dpN = params.dataProviderName;
112
- const dpMatch = (q: { queryKey: readonly unknown[] }) =>
113
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpN;
114
- queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === params.resource });
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
  });
@@ -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
- // ─── Missing hooks stubs ───────────────────────────────────────────
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 { appendTenantCacheKey, queryKeyMatchesTenant } from './provider-bundle';
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: appendTenantCacheKey([
63
- options.dataProviderName,
64
- resource,
65
- 'infiniteList',
66
- options.pagination?.pageSize,
67
- options.sorters,
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: appendTenantCacheKey([
177
- dataProviderName, resource, 'select', allFilters, sorters, pagination, meta,
178
- ], adminContext.tenantCacheKey),
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: appendTenantCacheKey([
192
- dataProviderName, resource, 'select-defaults', defaultValueIds,
193
- ], adminContext.tenantCacheKey),
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: appendTenantCacheKey([
291
- options.dataProviderName, 'custom', options.url, options.method, options.config, options.meta,
292
- ], adminContext.tenantCacheKey),
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
- undefined,
360
- dataProviderName,
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
- undefined,
422
- ['list', 'many'],
423
- undefined,
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
- undefined,
480
- ['list', 'many', 'detail'],
481
- undefined,
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) => queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey)
488
- && q.queryKey[0] === params.dataProviderName
489
- && q.queryKey[1] === resName
490
- && q.queryKey[2] === 'one'
491
- && q.queryKey[3] === id,
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
- undefined,
546
- ['list', 'many'],
547
- undefined,
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) => queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey)
554
- && q.queryKey[0] === params.dataProviderName
555
- && q.queryKey[1] === resName
556
- && q.queryKey[2] === 'one'
557
- && q.queryKey[3] === id,
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) => queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey)
576
- && q.queryKey[0] === params.dataProviderName,
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
- const dpMatch = (q: { queryKey: readonly unknown[] }) =>
583
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey)
584
- && q.queryKey[0] === params.dataProviderName;
585
- for (const scope of scopes) {
586
- if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res });
587
- else if ((scope === 'detail' || scope === 'one') && params.id) queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res && q.queryKey[2] === 'one' && q.queryKey[3] === params.id });
588
- else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res && q.queryKey[2] === 'one' });
589
- else if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
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,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useQueryClient } from '@tanstack/svelte-query';
4
4
  import { captureAdminContext } from './context.svelte';
5
- import { queryKeyMatchesTenant } from './provider-bundle';
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 dpName = options?.dataProviderName;
57
- const dpMatch = (q: { queryKey: readonly unknown[] }) =>
58
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpName;
59
- queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res });
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 { queryKeyMatchesTenant } from './provider-bundle';
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 function invalidateByScopes(
43
- queryClient: ReturnType<typeof useQueryClient>,
44
- resource: string,
45
- scopes: string[] | false | undefined,
46
- defaults: string[],
47
- id?: string | number,
48
- dataProviderName?: string,
49
- queryMatches: (queryKey: readonly unknown[]) => boolean = () => true,
50
- ): void {
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 dpMatch = dataProviderName === undefined
54
- ? (q: { queryKey: readonly unknown[] }) => queryMatches(q.queryKey)
55
- : (q: { queryKey: readonly unknown[] }) => queryMatches(q.queryKey) && q.queryKey[0] === dataProviderName;
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) => dpMatch(q) && q.queryKey[1] === resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select' || q.queryKey[2] === 'select-defaults') });
58
- else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'many' });
59
- else if ((scope === 'detail' || scope === 'one') && id != null) queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === id });
60
- else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' });
61
- else if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource });
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: any, source: any): any {
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 result = { ...target };
70
- for (const key in source) {
71
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && !(source[key] instanceof Date)) {
72
- result[key] = deepMerge(result[key] || {}, source[key]);
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] = source[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
- undefined,
166
- params.dataProviderName,
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 dpN = params.dataProviderName;
267
- const dp = (q: { queryKey: readonly unknown[] }) =>
268
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpN;
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) => dp(q) && q.queryKey[1] === resName });
277
+ await queryClient.cancelQueries({ predicate: (q) => dataMatch(q.queryKey) });
271
278
 
272
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
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({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: unknown) => detailFn(old, params.variables, targetId));
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) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, params.variables) } : old);
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) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
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) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
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
- (queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
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 dpN = params.dataProviderName;
446
- const dp = (q: { queryKey: readonly unknown[] }) =>
447
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpN;
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) => dp(q) && q.queryKey[1] === resName });
461
+ await queryClient.cancelQueries({ predicate: (q) => dataMatch(q.queryKey) });
450
462
 
451
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
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) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
467
+ if (targetId != null) queryClient.removeQueries({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) });
456
468
 
457
- queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
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) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
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) => queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey)
481
- && q.queryKey[0] === params.dataProviderName
482
- && q.queryKey[1] === resName
483
- && q.queryKey[2] === 'one'
484
- && q.queryKey[3] === targetId,
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
- (queryKey) => queryKeyMatchesTenant(queryKey, adminContext.tenantCacheKey),
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
  }
@@ -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: appendTenantCacheKey([
114
- opts.dataProviderName, resource, 'list', pagination, sorters, filters, meta,
115
- ], adminContext.tenantCacheKey),
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: appendTenantCacheKey(
214
- [opts.dataProviderName, resource, 'one', id, opts.meta],
215
- adminContext.tenantCacheKey,
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: appendTenantCacheKey(
334
- [dataProviderName, resource, 'many', ids, meta],
335
- adminContext.tenantCacheKey,
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 keys = () => {
2
- let _dataProviderName: string | undefined = undefined;
3
- let _resource = '';
4
- let _action = '';
5
- let _params: unknown = undefined;
6
-
7
- const builder = {
8
- data: (name: string | undefined) => { _dataProviderName = name; return builder; },
9
- resource: (name: string) => { _resource = name; return builder; },
10
- action: (name: string) => { _action = name; return builder; },
11
- params: (params: unknown) => { _params = params; return builder; },
12
- get: (): unknown[] => {
13
- const parts: unknown[] = [_dataProviderName, _resource];
14
- if (_action) parts.push(_action);
15
- if (_params !== undefined) parts.push(_params);
16
- return parts;
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
- return builder;
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
+ }
@@ -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[0] === 'taskList'
84
- && queryKeyMatchesTenant(query.queryKey, adminContext.tenantCacheKey),
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: appendTenantCacheKey(['task', options.taskId], adminContext.tenantCacheKey),
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: appendTenantCacheKey(
211
- ['taskList', options.dlq ? 'dlq' : 'default', options.params],
212
- adminContext.tenantCacheKey,
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: appendTenantCacheKey([
42
- 'useCan', opts.resource, opts.action, opts.params, opts.meta,
43
- ], adminContext.tenantCacheKey),
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 { queryKeyMatchesTenant } from './provider-bundle';
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 dpN = options.dataProviderName;
63
- const dp = (q: { queryKey: readonly unknown[] }) =>
64
- queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpN;
65
- queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
66
- queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && q.queryKey[2] === 'many' });
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