@svadmin/core 0.22.0 → 0.23.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.
@@ -27,6 +27,7 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
27
27
 
28
28
  type Callback = (event: LiveEvent) => void;
29
29
  const subscribers = new Map<string, Set<Callback>>();
30
+ const lastParams = new Map<string, Record<string, unknown> | undefined>();
30
31
  let ws: WebSocket | null = null;
31
32
  let reconnectAttempts = 0;
32
33
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -51,6 +52,14 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
51
52
  status = 'connected';
52
53
  reconnectAttempts = 0;
53
54
  options.onOpen?.();
55
+
56
+ // Resend subscriptions upon reconnects
57
+ for (const [res, callbacks] of subscribers.entries()) {
58
+ if (callbacks.size > 0) {
59
+ const liveParams = lastParams.get(res);
60
+ ws?.send(JSON.stringify({ type: 'SUBSCRIBE', resource: res, liveParams }));
61
+ }
62
+ }
54
63
  };
55
64
 
56
65
  ws.onmessage = (msgEvent) => {
@@ -107,22 +116,31 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
107
116
 
108
117
 
109
118
  return {
110
- subscribe({ resource, callback }) {
119
+ subscribe({ resource, liveParams, callback }) {
111
120
  if (!subscribers.has(resource)) {
112
121
  subscribers.set(resource, new Set());
113
122
  }
114
123
  subscribers.get(resource)!.add(callback);
124
+ lastParams.set(resource, liveParams);
115
125
 
116
126
  // Ensure connection is open
117
127
  if (status === 'disconnected' && typeof WebSocket !== 'undefined') {
118
128
  connect();
129
+ } else if (status === 'connected' && ws && ws.readyState === WebSocket.OPEN) {
130
+ // If already connected, dispatch subscription payload dynamically
131
+ ws.send(JSON.stringify({ type: 'SUBSCRIBE', resource, liveParams }));
119
132
  }
120
133
 
121
134
  return () => {
122
135
  const callbacks = subscribers.get(resource);
123
136
  if (callbacks) {
124
137
  callbacks.delete(callback);
125
- if (callbacks.size === 0) subscribers.delete(resource);
138
+ if (callbacks.size === 0) {
139
+ subscribers.delete(resource);
140
+ if (status === 'connected' && ws && ws.readyState === WebSocket.OPEN) {
141
+ ws.send(JSON.stringify({ type: 'UNSUBSCRIBE', resource }));
142
+ }
143
+ }
126
144
  }
127
145
  // Auto-disconnect if no subscribers left
128
146
  if (subscribers.size === 0) disconnect();
@@ -16,27 +16,39 @@ export interface LiveProvider {
16
16
  subscribe(params: { resource: string; liveParams?: Record<string, unknown>; callback: (event: LiveEvent) => void }): () => void;
17
17
  unsubscribe?(params: { resource: string; liveParams?: Record<string, unknown> }): void;
18
18
  publish?(event: LiveEvent): void;
19
+ onConnected?: () => void;
20
+ onDisconnected?: () => void;
21
+ }
22
+
23
+ export interface LiveProviderReconnectOptions {
24
+ enabled?: boolean;
25
+ retryCount?: number;
26
+ retryInterval?: number;
19
27
  }
20
28
 
21
29
  // ─── useLive — auto-invalidate queries on real-time events ──────
22
30
 
23
31
  export function useLive(
24
- liveProvider: LiveProvider,
25
- resource: string,
26
- options?: { liveMode?: LiveMode; onLiveEvent?: (event: LiveEvent) => void }
32
+ liveProvider: LiveProvider | (() => LiveProvider),
33
+ resource: string | (() => string),
34
+ options?: { liveMode?: LiveMode | (() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string, unknown> | (() => Record<string, unknown>) }
27
35
  ): void {
28
36
  const queryClient = useQueryClient();
29
- const liveMode = options?.liveMode ?? 'auto';
30
-
31
- if (liveMode === 'off') return;
32
37
 
33
38
  $effect(() => {
34
- const unsubscribe = liveProvider.subscribe({
35
- resource,
39
+ const lp = typeof liveProvider === 'function' ? liveProvider() : liveProvider;
40
+ const res = typeof resource === 'function' ? resource() : resource;
41
+ const liveMode = typeof options?.liveMode === 'function' ? options.liveMode() : (options?.liveMode ?? 'auto');
42
+ const liveParams = typeof options?.liveParams === 'function' ? options.liveParams() : options?.liveParams;
43
+ if (liveMode === 'off') return;
44
+
45
+ const unsubscribe = lp.subscribe({
46
+ resource: res,
47
+ liveParams,
36
48
  callback: (event) => {
37
49
  options?.onLiveEvent?.(event);
38
50
  if (liveMode === 'auto') {
39
- queryClient.invalidateQueries({ queryKey: [resource] });
51
+ queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res });
40
52
  }
41
53
  },
42
54
  });
@@ -47,21 +59,25 @@ export function useLive(
47
59
  // ─── useSubscription — manual channel subscription ──────────────
48
60
 
49
61
  interface UseSubscriptionOptions {
50
- resource: string;
51
- liveProvider: LiveProvider;
62
+ resource: string | (() => string);
63
+ liveProvider: LiveProvider | (() => LiveProvider);
52
64
  onLiveEvent: (event: LiveEvent) => void;
53
- enabled?: boolean;
65
+ enabled?: boolean | (() => boolean);
66
+ liveParams?: Record<string, unknown> | (() => Record<string, unknown>);
54
67
  }
55
68
 
56
69
  export function useSubscription(options: UseSubscriptionOptions): void {
57
- const { resource, liveProvider, onLiveEvent, enabled = true } = options;
58
-
59
- if (!enabled) return;
60
-
61
70
  $effect(() => {
62
- const unsubscribe = liveProvider.subscribe({
63
- resource,
64
- callback: onLiveEvent,
71
+ const res = typeof options.resource === 'function' ? options.resource() : options.resource;
72
+ const lp = typeof options.liveProvider === 'function' ? options.liveProvider() : options.liveProvider;
73
+ const enabled = typeof options.enabled === 'function' ? options.enabled() : (options.enabled ?? true);
74
+ const liveParams = typeof options.liveParams === 'function' ? options.liveParams() : options.liveParams;
75
+ if (!enabled) return;
76
+
77
+ const unsubscribe = lp.subscribe({
78
+ resource: res,
79
+ liveParams,
80
+ callback: options.onLiveEvent,
65
81
  });
66
82
  return unsubscribe;
67
83
  });
@@ -1,19 +1,86 @@
1
1
  import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
2
  import { getAdminOptions } from './options.svelte';
3
- import { getDataProviderForResource, getResource } from './context.svelte';
3
+ import { getDataProviderForResource, getResource, getLiveProvider } from './context.svelte';
4
4
  import { useParsed } from './useParsed.svelte';
5
5
  import { audit } from './audit';
6
6
  import { UndoError } from './types';
7
7
  import type { BaseRecord, HttpError, CreateParams, UpdateParams, DeleteParams, KnownResources } from './types';
8
- import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
8
+ import { checkError, createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
9
9
  import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
10
10
  import { toast } from './toast.svelte';
11
11
 
12
+ // ─── Helper: publish live events after mutations ────────────────
13
+
14
+ function publishLiveEvent(resource: string, type: 'created' | 'updated' | 'deleted', ids?: (string | number)[]) {
15
+ try {
16
+ const liveProvider = getLiveProvider();
17
+ if (liveProvider?.publish) {
18
+ liveProvider.publish({
19
+ type: type === 'created' ? 'INSERT' : type === 'updated' ? 'UPDATE' : 'DELETE',
20
+ resource,
21
+ payload: { ids },
22
+ });
23
+ }
24
+ } catch { /* LiveProvider not configured — skip silently */ }
25
+ }
26
+
27
+ /**
28
+ * Invalidate queries by scope. Respects per-mutation `invalidates` override.
29
+ * Default invalidation scopes per operation:
30
+ * - create: ['list', 'many']
31
+ * - update: ['list', 'many', 'detail']
32
+ * - delete: ['list', 'many']
33
+ */
34
+ export function invalidateByScopes(
35
+ queryClient: ReturnType<typeof useQueryClient>,
36
+ resource: string,
37
+ scopes: string[] | false | undefined,
38
+ defaults: string[],
39
+ id?: string | number
40
+ ): void {
41
+ if (scopes === false) return;
42
+ const effectiveScopes = (scopes && scopes.length > 0) ? scopes : defaults;
43
+ 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 });
48
+ }
49
+ }
50
+
51
+ export function deepMerge(target: any, source: any): any {
52
+ if (!target || typeof target !== 'object') return source;
53
+ if (!source || typeof source !== 'object') return source;
54
+ if (Array.isArray(source) || source instanceof Date) return source;
55
+ const result = { ...target };
56
+ for (const key in source) {
57
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && !(source[key] instanceof Date)) {
58
+ result[key] = deepMerge(result[key] || {}, source[key]);
59
+ } else {
60
+ result[key] = source[key];
61
+ }
62
+ }
63
+ return result;
64
+ }
65
+
12
66
  // ─── useCreate ─────────────────────────────────────────────────
13
67
 
68
+ interface MutationContext {
69
+ _svadmin_ctx?: boolean;
70
+ userContext?: unknown;
71
+ previousQueries?: [unknown, unknown][];
72
+ }
73
+
74
+ interface MutationCallbacks {
75
+ onMutate?: (variables: unknown) => unknown | Promise<unknown>;
76
+ onSuccess?: (data: unknown, variables: unknown, context: unknown) => void;
77
+ onError?: (error: unknown, variables: unknown, context: unknown) => void;
78
+ onSettled?: (data: unknown | undefined, error: unknown | null, variables: unknown, context: unknown) => void;
79
+ }
80
+
14
81
  export interface UseCreateOptions {
15
82
  resource?: KnownResources;
16
- mutationOptions?: Record<string, unknown>;
83
+ mutationOptions?: MutationCallbacks;
17
84
  overtimeOptions?: OvertimeOptions;
18
85
  }
19
86
 
@@ -33,8 +100,7 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
33
100
  const adminOptions = getAdminOptions();
34
101
  const queryClient = useQueryClient();
35
102
 
36
- let isMutating = $state(false);
37
- const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
103
+ const overtime = createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime);
38
104
 
39
105
  const mutation = createMutation<
40
106
  { data: TData },
@@ -43,34 +109,31 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
43
109
  >(() => ({
44
110
  ...options.mutationOptions,
45
111
  mutationFn: async (params) => {
46
- isMutating = true;
47
112
  const resName = params.resource ?? defaultResource;
48
113
  const provider = getDataProviderForResource(resName, params.dataProviderName);
49
- try {
50
- return await provider.create<TData, TVariables>({
114
+ return await provider.create<TData, TVariables>({
51
115
  resource: resName,
52
116
  variables: params.variables,
53
117
  meta: params.meta,
54
118
  });
55
- } finally {
56
- isMutating = false;
57
- }
58
119
  },
59
- onMutate: (options.mutationOptions as any)?.onMutate,
120
+ onMutate: options.mutationOptions?.onMutate,
60
121
  onSuccess: (data, params, context) => {
61
122
  const resName = params.resource ?? defaultResource;
62
- if (params.invalidates !== false) {
63
- queryClient.invalidateQueries({ queryKey: [resName] });
64
- }
65
123
  fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
66
124
  const res = getResource(resName);
67
125
  const pk = res.primaryKey ?? 'id';
68
- audit({ action: 'create', resource: resName, recordId: String((data.data as Record<string, unknown>)[pk]) });
126
+ const newId = (data.data as Record<string, unknown>)[pk];
127
+ audit({ action: 'create', resource: resName, recordId: String(newId) });
128
+ publishLiveEvent(resName, 'created', newId != null ? [newId as string | number] : undefined);
129
+ // Invalidation in onSuccess for create (refine pattern — no optimistic data to reconcile on error)
130
+ invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many']);
69
131
  if (typeof options.mutationOptions?.onSuccess === 'function') {
70
132
  (options.mutationOptions.onSuccess as Function)(data, params, context);
71
133
  }
72
134
  },
73
135
  onError: (error, params, context) => {
136
+ checkError(error);
74
137
  fireErrorNotification(params.errorNotification, 'Create failed', error);
75
138
  if (typeof options.mutationOptions?.onError === 'function') {
76
139
  (options.mutationOptions.onError as Function)(error, params, context);
@@ -88,7 +151,7 @@ export interface UseUpdateOptions {
88
151
  id?: string | number;
89
152
  mutationMode?: 'pessimistic' | 'optimistic' | 'undoable';
90
153
  undoableTimeout?: number;
91
- mutationOptions?: Record<string, unknown>;
154
+ mutationOptions?: MutationCallbacks;
92
155
  overtimeOptions?: OvertimeOptions;
93
156
  }
94
157
 
@@ -103,8 +166,9 @@ export interface UseUpdateMutateParams<TVariables> {
103
166
  dataProviderName?: string;
104
167
  onCancel?: () => void;
105
168
  optimisticUpdateMap?: {
106
- detail?: (previous: unknown, variables: TVariables, id?: string | number) => unknown;
107
- list?: (previous: unknown, variables: TVariables, id?: string | number) => unknown;
169
+ detail?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
170
+ list?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
171
+ many?: boolean | ((previous: unknown, variables: TVariables, id?: string | number) => unknown);
108
172
  };
109
173
  }
110
174
 
@@ -117,8 +181,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
117
181
  const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
118
182
  const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
119
183
 
120
- let isMutating = $state(false);
121
- const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
184
+ const overtime = createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime);
122
185
 
123
186
  const mutation = createMutation<
124
187
  { data: TData },
@@ -127,7 +190,6 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
127
190
  >(() => ({
128
191
  ...options.mutationOptions,
129
192
  mutationFn: async (params) => {
130
- isMutating = true;
131
193
  const resName = params.resource ?? defaultResource;
132
194
  const targetId = params.id ?? defaultId;
133
195
  if (targetId == null) throw new Error('useUpdate requires an id');
@@ -142,73 +204,84 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
142
204
  }
143
205
 
144
206
  const provider = getDataProviderForResource(resName, params.dataProviderName);
145
- try {
146
- return await provider.update<TData, TVariables>({
207
+ return await provider.update<TData, TVariables>({
147
208
  resource: resName,
148
209
  id: targetId,
149
210
  variables: params.variables,
150
211
  meta: params.meta,
151
212
  });
152
- } finally {
153
- isMutating = false;
154
- }
155
213
  },
156
214
  onMutate: async (params) => {
157
- const userOnMutate = (options.mutationOptions as any)?.onMutate;
215
+ const userOnMutate = options.mutationOptions?.onMutate;
158
216
  const userContext = typeof userOnMutate === 'function' ? await userOnMutate(params) : undefined;
159
217
 
160
218
  if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
161
219
  const resName = params.resource ?? defaultResource;
162
220
  const targetId = params.id ?? defaultId;
163
221
 
164
- await queryClient.cancelQueries({ queryKey: [resName] });
165
- const previousDetail = queryClient.getQueryData([resName, 'one', targetId]);
166
- const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
222
+ await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resName });
223
+
224
+ const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resName });
167
225
 
168
226
  const pk = getResource(resName).primaryKey ?? 'id';
169
- const updater = params.optimisticUpdateMap;
227
+ const updater = params.optimisticUpdateMap ?? { list: true, many: true, detail: true };
170
228
 
171
- if (updater?.detail) {
172
- queryClient.setQueryData([resName, 'one', targetId], (old: unknown) => updater.detail!(old, params.variables, targetId));
173
- } else {
174
- queryClient.setQueryData([resName, 'one', targetId], (old: Record<string, unknown> | undefined) => old ? { ...old, ...params.variables } : old);
229
+ if (updater.detail !== false) {
230
+ const detailFn = updater.detail;
231
+ 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));
233
+ } 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);
235
+ }
175
236
  }
176
237
 
177
- if (updater?.list) {
178
- queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => updater.list!(old, params.variables, targetId));
179
- } else {
180
- queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
238
+ if (updater.list !== false) {
239
+ queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
240
+ if (typeof updater.list === 'function') return updater.list!(old, params.variables, targetId);
181
241
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
182
242
  const o = old as { data: Record<string, unknown>[] };
183
- return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? { ...item, ...params.variables } : item) };
243
+ return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, params.variables) : item) };
184
244
  });
185
245
  }
186
246
 
187
- return { _svadmin_ctx: true, userContext, previousDetail, previousList };
247
+ if (updater.many !== false) {
248
+ queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
249
+ if (typeof updater.many === 'function') return updater.many!(old, params.variables, targetId);
250
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
251
+ const o = old as { data: Record<string, unknown>[] };
252
+ return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? deepMerge(item, params.variables) : item) };
253
+ });
254
+ }
255
+
256
+ return { _svadmin_ctx: true, userContext, previousQueries };
188
257
  },
