@svadmin/core 0.23.1 → 0.23.4

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 +11 -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 +15 -5
  47. package/src/utility-hooks.svelte.ts +5 -2
@@ -83,7 +83,10 @@ export function unionSorters(
83
83
  const result = [...newSorters];
84
84
 
85
85
  for (const perm of permanentSorters) {
86
- if (!result.some((s) => s.field === perm.field)) {
86
+ const idx = result.findIndex((s) => s.field === perm.field);
87
+ if (idx >= 0) {
88
+ result[idx] = perm;
89
+ } else {
87
90
  result.push(perm);
88
91
  }
89
92
  }
@@ -113,6 +116,7 @@ export function parseCSV(text: string): string[][] {
113
116
  let currentRow: string[] = [];
114
117
  let currentVal = '';
115
118
  let inQuotes = false;
119
+ let wasQuoted = false;
116
120
 
117
121
  for (let i = 0; i < text.length; i++) {
118
122
  const ch = text[i];
@@ -120,7 +124,7 @@ export function parseCSV(text: string): string[][] {
120
124
  if (ch === '"') {
121
125
  if (i + 1 < text.length && text[i + 1] === '"') {
122
126
  currentVal += '"';
123
- i++; // skip escaped quote
127
+ i++;
124
128
  } else {
125
129
  inQuotes = false;
126
130
  }
@@ -130,18 +134,21 @@ export function parseCSV(text: string): string[][] {
130
134
  } else {
131
135
  if (ch === '"') {
132
136
  inQuotes = true;
137
+ wasQuoted = true;
133
138
  } else if (ch === ',') {
134
- currentRow.push(currentVal.trim());
139
+ currentRow.push(wasQuoted ? currentVal : currentVal.trim());
135
140
  currentVal = '';
141
+ wasQuoted = false;
136
142
  } else if (ch === '\n' || ch === '\r') {
137
- currentRow.push(currentVal.trim());
143
+ currentRow.push(wasQuoted ? currentVal : currentVal.trim());
138
144
  if (currentRow.some(v => v !== '')) {
139
145
  rows.push(currentRow);
140
146
  }
141
147
  currentRow = [];
142
148
  currentVal = '';
149
+ wasQuoted = false;
143
150
  if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') {
144
- i++; // consume \n of \r\n
151
+ i++;
145
152
  }
146
153
  } else {
147
154
  currentVal += ch;
@@ -150,7 +157,7 @@ export function parseCSV(text: string): string[][] {
150
157
  }
151
158
 
152
159
  if (currentVal || currentRow.length > 0) {
153
- currentRow.push(currentVal.trim());
160
+ currentRow.push(wasQuoted ? currentVal : currentVal.trim());
154
161
  if (currentRow.some(v => v !== '')) {
155
162
  rows.push(currentRow);
156
163
  }
@@ -123,12 +123,12 @@ describe('unionSorters', () => {
123
123
  expect(result[0].field).toBe('name');
124
124
  });
125
125
 
126
- test('permanent sorters are not duplicated', () => {
126
+ test('permanent sorters override new sorters on same field', () => {
127
127
  const permanent: Sort[] = [{ field: 'name', order: 'asc' }];
128
128
  const newSorters: Sort[] = [{ field: 'name', order: 'desc' }];
129
129
  const result = unionSorters(permanent, newSorters);
130
130
  expect(result).toHaveLength(1);
131
- expect(result[0].order).toBe('desc');
131
+ expect(result[0].order).toBe('asc');
132
132
  });
133
133
 
134
134
  test('empty arrays', () => {
package/src/helpers.ts CHANGED
@@ -125,6 +125,10 @@ export function deriveValidator(
125
125
  case 'number': {
126
126
  if (typeof value === 'string' && isNaN(Number(value))) {
127
127
  errors[field.key] = t('validation.invalidNumber');
128
+ } else if (typeof value === 'number' && isNaN(value)) {
129
+ errors[field.key] = t('validation.invalidNumber');
130
+ } else if (typeof value !== 'string' && typeof value !== 'number') {
131
+ errors[field.key] = t('validation.invalidNumber');
128
132
  }
129
133
  break;
130
134
  }
@@ -12,15 +12,25 @@ import { notify } from './notification.svelte';
12
12
  export async function checkError(error: unknown): Promise<void> {
13
13
  try {
14
14
  const authProvider = getAuthProvider({ optional: true });
15
- if (!authProvider?.onError) return;
16
- const result = await authProvider.onError(error);
17
- if (result.logout) {
18
- await authProvider.logout?.();
19
- const { navigate } = await import('./router');
20
- navigate(result.redirectTo ?? '/login');
21
- } else if (result.redirectTo) {
22
- const { navigate } = await import('./router');
23
- navigate(result.redirectTo);
15
+ if (authProvider?.onError) {
16
+ const result = await authProvider.onError(error);
17
+ if (!result) return;
18
+ if (result.logout) {
19
+ try { await authProvider.logout?.(); } catch { /* logout failure should not block redirect */ }
20
+ const { navigate } = await import('./router');
21
+ navigate(result.redirectTo ?? '/login');
22
+ } else if (result.redirectTo) {
23
+ const { navigate } = await import('./router');
24
+ navigate(result.redirectTo);
25
+ }
26
+ return;
27
+ }
28
+ if (error && typeof error === 'object' && 'statusCode' in error) {
29
+ const status = (error as { statusCode: number }).statusCode;
30
+ if (status === 401 || status === 403) {
31
+ const { navigate } = await import('./router');
32
+ navigate('/login');
33
+ }
24
34
  }
25
35
  } catch { /* auth check failed silently */ }
26
36
  }
@@ -72,6 +82,7 @@ export interface LiveSubscriptionParams {
72
82
  onLiveEvent?: (event: LiveEvent) => void;
73
83
  liveParams?: Record<string, unknown>;
74
84
  enabled?: boolean;
85
+ dataProviderName?: string;
75
86
  }
76
87
 
77
88
  export function createLiveSubscription(paramsFn: () => LiveSubscriptionParams): void {
@@ -85,16 +96,24 @@ export function createLiveSubscription(paramsFn: () => LiveSubscriptionParams):
85
96
 
86
97
  if (!liveProvider || liveMode === 'off' || !enabled) return;
87
98
 
88
- const unsubscribe = liveProvider.subscribe({
89
- resource: params.resource,
90
- liveParams: params.liveParams,
91
- callback: (event: LiveEvent) => {
92
- params.onLiveEvent?.(event);
93
- if (liveMode === 'auto') {
94
- queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === params.resource });
95
- }
96
- },
97
- });
99
+ let unsubscribe: (() => void) | undefined;
100
+ try {
101
+ unsubscribe = liveProvider.subscribe({
102
+ resource: params.resource,
103
+ liveParams: params.liveParams,
104
+ callback: (event: LiveEvent) => {
105
+ params.onLiveEvent?.(event);
106
+ if (liveMode === 'auto') {
107
+ const dpN = params.dataProviderName;
108
+ const dpMatch = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
109
+ queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === params.resource });
110
+ }
111
+ },
112
+ });
113
+ } catch (e) {
114
+ console.warn('[svadmin] LiveProvider.subscribe failed:', e);
115
+ return;
116
+ }
98
117
  return unsubscribe;
99
118
  });
100
119
  }
@@ -128,11 +147,12 @@ export function fireErrorNotification(
128
147
  config: NotificationConfig,
129
148
  defaultMessage: string,
130
149
  error?: unknown,
150
+ resource?: string,
131
151
  ): void {
132
152
  if (config === false) return;
133
153
  if (!config && !defaultMessage) return;
134
154
  if (typeof config === 'function') {
135
- const result = config(error);
155
+ const result = config(error, undefined, resource);
136
156
  notify({ type: 'error', message: result.message, description: result.description });
137
157
  return;
138
158
  }
@@ -26,7 +26,7 @@ import { getAdminOptions } from './options.svelte';
26
26
  import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
27
27
  import { useParsed } from './useParsed.svelte';
28
28
  import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification, checkError } from './hook-utils.svelte';
29
- import { invalidateByScopes } from './mutation-hooks.svelte';
29
+ import { invalidateByScopes, publishLiveEvent } from './mutation-hooks.svelte';
30
30
  import type { NotificationConfig, OvertimeOptions, LiveSubscriptionParams } from './hook-utils.svelte';
31
31
  import type { BaseRecord, HttpError, Pagination, Sort, Filter, DataProvider, KnownResources } from './types';
32
32
  import type { LiveMode, LiveEvent } from './live.svelte';
@@ -92,6 +92,7 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
92
92
  adminOptions.onLiveEvent?.(e);
93
93
  },
94
94
  enabled: options.queryOptions?.enabled ?? true,
95
+ dataProviderName: options.dataProviderName,
95
96
  }));
96
97
 
97
98
  let lastSuccessAt = 0;
@@ -103,7 +104,7 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
103
104
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
104
105
  lastErrorAt = query.errorUpdatedAt;
105
106
  checkError(query.error);
106
- fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
107
+ fireErrorNotification(options.errorNotification, 'Fetch failed', query.error, options.resource ?? parsed.resource ?? '');
107
108
  }
108
109
  });
109
110
 
@@ -206,7 +207,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
206
207
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
207
208
  lastErrorAt = query.errorUpdatedAt;
208
209
  checkError(query.error);
209
- fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
210
+ fireErrorNotification(options.errorNotification, 'Fetch failed', query.error, resource);
210
211
  }
