@svadmin/core 0.22.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
package/src/audit.ts CHANGED
@@ -45,6 +45,11 @@ export function getAuditLogProvider(): AuditLogProvider | null {
45
45
  return auditLogProvider;
46
46
  }
47
47
 
48
+ export function resetAuditLogProvider(): void {
49
+ auditLogProvider = null;
50
+ handler = (entry) => { console.info('[audit]', entry.action, entry.resource, entry.recordId); };
51
+ }
52
+
48
53
  export function audit(entry: Omit<AuditEntry, 'timestamp'>): void {
49
54
  const fullEntry: AuditEntry = { ...entry, timestamp: new Date().toISOString() };
50
55
  try {
@@ -13,7 +13,7 @@ import type { AuthActionResult, CheckResult, Identity, AuthProvider } from './ty
13
13
  interface CreateAuthMutationOptions {
14
14
  method: keyof AuthProvider;
15
15
  successMessage?: string | null;
16
- errorMessage?: string;
16
+ errorMessage?: string | false;
17
17
  onSuccess?: (result: AuthActionResult) => void;
18
18
  }
19
19
 
@@ -33,13 +33,13 @@ function createAuthMutation(options: CreateAuthMutationOptions) {
33
33
  if (options.successMessage) notify({ type: 'success', message: options.successMessage });
34
34
  if (options.onSuccess) options.onSuccess(result);
35
35
  } else {
36
- const msg = result.error?.message ?? options.errorMessage ?? t('common.operationFailed');
37
- notify({ type: 'error', message: msg });
36
+ const msg = result.error?.message ?? (typeof options.errorMessage === 'string' ? options.errorMessage : t('common.operationFailed'));
37
+ if (options.errorMessage !== false) notify({ type: 'error', message: msg });
38
38
  }
39
39
  return result;
40
40
  } catch (err) {
41
- const msg = err instanceof Error ? err.message : (options.errorMessage ?? t('common.operationFailed'));
42
- notify({ type: 'error', message: msg });
41
+ const msg = err instanceof Error ? err.message : (typeof options.errorMessage === 'string' ? options.errorMessage : t('common.operationFailed'));
42
+ if (options.errorMessage !== false) notify({ type: 'error', message: msg });
43
43
  return { success: false, error: { message: msg } };
44
44
  } finally {
45
45
  isLoading = false;
@@ -54,11 +54,11 @@ function createAuthMutation(options: CreateAuthMutationOptions) {
54
54
 
55
55
  // ─── useLogin ─────────────────────────────────────────────────
56
56
 
57
- export function useLogin() {
57
+ export function useLogin(opts?: { errorMessage?: string | false }) {
58
58
  return createAuthMutation({
59
59
  method: 'login',
60
60
  successMessage: t('common.operationSuccess'),
61
- errorMessage: t('common.loginFailed'),
61
+ errorMessage: opts?.errorMessage ?? t('common.loginFailed'),
62
62
  onSuccess: (result) => { if (result.redirectTo) navigate(result.redirectTo); }
63
63
  });
64
64
  }
@@ -105,7 +105,7 @@ export function useUpdatePassword() {
105
105
  // ─── useGetIdentity ──────────────────────────────────────────
106
106
 
107
107
  export function useGetIdentity() {
108
- const provider = getAuthProvider();
108
+ const provider = getAuthProvider({ optional: true });
109
109
  let data = $state<Identity | null>(null);
110
110
  let isLoading = $state(true);
111
111
  let error = $state<Error | null>(null);
@@ -138,33 +138,43 @@ export function useGetIdentity() {
138
138
  // ─── useIsAuthenticated ──────────────────────────────────────
139
139
 
140
140
  export function useIsAuthenticated() {
141
- const provider = getAuthProvider();
141
+ const provider = getAuthProvider({ optional: true });
142
142
  let isAuthenticated = $state(false);
143
143
  let isLoading = $state(true);
144
+ let checkResult = $state<CheckResult | null>(null);
144
145
 
145
- if (provider) {
146
- if (typeof window !== 'undefined') {
147
- provider.check().then((result: CheckResult) => {
148
- isAuthenticated = result.authenticated;
149
- isLoading = false;
150
- }).catch(() => {
151
- isAuthenticated = false;
152
- isLoading = false;
153
- });
154
- } else {
146
+ function check() {
147
+ if (!provider) {
148
+ isAuthenticated = true;
149
+ isLoading = false;
150
+ return;
151
+ }
152
+ if (typeof window === 'undefined') {
155
153
  // In SSR we assume false to prevent hydration mismatch before check
156
154
  isAuthenticated = false;
157
155
  isLoading = false;
156
+ return;
158
157
  }
159
- } else {
160
- // No auth provider — treat as authenticated
161
- isAuthenticated = true;
162
- isLoading = false;
158
+ isLoading = true;
159
+ provider.check().then((result: CheckResult) => {
160
+ isAuthenticated = result.authenticated;
161
+ checkResult = result;
162
+ isLoading = false;
163
+ }).catch(() => {
164
+ isAuthenticated = false;
165
+ checkResult = { authenticated: false };
166
+ isLoading = false;
167
+ });
163
168
  }
164
169
 
170
+ // Initial check
171
+ check();
172
+
165
173
  return {
166
174
  get isAuthenticated() { return isAuthenticated; },
167
175
  get isLoading() { return isLoading; },
176
+ get data() { return checkResult; },
177
+ refetch: check,
168
178
  };
169
179
  }
170
180
 
@@ -176,7 +186,7 @@ export function useIsAuthenticated() {
176
186
  * If it returns { redirectTo }, navigates there.
177
187
  */
178
188
  export function useOnError() {
179
- const provider = getAuthProvider();
189
+ const provider = getAuthProvider({ optional: true });
180
190
 
181
191
  async function mutate(error: unknown) {
182
192
  if (!provider?.onError) {
@@ -186,36 +186,23 @@ export function getAgentProvider(): AgentProvider | null {
186
186
  interface ApprovalCallback {
187
187
  (approved: boolean): void;
188
188
  }
189
- const pendingApprovals = new Map<string, ApprovalCallback>();
189
+ interface ApprovalEntry { id: string; callback: ApprovalCallback }
190
+ let pendingApprovals = $state<ApprovalEntry[]>([]);
190
191
 
191
- /**
192
- * Register a pending approval request. Called internally by the framework
193
- * when an `approval_request` event is received from the agent.
194
- */
195
192
  export function registerApproval(id: string, callback: ApprovalCallback): void {
196
- pendingApprovals.set(id, callback);
193
+ pendingApprovals = [...pendingApprovals, { id, callback }];
197
194
  }
198
195
 
199
- /**
200
- * Approve or reject a pending tool call.
201
- *
202
- * @example
203
- * ```svelte
204
- * <button onclick={() => resolveApproval('req-1', true)}>Approve</button>
205
- * <button onclick={() => resolveApproval('req-1', false)}>Reject</button>
206
- * ```
207
- */
208
196
  export function resolveApproval(id: string, approved: boolean): boolean {
209
- const cb = pendingApprovals.get(id);
210
- if (!cb) return false;
211
- cb(approved);
212
- pendingApprovals.delete(id);
197
+ const entry = pendingApprovals.find(e => e.id === id);
198
+ if (!entry) return false;
199
+ entry.callback(approved);
200
+ pendingApprovals = pendingApprovals.filter(e => e.id !== id);
213
201
  return true;
214
202
  }
215
203
 
216
- /** Check if there are any pending approval requests. */
217
204
  export function hasPendingApprovals(): boolean {
218
- return pendingApprovals.size > 0;
205
+ return pendingApprovals.length > 0;
219
206
  }
220
207
 
221
208
  // ─── Chat Context singleton ────────────────────────────────────
@@ -3,6 +3,11 @@
3
3
 
4
4
  import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
5
5
  import type { RouterProvider } from './router-provider';
6
+ import { resetNotificationProvider } from './notification.svelte';
7
+ import { resetAuditLogProvider } from './audit';
8
+ import { resetAccessControlProvider } from './permissions';
9
+ import { resetAdminOptions } from './options.svelte';
10
+ import { resetI18n } from './i18n.svelte';
6
11
 
7
12
  // ─── DataProvider (supports single or multiple) ─────────────────
8
13
 
@@ -122,3 +127,18 @@ export function setLiveProvider(provider: import('./live.svelte').LiveProvider):
122
127
  export function getLiveProvider(): import('./live.svelte').LiveProvider | undefined {
123
128
  return liveProviderState;
124
129
  }
130
+
131
+ // ─── Reset — for testing / HMR ─────────────────────────────────
132
+
133
+ export function resetContext(): void {
134
+ providers = null;
135
+ authProvider = null;
136
+ resources = [];
137
+ routerProvider = undefined;
138
+ liveProviderState = undefined;
139
+ resetNotificationProvider();
140
+ resetAuditLogProvider();
141
+ resetAccessControlProvider();
142
+ resetAdminOptions();
143
+ resetI18n();
144
+ }
@@ -57,14 +57,13 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
57
57
 
58
58
  if (options.download !== false && typeof document !== 'undefined') {
59
59
  const fields = Object.keys(mapped[0]);
60
- const header = fields.join(',');
60
+ const escapeCsvField = (s: string) => s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r') ? `"${s.replace(/"/g, '""')}"` : s;
61
+ const header = fields.map(escapeCsvField).join(',');
61
62
  const rows = mapped.map(record =>
62
63
  fields.map(f => {
63
64
  const val = record[f];
64
- const str = val === null || val === undefined ? '' : String(val);
65
- return str.includes(',') || str.includes('"') || str.includes('\n')
66
- ? `"${str.replace(/"/g, '""')}"`
67
- : str;
65
+ const str = val === null || val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
66
+ return escapeCsvField(str);
68
67
  }).join(',')
69
68
  );
70
69
 
@@ -200,6 +199,7 @@ function parseCSV(text: string): string[][] {
200
199
  let currentRow: string[] = [];
201
200
  let currentVal = '';
202
201
  let inQuotes = false;
202
+ let wasQuoted = false;
203
203
 
204
204
  for (let i = 0; i < text.length; i++) {
205
205
  const ch = text[i];
@@ -207,7 +207,7 @@ function parseCSV(text: string): string[][] {
207
207
  if (ch === '"') {
208
208
  if (i + 1 < text.length && text[i + 1] === '"') {
209
209
  currentVal += '"';
210
- i++; // skip escaped quote
210
+ i++;
211
211
  } else {
212
212
  inQuotes = false;
213
213
  }
@@ -217,18 +217,21 @@ function parseCSV(text: string): string[][] {
217
217
  } else {
218
218
  if (ch === '"') {
219
219
  inQuotes = true;
220
+ wasQuoted = true;
220
221
  } else if (ch === ',') {
221
- currentRow.push(currentVal.trim());
222
+ currentRow.push(wasQuoted ? currentVal : currentVal.trim());
222
223
  currentVal = '';
224
+ wasQuoted = false;
223
225
  } else if (ch === '\n' || ch === '\r') {
224
- currentRow.push(currentVal.trim());
226
+ currentRow.push(wasQuoted ? currentVal : currentVal.trim());
225
227
  if (currentRow.some(v => v !== '')) {
226
228
  rows.push(currentRow);
227
229
  }
228
230
  currentRow = [];
229
231
  currentVal = '';
232
+ wasQuoted = false;
230
233
  if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
231
- i++; // consume \n of \r\n
234
+ i++;
232
235
  }
233
236
  } else {
234
237
  currentVal += ch;
@@ -237,7 +240,7 @@ function parseCSV(text: string): string[][] {
237
240
  }
238
241
 
239
242
  if (currentVal || currentRow.length > 0) {
240
- currentRow.push(currentVal.trim());
243
+ currentRow.push(wasQuoted ? currentVal : currentVal.trim());
241
244
  if (currentRow.some(v => v !== '')) {
242
245
  rows.push(currentRow);
243
246
  }
@@ -1,14 +1,17 @@
1
1
  import { useQueryClient } from '@tanstack/svelte-query';
2
2
  import { useParsed } from './useParsed.svelte';
3
3
  import { getAdminOptions } from './options.svelte';
4
- import { getDataProviderForResource, getResource } from './context.svelte';
4
+ import { getDataProviderForResource, getResource, getLiveProvider } from './context.svelte';
5
5
  import { createQuery, createMutation } from '@tanstack/svelte-query';
6
6
  import { notify } from './notification.svelte';
7
7
  import { t } from './i18n.svelte';
8
8
  import { audit } from './audit';
9
9
  import { navigate, currentPath } from './router';
10
- import { HttpError } from './types';
10
+ import { HttpError, UndoError } from './types';
11
11
  import type { BaseRecord, MutationMode, KnownResources } from './types';
12
+ import { checkError } from './hook-utils.svelte';
13
+ import { toast } from './toast.svelte';
14
+ import { deepMerge, invalidateByScopes } from './mutation-hooks.svelte';
12
15
 
13
16
  // ─── Types ───────────────────────────────────────────────────────────
14
17
 
@@ -18,7 +21,7 @@ export interface UseFormOptions<
18
21
  TError = HttpError,
19
22
  > {
20
23
  resource?: KnownResources;
21
- action?: 'create' | 'edit' | 'clone';
24
+ action?: 'create' | 'edit' | 'clone' | 'show';
22
25
  id?: string | number;
23
26
  redirect?: 'list' | 'edit' | 'show' | false;
24
27
 
@@ -41,7 +44,7 @@ export interface UseFormOptions<
41
44
  /** Called every time a field value changes. */
42
45
  onChange?: (event: { field: string; value: unknown; values: TVariables }) => void;
43
46
  /** Called before mutation. Return false or call cancel() to prevent submission. */
44
- onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone'; cancel: () => void }) => void | boolean;
47
+ onSubmit?: (ctx: { values: TVariables; action: 'create' | 'edit' | 'clone' | 'show'; cancel: () => void }) => void | boolean;
45
48
 
46
49
  // ─── Meta ─────────────────────────────────────────────────────
47
50
  meta?: Record<string, unknown>;
@@ -95,6 +98,7 @@ export interface UseFormReturn<
95
98
  // ─── Tainted (dirty) ──────────────────────────────────────────
96
99
  readonly tainted: Record<string, boolean>;
97
100
  isTainted: (field?: string) => boolean;
101
+ untaint: () => void;
98
102
 
99
103
  // ─── Errors ───────────────────────────────────────────────────
100
104
  readonly errors: Record<string, string>;
@@ -113,12 +117,12 @@ export interface UseFormReturn<
113
117
  // ─── State ──────────────────────────────────────────────────────
114
118
  readonly loading: boolean;
115
119
  readonly submitting: boolean;
116
- readonly action: 'create' | 'edit' | 'clone';
120
+ readonly action: 'create' | 'edit' | 'clone' | 'show';
117
121
  readonly resource: string;
118
122
  readonly id: string | number | undefined;
119
123
  readonly isDirty: boolean;
120
124
  setId: (id: string | number | undefined) => void;
121
- setAction: (action: 'create' | 'edit' | 'clone') => void;
125
+ setAction: (action: 'create' | 'edit' | 'clone' | 'show') => void;
122
126
  mutationMode: MutationMode;
123
127
  redirect: (to: 'list' | 'edit' | 'show' | false) => void;
124
128
 
@@ -143,7 +147,7 @@ export function useForm<
143
147
  const adminOptions = getAdminOptions();
144
148
 
145
149
  const resource = $derived(options.resource ?? parsed.resource ?? '');
146
- let action = $state<'create' | 'edit' | 'clone'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create');
150
+ let action = $state<'create' | 'edit' | 'clone' | 'show'>(options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone' | 'show') ?? 'create');
147
151
  let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
148
152
 
149
153
  const defaultRedirectOpt = $derived(action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate);
@@ -166,12 +170,11 @@ export function useForm<
166
170
  const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
167
171
 
168
172
  const provider = $derived(getDataProviderForResource(resource, dataProviderName));
169
- const parsedMeta = useParsed().params || {};
170
- const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
171
- const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
173
+ const queryMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookQueryMeta });
174
+ const mutationMeta = $derived({ ...useParsed().params, ...hookMeta, ...hookMutationMeta });
172
175
 
173
176
  function setId(newId: string | number | undefined) { currentId = newId; }
174
- function setAction(newAction: 'create' | 'edit' | 'clone') { action = newAction; }
177
+ function setAction(newAction: 'create' | 'edit' | 'clone' | 'show') { action = newAction; }
175
178
 
176
179
  // ─── Form values (single source of truth) ───────────────────────
177
180
  let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
@@ -243,10 +246,13 @@ export function useForm<
243
246
  values = { ...initialValues } as TVariables;
244
247
  tainted = {};
245
248
  clearErrors();
249
+ queryInitializedId = undefined;
246
250
  }
247
251
 
248
252
  // ─── Server error handling ──────────────────────────────────────
253
+
249
254
  function handleHttpError(error: Error) {
255
+ checkError(error);
250
256
  if (error instanceof HttpError && error.errors && !disableServerSideValidation) {
251
257
  for (const [field, messages] of Object.entries(error.errors)) {
252
258
  const msg = Array.isArray(messages) ? messages[0] : messages;
@@ -262,12 +268,12 @@ export function useForm<
262
268
  // Always create the query — use `enabled` to conditionally activate.
263
269
  // This ensures setAction('edit') at runtime will trigger a fetch.
264
270
  const query = createQuery(() => ({
265
- queryKey: [resource, 'one', currentId],
271
+ queryKey: [options.dataProviderName, resource, 'one', currentId, queryMeta],
266
272
  queryFn: async () => {
267
273
  const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
268
- return result.data;
274
+ return result;
269
275
  },
270
- enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone') && currentId != null,
276
+ enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone' || action === 'show') && currentId != null,
271
277
  staleTime: queryOptions?.staleTime,
272
278
  }));
273
279
 
@@ -276,21 +282,23 @@ export function useForm<
276
282
  {
277
283
  $effect.pre(() => {
278
284
  // Discard previous fetch lock and default when switched strictly back to create
279
- if (action === 'create' && currentId === undefined && queryInitializedId !== undefined) {
285
+ if (action === 'create' && queryInitializedId !== undefined) {
280
286
  queryInitializedId = undefined;
281
287
  const def = (options.defaultValues ?? {}) as TVariables;
282
288
  values = { ...def };
283
289
  initialValues = { ...def };
290
+ tainted = {};
291
+ clearErrors();
284
292
  return;
285
293
  }
286
294
  if (queryInitializedId === currentId) return;
287
- const data = query.data as Record<string, unknown> | undefined;
295
+ const record = (query.data as { data: Record<string, unknown> } | undefined)?.data;
288
296
  const pk = getResource(resource).primaryKey ?? 'id';
289
- if (data && (currentId == null || String(data[pk]) === String(currentId) || !data[pk])) {
290
- // In clone mode, strip out identifiers
297
+ if (record && String(record[pk]) === String(currentId)) {
298
+ const cloneStripKeys = new Set(['id', '_id', pk, 'createdAt', 'updatedAt', 'created_at', 'updated_at', 'createdBy', 'updated_by', 'created_by', 'updatedBy']);
291
299
  const targetData = action === 'clone'
292
- ? Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id' && k !== '_id' && k !== pk))
293
- : data;
300
+ ? Object.fromEntries(Object.entries(record).filter(([k]) => !cloneStripKeys.has(k)))
301
+ : record;
294
302
  // Merge: defaultValues < query data
295
303
  const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
296
304
  values = merged;
@@ -307,13 +315,18 @@ export function useForm<
307
315
  ...options.createMutationOptions,
308
316
  mutationFn: (variables: TVariables) => provider.create<TData, TVariables>({ resource, variables, meta: mutationMeta }),
309
317
  onSuccess: (data: { data: TData }) => {
310
- if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
318
+ invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many']);
311
319
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
312
320
  const pk = getResource(resource).primaryKey ?? 'id';
313
- audit({ action: 'create', resource, recordId: String((data.data as Record<string, unknown>)[pk]) });
321
+ const newId = (data.data as Record<string, unknown>)[pk];
322
+ audit({ action: 'create', resource, recordId: String(newId) });
323
+ // Publish live event
324
+ try {
325
+ const lp = getLiveProvider();
326
+ if (lp?.publish) lp.publish({ type: 'INSERT', resource, payload: { ids: newId != null ? [newId as string | number] : [] } });
327
+ } catch { /* no live provider */ }
314
328
  onMutationSuccess?.(data);
315
329
  if (redirectOverride !== false) {
316
- const newId = (data.data as Record<string, unknown>)[pk];
317
330
  if (newId != null) currentId = newId as string | number;
318
331
  doRedirect(redirectOverride ?? redirectDefault);
319
332
  }
@@ -321,17 +334,60 @@ export function useForm<
321
334
  onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
322
335
  }));
323
336
 
324
- const updateMut = createMutation(() => ({
337
+ const updateMut = createMutation<{ data: TData }, unknown, TVariables>(() => ({
325
338
  ...options.updateMutationOptions,
326
- mutationFn: (variables: TVariables) => provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta }),
327
- onSuccess: (data: { data: TData }) => {
328
- if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
339
+ mutationFn: async (variables: TVariables) => {
340
+ if (mutationMode === 'undoable') {
341
+ await new Promise<void>((resolve, reject) => {
342
+ toast.undoable(t('common.actionCanBeUndone'), undoableTimeout, () => {
343
+ reject(new UndoError());
344
+ }, resolve);
345
+ });
346
+ }
347
+ return provider.update<TData, TVariables>({ resource, id: currentId!, variables, meta: mutationMeta });
348
+ },
349
+ onMutate: async (variables: TVariables) => {
350
+ if (mutationMode === 'pessimistic') return;
351
+ await queryClient.cancelQueries({ predicate: (q) => q.queryKey[1] === resource });
352
+ const previousQueries = queryClient.getQueriesData({ predicate: (q) => q.queryKey[1] === resource });
353
+ if (optimisticUpdateMap?.list !== false) {
354
+ queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'list' }, (old: unknown) => {
355
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
356
+ const o = old as { data: Record<string, unknown>[] };
357
+ const pk = getResource(resource).primaryKey ?? 'id';
358
+ return { ...o, data: o.data.map((item) => String(item[pk]) === String(currentId) ? deepMerge(item, variables) : item) };
359
+ });
360
+ }
361
+ if (optimisticUpdateMap?.detail !== false && currentId != null) {
362
+ queryClient.setQueriesData({ predicate: (q) => q.queryKey[1] === resource && q.queryKey[2] === 'one' && q.queryKey[3] === currentId }, (old: Record<string, unknown> | undefined) => old ? { ...old, data: deepMerge((old as Record<string, unknown>).data || {}, variables) } : old);
363
+ }
364
+ return { previousQueries };
365
+ },
366
+ onSuccess: (data: unknown) => {
329
367
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.updateSuccess') });
330
368
  audit({ action: 'update', resource, recordId: String(currentId) });
369
+ try {
370
+ const lp = getLiveProvider();
371
+ if (lp?.publish) lp.publish({ type: 'UPDATE', resource, payload: { ids: currentId != null ? [currentId] : [] } });
372
+ } catch { /* no live provider */ }
331
373
  onMutationSuccess?.(data);
332
374
  if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
333
375
  },
334
- onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
376
+ onSettled: (_data: unknown, error: unknown) => {
377
+ if (error instanceof UndoError) return;
378
+ invalidateByScopes(queryClient, resource, invalidateScopes, ['list', 'many', 'detail'], currentId ?? undefined);
379
+ },
380
+ onError: (error: unknown, _vars: unknown, context: unknown) => {
381
+ const ctx = context as { previousQueries?: [unknown, unknown][] } | undefined;
382
+ if (ctx?.previousQueries) {
383
+ for (const [queryKey, data] of ctx.previousQueries) {
384
+ queryClient.setQueryData(queryKey as string[], data);
385
+ }
386
+ }
387
+ if (error instanceof UndoError) return;
388
+ handleHttpError(error as Error);
389
+ onMutationError?.(error as Error);
390
+ },
335
391
  }));
336
392
 
337
393
  function doRedirect(to: 'list' | 'edit' | 'show' | false) {
@@ -347,7 +403,7 @@ export function useForm<
347
403
 
348
404
  // ─── Submit ─────────────────────────────────────────────────────
349
405
  async function submit(overrides?: { redirect?: 'list' | 'edit' | 'show' | false }) {
350
- // onSubmit pre-hook
406
+ if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
351
407
  if (onSubmitFn) {
352
408
  let cancelled = false;
353
409
  const result = onSubmitFn({ values, action, cancel: () => { cancelled = true; } });
@@ -359,11 +415,9 @@ export function useForm<
359
415
  try {
360
416
  if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
361
417
  else await updateMut.mutateAsync(values);
362
- // Untaint on success
363
418
  tainted = {};
364
- } catch (error) {
365
- // Errors are already handled by the mutation's onError callback.
366
- // We catch here to prevent unhandled promise rejections.
419
+ } catch {
420
+ // Errors already handled by mutation's onError callback
367
421
  }
368
422
  }
369
423
 
@@ -373,36 +427,41 @@ export function useForm<
373
427
  let lastAutoSaveData = $state<unknown>(null);
374
428
  let lastAutoSaveError = $state<unknown>(null);
375
429
 
430
+ $effect(() => {
431
+ return () => {
432
+ if (autoSaveTimer) clearTimeout(autoSaveTimer);
433
+ };
434
+ });
435
+
376
436
  function triggerAutoSave() {
377
- const currentAction = action; // read $state reactively at call time
378
- if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone') return;
437
+ const currentAction = action;
438
+ if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone' || currentId == null) return;
439
+ if (createMut.isPending || updateMut.isPending) return;
379
440
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
380
441
 
442
+ const safeId = currentId;
381
443
  autoSaveTimer = setTimeout(async () => {
382
444
  const finalValues = autoSaveOpts.onFinish ? autoSaveOpts.onFinish(values) : values;
383
445
  autoSaveStatus = 'saving';
384
446
  try {
385
- await provider.update<TData, TVariables>({ resource, id: currentId!, variables: finalValues, meta: mutationMeta });
447
+ await provider.update<TData, TVariables>({ resource, id: safeId as string | number, variables: finalValues, meta: mutationMeta });
386
448
  const scopes = autoSaveOpts.invalidates ?? ['resourceAll'];
387
- for (const scope of scopes) {
388
- if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [resource] });
389
- else if (scope === 'detail' && currentId) queryClient.invalidateQueries({ queryKey: [resource, 'one', currentId] });
390
- else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [resource, 'list'] });
391
- }
449
+ invalidateByScopes(queryClient, resource, scopes, ['resourceAll'], safeId ?? undefined);
392
450
  autoSaveStatus = 'saved';
393
- lastAutoSaveData = undefined;
451
+ lastAutoSaveData = finalValues;
394
452
  lastAutoSaveError = null;
395
453
  setTimeout(() => { autoSaveStatus = 'idle'; }, 2000);
396
454
  } catch (e) {
397
455
  autoSaveStatus = 'error';
398
456
  lastAutoSaveError = e;
457
+ checkError(e);
399
458
  }
400
459
  }, autoSaveOpts.debounce ?? 1000);
401
460
  }
402
461
 
403
462
  if (autoSaveOpts?.invalidateOnUnmount) {
404
463
  $effect(() => {
405
- return () => queryClient.invalidateQueries({ queryKey: [resource] });
464
+ return () => queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resource });
406
465
  });
407
466
  }
408
467
 
@@ -430,6 +489,7 @@ export function useForm<
430
489
  // Tainted
431
490
  get tainted() { return tainted; },
432
491
  isTainted,
492
+ untaint: () => { tainted = {}; },
433
493
 
434
494
  // Errors
435
495
  get errors() { return errors; },