189
258
  onSuccess: (data, params, context) => {
190
- const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
259
+ const extractedCtx = (context as MutationContext)?._svadmin_ctx ? (context as MutationContext).userContext : context;
191
260
  const resName = params.resource ?? defaultResource;
192
261
  const targetId = params.id ?? defaultId;
193
- if (params.invalidates !== false) {
194
- queryClient.invalidateQueries({ queryKey: [resName] });
195
- }
196
262
  fireSuccessNotification(params.successNotification, 'Updated successfully', data.data, params.variables, resName);
197
263
  audit({ action: 'update', resource: resName, recordId: String(targetId) });
264
+ publishLiveEvent(resName, 'updated', targetId != null ? [targetId] : undefined);
198
265
  if (typeof options.mutationOptions?.onSuccess === 'function') {
199
266
  (options.mutationOptions.onSuccess as Function)(data, params, extractedCtx);
200
267
  }
201
268
  },
202
- onError: (error, params, context: unknown) => {
203
- const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
269
+ onSettled: (_data, error, params) => {
270
+ if (error instanceof UndoError) return;
204
271
  const resName = params.resource ?? defaultResource;
205
272
  const targetId = params.id ?? defaultId;
206
- const ctx = context as { previousDetail?: unknown; previousList?: [unknown, unknown][] } | undefined;
207
- if (ctx?.previousDetail) queryClient.setQueryData([resName, 'one', targetId], ctx.previousDetail);
208
- if (ctx?.previousList) {
209
- ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
273
+ invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many', 'detail'], targetId != null ? targetId : undefined);
274
+ },
275
+ onError: (error, params, context: unknown) => {
276
+ const ctx = context as MutationContext | undefined;
277
+ if (ctx?.previousQueries) {
278
+ for (const [queryKey, data] of ctx.previousQueries) {
279
+ queryClient.setQueryData(queryKey as string[], data);
280
+ }
210
281
  }
211
282
  if (error instanceof UndoError) return;
283
+ checkError(error);
284
+ const extractedCtx = ctx?._svadmin_ctx ? ctx.userContext : context;
212
285
  fireErrorNotification(params.errorNotification, 'Update failed', error);
213
286
  if (typeof options.mutationOptions?.onError === 'function') {
214
287
  (options.mutationOptions.onError as Function)(error, params, extractedCtx);
@@ -226,7 +299,7 @@ export interface UseDeleteOptions {
226
299
  id?: string | number;
227
300
  mutationMode?: 'pessimistic' | 'optimistic' | 'undoable';
228
301
  undoableTimeout?: number;
229
- mutationOptions?: Record<string, unknown>;
302
+ mutationOptions?: MutationCallbacks;
230
303
  overtimeOptions?: OvertimeOptions;
231
304
  }
232
305
 
@@ -251,8 +324,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
251
324
  const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
252
325
  const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
253
326
 
254
- let isMutating = $state(false);
255
- const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
327
+ const overtime = createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime);
256
328
 
257
329
  const mutation = createMutation<
258
330
  { data: TData },
@@ -261,7 +333,6 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
261
333
  >(() => ({
262
334
  ...options.mutationOptions,
263
335
  mutationFn: async (params) => {
264
- isMutating = true;
265
336
  const resName = params.resource ?? defaultResource;
266
337
  const targetId = params.id ?? defaultId;
267
338
  if (targetId == null) throw new Error('useDelete requires an id');
@@ -276,58 +347,76 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
276
347
  }
277
348
 
278
349
  const provider = getDataProviderForResource(resName, params.dataProviderName);
279
- try {
280
- return await provider.deleteOne<TData, TVariables>({
350
+ return await provider.deleteOne<TData, TVariables>({
281
351
  resource: resName,
282
352
  id: targetId,
283
353
  variables: params.variables,
284
354
  meta: params.meta,
285
355
  });
286
- } finally {
287
- isMutating = false;
288
- }
289
356
  },
290
357
  onMutate: async (params) => {
291
- const userOnMutate = (options.mutationOptions as any)?.onMutate;
358
+ const userOnMutate = options.mutationOptions?.onMutate;
292
359
  const userContext = typeof userOnMutate === 'function' ? await userOnMutate(params) : undefined;
293
360
 
294
361
  if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
295
362
  const resName = params.resource ?? defaultResource;
296
363
  const targetId = params.id ?? defaultId;
297
364
 
298
- await queryClient.cancelQueries({ queryKey: [resName] });
299
- const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
365
+ await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resName });
366
+
367
+ const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resName });
300
368
 
301
369
  const pk = getResource(resName).primaryKey ?? 'id';
302
370
 
303
- queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
371
+ if (targetId != null) queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
372
+
373
+ queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'list' }, (old: unknown) => {
374
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
375
+ const o = old as { data: Record<string, unknown>[]; total?: number };
376
+ const filtered = o.data.filter((item) => String(item[pk]) !== String(targetId));
377
+ return { ...o, data: filtered, total: (o.total ?? o.data.length) - (o.data.length - filtered.length) };
378
+ });
379
+
380
+ queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'many' }, (old: unknown) => {
304
381
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
305
382
  const o = old as { data: Record<string, unknown>[] };
306
383
  return { ...o, data: o.data.filter((item) => String(item[pk]) !== String(targetId)) };
307
384
  });