211
212
  });
212
213
 
@@ -278,7 +279,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
278
279
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
279
280
  lastErrorAt = query.errorUpdatedAt;
280
281
  checkError(query.error);
281
- fireErrorNotification(options.errorNotification, 'Custom request failed', query.error);
282
+ fireErrorNotification(options.errorNotification, 'Custom request failed', query.error, '');
282
283
  }
283
284
  });
284
285
 
@@ -298,7 +299,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
298
299
  },
299
300
  onSuccess: (data, params) => {
300
301
  if (params.resource && params.invalidates !== false) {
301
- invalidateByScopes(queryClient, params.resource, params.invalidates, ['list', 'many']);
302
+ invalidateByScopes(queryClient, params.resource, params.invalidates, ['list', 'many'], undefined, dataProviderName);
302
303
  }
303
304
  },
304
305
  onError: (error) => {
@@ -316,36 +317,32 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
316
317
  const resource = options.resource ?? parsed.resource ?? '';
317
318
  const adminOptions = getAdminOptions();
318
319
  const queryClient = useQueryClient();
319
- let isMutating = $state(false);
320
- const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
321
320
 
322
321
  const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; variables: TVariables[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
323
322
  mutationFn: async (params) => {
324
- isMutating = true;
325
323
  const resName = params.resource ?? resource;
326
324
  const provider = getDataProviderForResource(resName, params.dataProviderName);
327
- try {
328
- if (provider.createMany) return await provider.createMany<TData, TVariables>({ resource: resName, variables: params.variables, meta: params.meta });
329
- const results = await Promise.all(params.variables.map(v => provider.create<TData, TVariables>({ resource: resName, variables: v, meta: params.meta })));
330
- return { data: results.map(r => r.data) };
331
- } finally { isMutating = false; }
325
+ if (provider.createMany) return await provider.createMany<TData, TVariables>({ resource: resName, variables: params.variables, meta: params.meta });
326
+ const results = await Promise.all(params.variables.map(v => provider.create<TData, TVariables>({ resource: resName, variables: v, meta: params.meta })));
327
+ return { data: results.map(r => r.data) };
332
328
  },
333
329
  onSuccess: (data, params) => {
334
330
  const resName = params.resource ?? resource;
335
331
  fireSuccessNotification(undefined, 'Created successfully', data.data, params.variables, resName);
336
332
  audit({ action: 'create', resource: resName });
333
+ publishLiveEvent(resName, 'created');
337
334
  },
338
335
  onError: (error, params) => {
339
336
  checkError(error);
340
- fireErrorNotification(undefined, 'Create many failed', error);
337
+ fireErrorNotification(undefined, 'Create many failed', error, params.resource ?? resource);
341
338
  },
342
339
  onSettled: (_d, _e, params) => {
343
340
  const resName = params.resource ?? resource;
344
- invalidateByScopes(queryClient, resName, undefined, ['list', 'many']);
341
+ invalidateByScopes(queryClient, resName, undefined, ['list', 'many'], undefined, params.dataProviderName);
345
342
  },
346
343
  }));
