@svadmin/core 0.23.1 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +20 -3
  2. package/src/adapters/casbin.ts +1 -1
  3. package/src/adapters/casl.ts +1 -1
  4. package/src/audit.ts +12 -11
  5. package/src/auth-hooks.svelte.ts +25 -2
  6. package/src/chatProvider.svelte.ts +20 -3
  7. package/src/context.svelte.ts +17 -1
  8. package/src/data-transfer.svelte.ts +23 -11
  9. package/src/form-hooks.svelte.ts +71 -27
  10. package/src/form-hooks.test.svelte.ts +83 -0
  11. package/src/helpers-pure.ts +13 -6
  12. package/src/helpers.test.ts +2 -2
  13. package/src/helpers.ts +4 -0
  14. package/src/hook-utils.svelte.ts +40 -20
  15. package/src/hooks.svelte.ts +43 -50
  16. package/src/i18n.svelte.ts +3 -0
  17. package/src/index.ts +10 -8
  18. package/src/inferencer-openapi.ts +5 -3
  19. package/src/inferencer.ts +6 -4
  20. package/src/live-sse.ts +20 -17
  21. package/src/live-websocket.ts +1 -0
  22. package/src/live.svelte.ts +19 -11
  23. package/src/mutation-hooks.svelte.ts +39 -25
  24. package/src/mutation-hooks.test.svelte.ts +114 -0
  25. package/src/notification.svelte.ts +6 -6
  26. package/src/options.svelte.ts +1 -1
  27. package/src/{permissions.ts → permissions.svelte.ts} +1 -1
  28. package/src/permissions.test.ts +32 -80
  29. package/src/query-hooks.svelte.ts +18 -9
  30. package/src/query-hooks.test.svelte.ts +65 -0
  31. package/src/query-keys.ts +3 -4
  32. package/src/reactive-hooks.test.svelte.ts +26 -0
  33. package/src/router-provider.test.ts +3 -0
  34. package/src/router-provider.ts +13 -9
  35. package/src/router.ts +8 -0
  36. package/src/routing-hooks.svelte.ts +16 -13
  37. package/src/routing-hooks.test.svelte.ts +55 -0
  38. package/src/table-hooks.svelte.ts +46 -1
  39. package/src/table-hooks.test.svelte.ts +57 -0
  40. package/src/table-state.svelte.ts +17 -12
  41. package/src/theme.svelte.ts +25 -6
  42. package/src/toast.svelte.ts +8 -0
  43. package/src/unsaved-changes.svelte.ts +14 -6
  44. package/src/url-sync.ts +2 -1
  45. package/src/useCan.ts +2 -2
  46. package/src/useParsed.svelte.ts +12 -4
  47. package/src/utility-hooks.svelte.ts +5 -2
package/src/live-sse.ts CHANGED
@@ -12,13 +12,9 @@
12
12
  import type { LiveProvider, LiveEvent } from './live.svelte';
13
13
 
14
14
  export interface SSELiveProviderOptions {
15
- /** SSE endpoint URL, e.g. "https://api.example.com/events" */
16
15
  url: string;
17
- /** Optional EventSource init options (e.g. withCredentials) */
18
16
  eventSourceInit?: EventSourceInit;
19
- /** Optional callback when connection opens */
20
17
  onOpen?: () => void;
21
- /** Optional callback for connection errors */
22
18
  onError?: (event: Event) => void;
23
19
  }
24
20
 
@@ -29,7 +25,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
29
25
  const subscribers = new Map<string, Set<Callback>>();
30
26
  let eventSource: EventSource | null = null;
31
27
  let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
32
- const registeredListeners = new Set<string>();
28
+ const namedListeners = new Map<string, EventListener>();
33
29
 
34
30
  function connect() {
35
31
  if (eventSource) return;
@@ -43,20 +39,17 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
43
39
  options.onOpen?.();
44
40
  };
45
41
 
46
- // Listen for generic "message" events
47
42
  eventSource.onmessage = (msgEvent) => {
48
43
  dispatchEvent(msgEvent.data);
49
44
  };
50
45
 