308
385
 
309
- return { _svadmin_ctx: true, userContext, previousList };
386
+ return { _svadmin_ctx: true, userContext, previousQueries };
310
387
  },
311
388
  onSuccess: (data, params, context) => {
312
- const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
389
+ const extractedCtx = (context as MutationContext)?._svadmin_ctx ? (context as MutationContext).userContext : context;
313
390
  const resName = params.resource ?? defaultResource;
314
391
  const targetId = params.id ?? defaultId;
315
- if (params.invalidates !== false) {
316
- queryClient.invalidateQueries({ queryKey: [resName] });
392
+
393
+ // Remove detail cache entry (refine pattern — no stale show page)
394
+ if (targetId != null) {
395
+ queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === targetId });
317
396
  }
397
+
318
398
  fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
319
399
  audit({ action: 'delete', resource: resName, recordId: String(targetId) });
400
+ publishLiveEvent(resName, 'deleted', targetId != null ? [targetId] : undefined);
320
401
  if (typeof options.mutationOptions?.onSuccess === 'function') {
321
402
  (options.mutationOptions.onSuccess as Function)(data, params, extractedCtx);
322
403
  }
323
404
  },
405
+ onSettled: (_data, error, params) => {
406
+ if (error instanceof UndoError) return;
407
+ const resName = params.resource ?? defaultResource;
408
+ invalidateByScopes(queryClient, resName, params.invalidates, ['list', 'many']);
409
+ },
324
410
  onError: (error, params, context: unknown) => {
325
- const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
326
- const ctx = context as { previousList?: [unknown, unknown][] } | undefined;
327
- if (ctx?.previousList) {
328
- ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
411
+ const ctx = context as MutationContext | undefined;
412
+ if (ctx?.previousQueries) {
413
+ for (const [queryKey, data] of ctx.previousQueries) {
414
+ queryClient.setQueryData(queryKey as string[], data);
415
+ }
329
416
  }
330
417
  if (error instanceof UndoError) return;
418
+ checkError(error);
419
+ const extractedCtx = ctx?._svadmin_ctx ? ctx.userContext : context;
331
420
  fireErrorNotification(params.errorNotification, 'Delete failed', error);
332
421
  if (typeof options.mutationOptions?.onError === 'function') {
333
422
  (options.mutationOptions.onError as Function)(error, params, extractedCtx);
@@ -4,7 +4,7 @@
4
4
  import { toast } from './toast.svelte';
5
5
  import type { NotificationProvider } from './types';
6
6
 
7
- let notificationProvider: NotificationProvider | null = null;
7
+ let notificationProvider = $state<NotificationProvider | null>(null);
8
8
 
9
9
  export function setNotificationProvider(provider: NotificationProvider): void {
10
10
  notificationProvider = provider;
@@ -29,10 +29,10 @@ export function notify(params: {
29
29
  // Fallback to built-in toast
30
30
  const { type, message } = params;
31
31
  switch (type) {
32
- case 'success': toast.success(message); break;
33
- case 'error': toast.error(message); break;
34
- case 'warning': toast.warning(message); break;
35
- case 'info': toast.info(message); break;
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;
36
36
  }
37
37
  }
38
38
  }
@@ -43,3 +43,7 @@ export function notify(params: {
43
43
  export function closeNotification(key: string): void {
44
44
  notificationProvider?.close(key);
45
45
  }
46
+
47
+ export function resetNotificationProvider(): void {
48
+ notificationProvider = null;
49
+ }