347
344
 
348
- return { mutation, get overtime() { return overtime; } };
345
+ return { mutation, get overtime() { return createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime); } };
349
346
  }
350
347
 
351
348
  export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
@@ -353,39 +350,35 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
353
350
  const resource = options.resource ?? parsed.resource ?? '';
354
351
  const adminOptions = getAdminOptions();
355
352
  const queryClient = useQueryClient();
356
- let isMutating = $state(false);
357
- const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
358
353
 
359
354
  const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; variables: TVariables; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
360
355
  mutationFn: async (params) => {
361
- isMutating = true;
362
356
  const resName = params.resource ?? resource;
363
357
  const provider = getDataProviderForResource(resName, params.dataProviderName);
364
- try {
365
- if (provider.updateMany) return await provider.updateMany<TData, TVariables>({ resource: resName, ids: params.ids, variables: params.variables, meta: params.meta });
366
- const results = await Promise.all(params.ids.map(id => provider.update<TData, TVariables>({ resource: resName, id, variables: params.variables, meta: params.meta })));
367
- return { data: results.map(r => r.data) };
368
- } finally { isMutating = false; }
358
+ if (provider.updateMany) return await provider.updateMany<TData, TVariables>({ resource: resName, ids: params.ids, variables: params.variables, meta: params.meta });
359
+ const results = await Promise.all(params.ids.map(id => provider.update<TData, TVariables>({ resource: resName, id, variables: params.variables, meta: params.meta })));
360
+ return { data: results.map(r => r.data) };
369
361
  },