51
46
  eventSource.onerror = (event) => {
52
- // EventSource auto-reconnects by default
53
47
  if (eventSource?.readyState === EventSource.CLOSED) {
54
48
  status = 'disconnected';
55
49
  }
56
50
  options.onError?.(event);
57
51
  };
58
52
 
59
- // Also listen for resource-specific named events
60
53
  for (const resource of subscribers.keys()) {
61
54
  if (resource !== '*') {
62
55
  addNamedListener(resource);
@@ -65,9 +58,8 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
65
58
  }
66
59
 
67
60
  function addNamedListener(resource: string) {
68
- if (!eventSource || registeredListeners.has(resource)) return;
69
- registeredListeners.add(resource);
70
- eventSource.addEventListener(resource, ((event: MessageEvent) => {
61
+ if (!eventSource || namedListeners.has(resource)) return;
62
+ const listener = ((event: MessageEvent) => {
71
63
  try {
72
64
  const parsed = JSON.parse(event.data);
73
65
  const liveEvent: LiveEvent = {
@@ -79,7 +71,18 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
79
71
  } catch {
80
72
  // ignore non-JSON
81
73
  }
82
- }) as EventListener);
74
+ }) as EventListener;
75
+ namedListeners.set(resource, listener);
76
+ eventSource.addEventListener(resource, listener);
77
+ }
78
+
79
+ function removeNamedListener(resource: string) {
80
+ const listener = namedListeners.get(resource);
81
+ if (!listener) return;
82
+ if (eventSource) {
83
+ eventSource.removeEventListener(resource, listener);
84
+ }
85
+ namedListeners.delete(resource);
83
86
  }
84
87
 
85
88
  function dispatchEvent(data: string) {
@@ -98,7 +101,6 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
98
101
  if (callbacks) {
99
102
  for (const cb of callbacks) cb(event);
100
103
  }
101
- // Wildcard subscribers
102
104
  const wildcardCallbacks = subscribers.get('*');
103
105
  if (wildcardCallbacks) {
104
106
  for (const cb of wildcardCallbacks) cb(event);
@@ -111,7 +113,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
111
113
  eventSource = null;
112
114
  }
113
115
  status = 'disconnected';
114
- registeredListeners.clear();
116
+ namedListeners.clear();
115
117
  }
116
118
 
117
119
  return {
@@ -123,11 +125,9 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
123
125
  }
124
126
  subscribers.get(resource)!.add(callback);
125
127
 
126
- // Connect if not already
127
128
  if (!eventSource && typeof EventSource !== 'undefined') {
128
129
  connect();
129
130
  }
130
- // Add named listener for this resource if connected
131
131
  if (eventSource && resource !== '*' && isNewResource) {
132
132
  addNamedListener(resource);
133
133
  }
@@ -136,7 +136,10 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
136
136
  const callbacks = subscribers.get(resource);
137
137
  if (callbacks) {
138
138
  callbacks.delete(callback);
139
- if (callbacks.size === 0) subscribers.delete(resource);
139
+ if (callbacks.size === 0) {
140
+ subscribers.delete(resource);
141
+ removeNamedListener(resource);
142
+ }
140
143
  }
141
144
  if (subscribers.size === 0) disconnect();
142
145
  };
@@ -102,6 +102,7 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
102
102
 
103
103
  function disconnect() {
104
104
  intentionalClose = true;
105
+ reconnectAttempts = 0;
105
106
  if (reconnectTimer) {
106
107
  clearTimeout(reconnectTimer);
107
108
  reconnectTimer = null;
@@ -31,7 +31,7 @@ export interface LiveProviderReconnectOptions {
31
31
  export function useLive(
32
32
  liveProvider: LiveProvider | (() => LiveProvider),
33
33
  resource: string | (() => string),
34
- options?: { liveMode?: LiveMode | (() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string, unknown> | (() => Record<string, unknown>) }
34
+ options?: { liveMode?: LiveMode | (() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string, unknown> | (() => Record<string, unknown>); dataProviderName?: string }
35
35
  ): void {
36
36
  const queryClient = useQueryClient();
37
37
 
@@ -42,16 +42,24 @@ export function useLive(
42
42
  const liveParams = typeof options?.liveParams === 'function' ? options.liveParams() : options?.liveParams;
43
43
  if (liveMode === 'off') return;
44
44
 
45
- const unsubscribe = lp.subscribe({
46
- resource: res,
47
- liveParams,
48
- callback: (event) => {
49
- options?.onLiveEvent?.(event);
50
- if (liveMode === 'auto') {
51
- queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res });
52
- }
53
- },
54
- });
45
+ let unsubscribe: (() => void) | undefined;
46
+ try {
47
+ unsubscribe = lp.subscribe({
48
+ resource: res,
49
+ liveParams,
50
+ callback: (event) => {
51
+ options?.onLiveEvent?.(event);
52
+ if (liveMode === 'auto') {
53
+ const dpName = options?.dataProviderName;
54
+ const dpMatch = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpName;
55
+ queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res });
56
+ }
57
+ },
58
+ });
59
+ } catch (e) {
60
+ console.warn('[svadmin] LiveProvider.subscribe failed:', e);
61
+ return;
62
+ }
55
63
  return unsubscribe;
56
64
  });
57
65
  }
@@ -11,7 +11,7 @@ import { toast } from './toast.svelte';
11
11
 
12
12
  // ─── Helper: publish live events after mutations ────────────────
13
13
 
14
- function publishLiveEvent(resource: string, type: 'created' | 'updated' | 'deleted', ids?: (string | number)[]) {
14
+ export function publishLiveEvent(resource: string, type: 'created' | 'updated' | 'deleted', ids?: (string | number)[]) {
15
15
  try {
16
16
  const liveProvider = getLiveProvider();
17
17
  if (liveProvider?.publish) {
@@ -36,21 +36,24 @@ export function invalidateByScopes(
36
36
  resource: string,
37
37
  scopes: string[] | false | undefined,
38
38
  defaults: string[],
39
- id?: string | number
39
+ id?: string | number,
40
+ dataProviderName?: string
40
41
  ): void {
41
42
  if (scopes === false) return;
42
43
  const effectiveScopes = (scopes && scopes.length > 0) ? scopes : defaults;
44
+ const dpMatch = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dataProviderName;
43
45
  for (const scope of effectiveScopes) {
44
- if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
45
- else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'many' });
46
- else if ((scope === 'detail' || scope === 'one') && id != null) queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === id });
47
- else if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource });
46
+ 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') });
47
+ else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'many' });
48
+ 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 });
49
+ else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource && q.queryKey[2] === 'one' });
50
+ else if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === resource });
48
51
  }