370
362
  onSuccess: (data, params) => {
371
363
  const resName = params.resource ?? resource;
372
364
  fireSuccessNotification(undefined, 'Updated successfully', data.data, params.variables, resName);
373
365
  audit({ action: 'update', resource: resName });
366
+ publishLiveEvent(resName, 'updated', params.ids);
374
367
  },
375
368
  onError: (error, params) => {
376
369
  checkError(error);
377
- fireErrorNotification(undefined, 'Update many failed', error);
370
+ fireErrorNotification(undefined, 'Update many failed', error, params.resource ?? resource);
378
371
  },
379
372
  onSettled: (_d, _e, params) => {
380
373
  const resName = params.resource ?? resource;
381
- invalidateByScopes(queryClient, resName, undefined, ['list', 'many', 'detail'], undefined);
374
+ invalidateByScopes(queryClient, resName, undefined, ['list', 'many', 'detail'], undefined, params.dataProviderName);
382
375
  for (const id of params.ids) {
383
- queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === id });
376
+ queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === params.dataProviderName && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === id });
384
377
  }
385
378
  },
386
379
  }));
387
380
 
388
- return { mutation, get overtime() { return overtime; } };
381
+ return { mutation, get overtime() { return createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime); } };
389
382
  }
390
383
 
391
384
  export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
@@ -393,39 +386,35 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
393
386
  const resource = options.resource ?? parsed.resource ?? '';
394
387
  const adminOptions = getAdminOptions();
395
388
  const queryClient = useQueryClient();
396
- let isMutating = $state(false);
397
- const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
398
389
 
399
390
  const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
400
391
  mutationFn: async (params) => {
401
- isMutating = true;
402
392
  const resName = params.resource ?? resource;
403
393
  const provider = getDataProviderForResource(resName, params.dataProviderName);
404
- try {
405
- if (provider.deleteMany) return await provider.deleteMany<TData>({ resource: resName, ids: params.ids, meta: params.meta });
406
- const results = await Promise.all(params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })));
407
- return { data: results.map(r => r.data) };
408
- } finally { isMutating = false; }
394
+ if (provider.deleteMany) return await provider.deleteMany<TData>({ resource: resName, ids: params.ids, meta: params.meta });
395
+ const results = await Promise.all(params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })));
396
+ return { data: results.map(r => r.data) };
409
397
  },
410
398
  onSuccess: (data, params) => {
411
399
  const resName = params.resource ?? resource;
412
400
  fireSuccessNotification(undefined, 'Deleted successfully', data.data, undefined, resName);
413
401
  audit({ action: 'delete', resource: resName });
402
+ publishLiveEvent(resName, 'deleted', params.ids);
414
403
  },
415
404
  onError: (error, params) => {
416
405
  checkError(error);
417
- fireErrorNotification(undefined, 'Delete many failed', error);
406
+ fireErrorNotification(undefined, 'Delete many failed', error, params.resource ?? resource);
418
407
  },
419
408
  onSettled: (_d, _e, params) => {
420
409
  const resName = params.resource ?? resource;
421
- invalidateByScopes(queryClient, resName, undefined, ['list', 'many']);
410
+ invalidateByScopes(queryClient, resName, undefined, ['list', 'many'], undefined, params.dataProviderName);
422
411
  for (const id of params.ids) {
423
- queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === id });
412
+ queryClient.removeQueries({ predicate: (q) => q.queryKey[0] === params.dataProviderName && q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === id });
424
413
  }
425
414
  },
426
415
  }));
427
416
 
428
- return { mutation, get overtime() { return overtime; } };
417
+ return { mutation, get overtime() { return createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime); } };
429
418
  }
430
419
 
431
420
  // ─── useInvalidate ──────────────────────────────────────────────────
@@ -435,17 +424,18 @@ export function useInvalidate() {
435
424
  return (params: { resource?: string; invalidates?: string[] | 'all' | false; id?: string | number; dataProviderName?: string }) => {
436
425
  if (params.invalidates === false) return;
437
426
  if (params.invalidates === 'all' || !params.resource) {
438
- queryClient.invalidateQueries();
427
+ queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === params.dataProviderName });
439
428
  return;
440
429
  }
441
430
  const scopes = params.invalidates || ['resourceAll'];
442
431
  const res = params.resource;
432
+ const dpMatch = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === params.dataProviderName;
443
433
  for (const scope of scopes) {
444
- if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res });
445
- else if ((scope === 'detail' || scope === 'one') && params.id) queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && q.queryKey[2] === 'one' && q.queryKey[3] === params.id });
446
- else if (scope === 'one') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && q.queryKey[2] === 'one' });
447
- else if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
448
- else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && q.queryKey[2] === 'many' });
434
+ if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res });
435
+ 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 });
436
+ else if (scope === 'detail' || scope === 'one') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res && q.queryKey[2] === 'one' });
437
+ 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') });
438
+ else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => dpMatch(q) && q.queryKey[1] === res && q.queryKey[2] === 'many' });
449
439
  }
450
440
  };
451
441
  }