49
52
  }
50
53
 
51
54
  export function deepMerge(target: any, source: any): any {
52
55
  if (!target || typeof target !== 'object') return source;
53
- if (!source || typeof source !== 'object') return source;
56
+ if (!source || typeof source !== 'object') return target;
54
57
  if (Array.isArray(source) || source instanceof Date) return source;
55
58
  const result = { ...target };
56
59
  for (const key in source) {
@@ -127,14 +130,14 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
127
130
  audit({ action: 'create', resource: resName, recordId: String(newId) });
128
131
  publishLiveEvent(resName, 'created', newId != null ? [newId as string | number] : undefined);
129
132
  // Invalidation in onSuccess for create (refine pattern — no optimistic data to reconcile on error)
130
- invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many']);
133
+ invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many'], undefined, params.dataProviderName);
131
134
  if (typeof options.mutationOptions?.onSuccess === 'function') {
132
135
  (options.mutationOptions.onSuccess as Function)(data, params, context);
133
136
  }
134
137
  },
135
138
  onError: (error, params, context) => {
136
139
  checkError(error);
137
- fireErrorNotification(params.errorNotification, 'Create failed', error);
140
+ fireErrorNotification(params.errorNotification, 'Create failed', error, params.resource ?? defaultResource);
138
141
  if (typeof options.mutationOptions?.onError === 'function') {
139
142
  (options.mutationOptions.onError as Function)(error, params, context);
140
143
  }
@@ -218,10 +221,12 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
218
221
  if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
219
222
  const resName = params.resource ?? defaultResource;
220
223
  const targetId = params.id ?? defaultId;
224
+ const dpN = params.dataProviderName;
225
+ const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
221
226
 
222
- await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resName });
227
+ await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
223
228
 
224
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resName });
229
+ const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
225
230
 
226
231
  const pk = getResource(resName).primaryKey ?? 'id';
227
232
  const updater = params.optimisticUpdateMap ?? { list: true, many: true, detail: true };
@@ -229,14 +234,14 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
229
234
  if (updater.detail !== false) {
230
235
  const detailFn = updater.detail;
231
236
  if (typeof detailFn === 'function') {
232
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId }, (old: unknown) => detailFn(old, params.variables, targetId));
237
+ 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));
233
238
  } else {
234
- queryClient.setQueriesData({ predicate: (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);
239
+ 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);
235
240
  }
236
241
  }
237
242
 
238
243
  if (updater.list !== false) {
239
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
244
+ queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
240
245
  if (typeof updater.list === 'function') return updater.list!(old, params.variables, targetId);
241
246
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
242
247
  const o = old as { data: Record<string, unknown>[] };
@@ -245,7 +250,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
245
250
  }
246
251
 
247
252
  if (updater.many !== false) {
248
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
253
+ queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
249
254
  if (typeof updater.many === 'function') return updater.many!(old, params.variables, targetId);
250
255
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
251
256
  const o = old as { data: Record<string, unknown>[] };
@@ -270,7 +275,10 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
270
275
  if (error instanceof UndoError) return;
271
276
  const resName = params.resource ?? defaultResource;
272
277
  const targetId = params.id ?? defaultId;
273
- invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many', 'detail'], targetId != null ? targetId : undefined);
278
+ invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many', 'detail'], targetId != null ? targetId : undefined, params.dataProviderName);
279
+ if (typeof options.mutationOptions?.onSettled === 'function') {
280
+ (options.mutationOptions.onSettled as Function)(_data, error, params, undefined);
281
+ }
274
282
  },
275
283
  onError: (error, params, context: unknown) => {
276
284
  const ctx = context as MutationContext | undefined;
@@ -282,7 +290,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
282
290
  if (error instanceof UndoError) return;
283
291
  checkError(error);
284
292
  const extractedCtx = ctx?._svadmin_ctx ? ctx.userContext : context;
285
- fireErrorNotification(params.errorNotification, 'Update failed', error);
293
+ fireErrorNotification(params.errorNotification, 'Update failed', error, params.resource ?? defaultResource);
286
294
  if (typeof options.mutationOptions?.onError === 'function') {
287
295
  (options.mutationOptions.onError as Function)(error, params, extractedCtx);
288
296
  }
@@ -361,23 +369,25 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
361
369
  if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
362
370
  const resName = params.resource ?? defaultResource;
363
371
  const targetId = params.id ?? defaultId;
372
+ const dpN = params.dataProviderName;
373
+ const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
364
374
 
365
- await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resName });
375
+ await queryClient.cancelQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
366
376
 
367
- const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resName });
377
+ const previousQueries = queryClient.getQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName });
368
378
 
369
379
  const pk = getResource(resName).primaryKey ?? 'id';
370
380
 
371
- if (targetId != null) queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
381
+ if (targetId != null) queryClient.removeQueries({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
372
382
 
373
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
383
+ queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
374
384
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
375
385
  const o = old as { data: Record<string, unknown>[]; total?: number };
376
386
  const filtered = o.data.filter((item) => String(item[pk]) !== String(targetId));
377
387
  return { ...o, data: filtered, total: (o.total ?? o.data.length) - (o.data.length - filtered.length) };
378
388
  });
379
389
 