@@ -470,12 +460,15 @@ export function useDataProvider(): (dataProviderName?: string) => DataProvider {
470
460
 
471
461
  // ─── useThemedLayoutContext ─────────────────────────────────────────
472
462
 
463
+ let _sidebarCollapsed = $state(false);
464
+
465
+ export function resetSidebarCollapsed() { _sidebarCollapsed = false; }
466
+
473
467
  export function useThemedLayoutContext() {
474
- let sidebarCollapsed = $state(false);
475
468
  return {
476
- get sidebarCollapsed() { return sidebarCollapsed; },
477
- setSidebarCollapsed(v: boolean) { sidebarCollapsed = v; },
478
- toggleSidebar() { sidebarCollapsed = !sidebarCollapsed; },
469
+ get sidebarCollapsed() { return _sidebarCollapsed; },
470
+ setSidebarCollapsed(v: boolean) { _sidebarCollapsed = v; },
471
+ toggleSidebar() { _sidebarCollapsed = !_sidebarCollapsed; },
479
472
  };
480
473
  }
481
474
 
@@ -507,6 +507,8 @@ let locales = $state<Record<string, Record<string, string>>>({
507
507
  },
508
508
  });
509
509
 
510
+ const initialLocales = locales;
511
+
510
512
  /** Detect best locale from browser language */