380
- queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
390
+ queryClient.setQueriesData({ predicate: (q) => dp(q) && q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
381
391
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
382
392
  const o = old as { data: Record<string, unknown>[] };
383
393
  return { ...o, data: o.data.filter((item) => String(item[pk]) !== String(targetId)) };
@@ -392,7 +402,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
392
402
 
393
403
  // Remove detail cache entry (refine pattern — no stale show page)
394
404
  if (targetId != null) {
395
- queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
405
+ queryClient.removeQueries({ predicate: (q) => q.queryKey[0] === params.dataProviderName && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
396
406
  }
397
407
 
398
408
  fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
@@ -405,7 +415,11 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
405
415
  onSettled: (_data, error, params) => {
406
416
  if (error instanceof UndoError) return;
407
417
  const resName = params.resource ?? defaultResource;
408
- invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many']);
418
+ const targetId = params.id ?? defaultId;
419
+ invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many'], targetId != null ? targetId : undefined, params.dataProviderName);
420
+ if (typeof options.mutationOptions?.onSettled === 'function') {
421
+ (options.mutationOptions.onSettled as Function)(_data, error, params, undefined);
422
+ }
409
423
  },
410
424
  onError: (error, params, context: unknown) => {
411
425
  const ctx = context as MutationContext | undefined;
@@ -417,7 +431,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
417
431
  if (error instanceof UndoError) return;
418
432
  checkError(error);
419
433
  const extractedCtx = ctx?._svadmin_ctx ? ctx.userContext : context;
420
- fireErrorNotification(params.errorNotification, 'Delete failed', error);
434
+ fireErrorNotification(params.errorNotification, 'Delete failed', error, params.resource ?? defaultResource);
421
435
  if (typeof options.mutationOptions?.onError === 'function') {
422
436
  (options.mutationOptions.onError as Function)(error, params, extractedCtx);
423
437
  }
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { useCreate, useUpdate, invalidateByScopes } from './mutation-hooks.svelte';
3
+ import { flushSync } from 'svelte';
4
+ import { QueryClient } from '@tanstack/svelte-query';
5
+
6
+ vi.mock('./context.svelte', () => ({
7
+ useDataProvider: () => ({
8
+ getOne: vi.fn(),
9
+ getList: vi.fn(),
10
+ create: vi.fn().mockResolvedValue({ data: { id: 1 } }),
11
+ update: vi.fn().mockResolvedValue({ data: { id: 1 } })
12
+ }),
13
+ useResource: () => ({ name: 'posts' }),
14
+ getResource: (name: string) => ({ name, primaryKey: 'id' }),
15
+ useRouterContext: () => ({ navigate: vi.fn() }),
16
+ useTranslate: () => (key: string) => key,
17
+ useNotification: () => ({ open: vi.fn(), close: vi.fn() }),
18
+ getLiveProvider: () => undefined
19
+ }));
20
+
21
+ vi.mock('@tanstack/svelte-query', async (importOriginal) => {
22
+ const actual = await importOriginal();
23
+ return {
24
+ ...actual as any,
25
+ useQueryClient: () => new QueryClient(),
26
+ createMutation: (opts: any) => ({
27
+ mutate: vi.fn((vars, callbacks) => {
28
+ if (opts.mutationFn) opts.mutationFn(vars);
29
+ if (callbacks?.onSuccess) callbacks.onSuccess({ data: {} });
30
+ }),
31
+ isPending: false
32
+ })
33
+ };
34
+ });
35
+
36
+ describe('useCreate & useUpdate - Headless Svelte 5 Compatibility', () => {
37
+
38
+ it('safely binds useCreate and allows mutations over proxies', () => {
39
+ let createMutation: ReturnType<typeof useCreate>;
40
+
41
+ const cleanup = $effect.root(() => {
42
+ createMutation = useCreate();
43
+ });
44
+
45
+ flushSync();
46
+
47
+ expect(createMutation!.mutation.mutate).toBeDefined();
48
+ expect(createMutation!.mutation.isPending).toBe(false);
49
+
50
+ cleanup();
51
+ });
52
+
53
+ it('safely binds useUpdate and evaluates data updates', () => {
54
+ let updateMutation: ReturnType<typeof useUpdate>;
55
+
56
+ const cleanup = $effect.root(() => {
57
+ updateMutation = useUpdate();
58
+ });
59
+
60
+ flushSync();
61
+
62
+ expect(updateMutation!.mutation.mutate).toBeDefined();
63
+ expect(updateMutation!.mutation.isPending).toBe(false);
64
+
65
+ cleanup();
66
+ });
67
+ });
68
+ describe('mutation-hooks', () => {
69
+
70
+ describe('invalidateByScopes - Multi-Provider Cache Isolation', () => {
71
+ it('filters perfectly by dataProviderName when passed', () => {
72
+ const queryClient = new QueryClient();
73
+ queryClient.invalidateQueries = vi.fn(async () => {});
74
+
75
+ const resource = 'posts';
76
+ const providerName = 'secondaryProvider';
77
+
78
+ // Act
79
+ invalidateByScopes(queryClient, resource, ['list'], ['list'], undefined, providerName);
80
+
81
+ expect(queryClient.invalidateQueries).toHaveBeenCalled();
82
+
83
+ // Assert predicate captures DataProvider scopes
84
+ const calls = (queryClient.invalidateQueries as ReturnType<typeof vi.fn>).mock.calls;
85
+
86
+ // Using arbitrary mock objects simulating Query keys
87
+ const predicateFn = calls[0][0].predicate;
88
+
89
+ const shouldPass = predicateFn({ queryKey: ['secondaryProvider', 'posts', 'list'] });
90
+ const shouldFail = predicateFn({ queryKey: ['default', 'posts', 'list'] });
91
+ const shouldFailResource = predicateFn({ queryKey: ['secondaryProvider', 'users', 'list'] });
92
+
93
+ expect(shouldPass).toBe(true);
94
+ expect(shouldFail).toBe(false);
95
+ expect(shouldFailResource).toBe(false);
96
+ });
97
+
98
+ it('falls back to all providers when NO provider is specified', () => {
99
+ const queryClient = new QueryClient();
100
+ queryClient.invalidateQueries = vi.fn(async () => {});
101
+
102
+ // Act without providerName
103
+ invalidateByScopes(queryClient, 'posts', ['list'], ['list']);
104
+
105
+ const predicateFn = (queryClient.invalidateQueries as ReturnType<typeof vi.fn>).mock.calls[0][0].predicate;
106
+
107
+ const pass1 = predicateFn({ queryKey: ['default', 'posts', 'list'] });
108
+ const pass2 = predicateFn({ queryKey: ['custom', 'posts', 'list'] });
109
+ expect(pass1).toBe(true);
110
+ expect(pass2).toBe(true);
111
+ });
112
+ });
113
+
114
+ });
@@ -26,13 +26,13 @@ export function notify(params: {
26
26
  if (notificationProvider) {
27
27
  notificationProvider.open(params);
28
28
  } else {
29
- // Fallback to built-in toast
30
- const { type, message } = params;
29
+ const { type, message, description } = params;
30
+ const fullMessage = description ? `${message}: ${description}` : message;
31
31
  switch (type) {
32
- case 'success': toast.success(message, undefined, { key: params.key }); break;
33
- case 'error': toast.error(message, undefined, { key: params.key }); break;
34
- case 'warning': toast.warning(message, undefined, { key: params.key }); break;
35
- case 'info': toast.info(message, undefined, { key: params.key }); break;
32
+ case 'success': toast.success(fullMessage, undefined, { key: params.key }); break;
33
+ case 'error': toast.error(fullMessage, undefined, { key: params.key }); break;
34
+ case 'warning': toast.warning(fullMessage, undefined, { key: params.key }); break;
35
+ case 'info': toast.info(fullMessage, undefined, { key: params.key }); break;
36
36
  }
37
37
  }
38
38
  }
@@ -62,7 +62,7 @@ function createDefaultOptions(): AdminOptions {
62
62
  liveMode: 'auto',
63
63
  disableServerSideValidation: false,
64
64
  undoableTimeout: 5000,
65
- textTransformers: defaultTextTransformers,
65
+ textTransformers: { ...defaultTextTransformers },
66
66
  redirect: { afterCreate: 'list', afterEdit: 'list', afterClone: 'list' },
67
67
  breadcrumb: 'default',
68
68
  disableRouteChangeHandler: false,
@@ -51,7 +51,7 @@ export interface AccessControlProvider {
51
51
 
52
52
  // ─── State ────────────────────────────────────────────────────
53
53
 
54
- let provider: AccessControlProvider | null = null;
54
+ let provider: AccessControlProvider | null = $state(null);
55
55
 
56
56
  // ─── API ──────────────────────────────────────────────────────
57
57