511
513
  function detectLocale(): string {
512
514
  if (typeof navigator === 'undefined') return 'en';
@@ -552,6 +554,7 @@ export function addTranslations(locale: string, translations: Record<string, str
552
554
 
553
555
  export function resetI18n(): void {
554
556
  currentLocale = detectLocale();
557
+ locales = initialLocales;
555
558
  }
556
559
 
557
560
  /**
package/src/index.ts CHANGED
@@ -26,22 +26,23 @@ export {
26
26
  useOvertime, useRelation,
27
27
  useNotification, useDataProvider,
28
28
  useMenu, useBreadcrumb, useThemedLayoutContext,
29
+ publishLiveEvent, resetSidebarCollapsed,
29
30
  } from './hooks.svelte';
30
- export { matchRoute, navigate, currentPath, setActiveRouterProvider, beforeEach, afterEach } from './router';
31
+ export { matchRoute, navigate, currentPath, setActiveRouterProvider, beforeEach, afterEach, resetRouter } from './router';
31
32
  export type { RouteGuard } from './router';
32
33
  export { readURLState, writeURLState } from './url-sync';
33
- export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions';
34
+ export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions.svelte';
34
35
  export { useLive, useSubscription, usePublish } from './live.svelte';
35
- export { toast, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue } from './toast.svelte';
36
+ export { toast, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue, resetToast } from './toast.svelte';
36
37
  export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
37
38
  export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation } from './i18n.svelte';
38
39
  export { audit, setAuditHandler, setAuditLogProvider, getAuditLogProvider } from './audit';
39
40
  export type { AuditLogProvider } from './audit';
40
- export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals } from './chatProvider.svelte';
41
+ export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals, resetChatProvider } from './chatProvider.svelte';
41
42
  export type { ChatProvider, ChatMessage, ChatContext, ChatAction, AgentProvider, AgentEvent, AgentOptions, AdminTool, AdminToolParameter, ToolResult } from './chatProvider.svelte';
42
- export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, getColorThemes, configureTheme, getThemeConfig, clearCssOverrides, builtinPresets, registerColorPreset, getColorPresets } from './theme.svelte';
43
+ export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, getColorThemes, configureTheme, getThemeConfig, clearCssOverrides, builtinPresets, registerColorPreset, getColorPresets, resetTheme } from './theme.svelte';
43
44
  export type { ThemeMode, ColorTheme, ThemeStrategy, ThemeConfig, ColorPreset } from './theme.svelte';
44
- export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
45
+ export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier, resetUnsavedChanges } from './unsaved-changes.svelte';
45
46
  export { setAdminOptions, getAdminOptions, getTextTransformers } from './options.svelte';
46
47
  export type { AdminOptions, TextTransformers, OvertimeConfig } from './options.svelte';
47
48
  export { checkError } from './hook-utils.svelte';
@@ -70,7 +71,7 @@ export type {
70
71
  } from './types';
71
72
  export type { InvalidateScope } from './options.svelte';
72
73
  export type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
73
- export type { Action, CanParams, CanResult, AccessControlProvider } from './permissions';
74
+ export type { Action, CanParams, CanResult, AccessControlProvider } from './permissions.svelte';
74
75
  export type { AuditEntry, AuditHandler } from './audit';
75
76
  export { useCan } from './useCan';
76
77
  export type { UseCanOptions, UseCanResult } from './useCan';
@@ -84,8 +85,9 @@ export {
84
85
  useRegister, useForgotPassword, useUpdatePassword,
85
86
  useGetIdentity, useIsAuthenticated,
86
87
  useOnError, usePermissions,
88
+ getLogoutVersion, resetLogoutVersion,
87
89
  } from './auth-hooks.svelte';
88
- export { useParsed } from './useParsed.svelte';
90
+ export { useParsed, resetGlobalPath, syncGlobalPath } from './useParsed.svelte';
89
91
  export * from './useStepsForm.svelte';
90
92
  export { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
91
93
  export type { RouterProvider } from './router-provider';
@@ -229,11 +229,13 @@ export function inferFromOpenAPI(
229
229
  });
230
230
 
231
231
  // Check path-based CRUD capabilities
232
- const pathInfo = resourcePaths.get(schemaName.toLowerCase()) ??
233
- resourcePaths.get(schemaName.toLowerCase() + 's');
232
+ const baseName = schemaName.toLowerCase();
233
+ const resourceName = baseName.endsWith('s') ? baseName : baseName + 's';
234
+ const pathInfo = resourcePaths.get(baseName) ??
235
+ resourcePaths.get(resourceName);
234
236
 
235
237
  resources.push({
236
- name: schemaName.toLowerCase() + 's',
238
+ name: resourceName,
237
239
  label: schemaName,
238
240
  primaryKey,
239
241
  fields,
package/src/inferencer.ts CHANGED
@@ -12,7 +12,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
12
  const URL_RE = /^https?:\/\//;
13
13
  const IMAGE_RE = /\.(png|jpe?g|gif|svg|webp|avif|ico)(\?.*)?$/i;
14
14
  const PHONE_RE = /^\+?[\d\s\-()]{7,}$/;
15
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})/;
15
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?/;
16
16
  const COLOR_RE = /^#([0-9a-fA-F]{3,8})$/;
17
17
 
18
18
  type InferredType = FieldDefinition['type'];
@@ -31,7 +31,8 @@ export function inferFieldType(key: string, value: unknown): InferredType {
31
31
  if (value.every(v => typeof v === 'string' && IMAGE_RE.test(v))) return 'images';
32
32
  return 'tags';
33
33
  }
34
- return 'tags';
34
+ if (value.length > 0 && typeof value[0] === 'number') return 'tags';
35
+ return 'json';
35
36
  }
36
37
 
37
38
  if (typeof value === 'object') return 'json';
@@ -53,7 +54,7 @@ export function inferFieldType(key: string, value: unknown): InferredType {
53
54
  if (lk.includes('avatar') || lk.includes('image') || lk.includes('photo') || lk.includes('thumbnail') || lk.includes('logo')) return 'image';
54
55
  if (lk.includes('color') || lk.includes('colour')) return 'color';
55
56
  if (lk.includes('description') || lk.includes('content') || lk.includes('body') || lk.includes('bio') || lk.includes('summary')) return 'textarea';
56
- if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date') || lk.includes('date')) return 'date';
57
+ if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date') || /\bdate\b/.test(lk)) return 'date';
57
58
 
58
59
  return 'text';
59
60
  }
@@ -196,9 +197,10 @@ export function inferResource(
196
197
  // ─── Code Generation ─────────────────────────────────────────
197
198
 
198
199
  function generateCode(resource: ResourceDefinition): string {
200
+ const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
199
201
  const fieldsCode = resource.fields.map(f => {
200
202
  const lines = [
201
- ` { key: '${f.key}', label: '${f.label}', type: '${f.type}'`,
203
+ ` { key: '${esc(f.key)}', label: '${esc(f.label)}', type: '${f.type}'`,
202
204
  ];
203
205
  if (f.sortable) lines.push(` sortable: true`);
204
206
  if (f.searchable) lines.push(` searchable: true`);