@svadmin/core 0.21.0 → 0.21.2

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.21.0",
3
+ "version": "0.21.2",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -123,7 +123,9 @@ export function useGetIdentity() {
123
123
  });
124
124
  }
125
125
 
126
- fetch();
126
+ if (typeof window !== 'undefined') {
127
+ fetch();
128
+ }
127
129
 
128
130
  return {
129
131
  get data() { return data; },
@@ -141,13 +143,19 @@ export function useIsAuthenticated() {
141
143
  let isLoading = $state(true);
142
144
 
143
145
  if (provider) {
144
- provider.check().then((result: CheckResult) => {
145
- isAuthenticated = result.authenticated;
146
- isLoading = false;
147
- }).catch(() => {
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 {
155
+ // In SSR we assume false to prevent hydration mismatch before check
148
156
  isAuthenticated = false;
149
157
  isLoading = false;
150
- });
158
+ }
151
159
  } else {
152
160
  // No auth provider — treat as authenticated
153
161
  isAuthenticated = true;
@@ -230,7 +238,9 @@ export function usePermissions<T = unknown>() {
230
238
  });
231
239
  }
232
240
 
233
- fetch();
241
+ if (typeof window !== 'undefined') {
242
+ fetch();
243
+ }
234
244
 
235
245
  return {
236
246
  get isLoading() { return isLoading; },
@@ -72,8 +72,10 @@ export function setAuthProvider(provider: AuthProvider): void {
72
72
  authProvider = provider;
73
73
  }
74
74
 
75
- export function getAuthProvider(): AuthProvider {
76
- if (!authProvider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
75
+ export function getAuthProvider(): AuthProvider;
76
+ export function getAuthProvider(options: { optional: true }): AuthProvider | null;
77
+ export function getAuthProvider(options?: { optional?: boolean }): AuthProvider | null {
78
+ if (!authProvider && !options?.optional) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
77
79
  return authProvider;
78
80
  }
79
81
 
@@ -21,7 +21,7 @@ export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
21
21
  */
22
22
  export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExportOptions<TData> = {}) {
23
23
  const parsed = useParsed();
24
- const resource = options.resource ?? parsed.resource ?? '';
24
+ const resource = $derived(options.resource ?? parsed.resource ?? '');
25
25
  let isLoading = $state(false);
26
26
 
27
27
  async function triggerExport() {
@@ -55,7 +55,7 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
55
55
  ? allRecords.map(options.mapData)
56
56
  : allRecords.map(r => r as unknown as Record<string, unknown>);
57
57
 
58
- if (options.download !== false) {
58
+ if (options.download !== false && typeof document !== 'undefined') {
59
59
  const fields = Object.keys(mapped[0]);
60
60
  const header = fields.join(',');
61
61
  const rows = mapped.map(record =>
@@ -109,7 +109,7 @@ export interface UseImportOptions<TData = Record<string, unknown>> {
109
109
  */
110
110
  export function useImport<TData = Record<string, unknown>>(options: UseImportOptions<TData> = {}) {
111
111
  const parsed = useParsed();
112
- const resource = options.resource ?? parsed.resource ?? '';
112
+ const resource = $derived(options.resource ?? parsed.resource ?? '');
113
113
  let isLoading = $state(false);
114
114
  let mutationResult = $state<{ succeeded: unknown[]; errored: { request: unknown; error: unknown }[] } | null>(null);
115
115
 
@@ -1,12 +1,12 @@
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 } from './context.svelte';
4
+ import { getDataProviderForResource, getResource } 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
- import { navigate } from './router';
9
+ import { navigate, currentPath } from './router';
10
10
  import { HttpError } from './types';
11
11
  import type { BaseRecord, MutationMode, KnownResources } from './types';
12
12
 
@@ -110,14 +110,16 @@ export interface UseFormReturn<
110
110
  /** Reset form to initial/query values. Clears tainted and errors. */
111
111
  reset: () => void;
112
112
 
113
- // ─── State ────────────────────────────────────────────────────
113
+ // ─── State ──────────────────────────────────────────────────────
114
114
  readonly loading: boolean;
115
115
  readonly submitting: boolean;
116
- readonly resource: string;
117
116
  readonly action: 'create' | 'edit' | 'clone';
117
+ readonly resource: string;
118
118
  readonly id: string | number | undefined;
119
- setId: (newId: string | number) => void;
120
- readonly mutationMode: MutationMode;
119
+ readonly isDirty: boolean;
120
+ setId: (id: string | number | undefined) => void;
121
+ setAction: (action: 'create' | 'edit' | 'clone') => void;
122
+ mutationMode: MutationMode;
121
123
  redirect: (to: 'list' | 'edit' | 'show' | false) => void;
122
124
 
123
125
  // ─── AutoSave ─────────────────────────────────────────────────
@@ -140,12 +142,13 @@ export function useForm<
140
142
  const parsed = useParsed();
141
143
  const adminOptions = getAdminOptions();
142
144
 
143
- const resource = options.resource ?? parsed.resource ?? '';
144
- const action = options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create';
145
+ 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');
145
147
  let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
146
148
 
149
+ const defaultRedirectOpt = $derived(action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate);
147
150
  const {
148
- redirect: redirectDefault = 'list',
151
+ redirect: redirectOption,
149
152
  successNotification, errorNotification,
150
153
  onMutationSuccess, onMutationError,
151
154
  meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
@@ -158,13 +161,17 @@ export function useForm<
158
161
  onChange: onChangeFn,
159
162
  onSubmit: onSubmitFn,
160
163
  } = options;
164
+ // Reactive redirect: uses $derived `defaultRedirectOpt` so redirect target
165
+ // updates when `action` changes at runtime (e.g., via `setAction`).
166
+ const redirectDefault = $derived(redirectOption ?? defaultRedirectOpt ?? 'list');
161
167
 
162
- const provider = getDataProviderForResource(resource, dataProviderName);
163
- const parsedMeta = typeof window !== 'undefined' ? Object.fromEntries(new URLSearchParams(window.location.search).entries()) : {};
168
+ const provider = $derived(getDataProviderForResource(resource, dataProviderName));
169
+ const parsedMeta = useParsed().params || {};
164
170
  const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
165
171
  const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
166
172
 
167
- function setId(newId: string | number) { currentId = newId; }
173
+ function setId(newId: string | number | undefined) { currentId = newId; }
174
+ function setAction(newAction: 'create' | 'edit' | 'clone') { action = newAction; }
168
175
 
169
176
  // ─── Form values (single source of truth) ───────────────────────
170
177
  let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
@@ -252,30 +259,35 @@ export function useForm<
252
259
  }
253
260
 
254
261
  // ─── Query (edit/clone mode) ────────────────────────────────────
255
- const query = (action === 'edit' || action === 'clone')
256
- ? createQuery(() => ({
257
- queryKey: [resource, 'one', currentId],
258
- queryFn: async () => {
259
- const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
260
- return result.data;
261
- },
262
- enabled: (queryOptions?.enabled ?? true) && currentId != null,
263
- staleTime: queryOptions?.staleTime,
264
- }))
265
- : null;
262
+ // Always create the query — use `enabled` to conditionally activate.
263
+ // This ensures setAction('edit') at runtime will trigger a fetch.
264
+ const query = createQuery(() => ({
265
+ queryKey: [resource, 'one', currentId],
266
+ queryFn: async () => {
267
+ const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
268
+ return result.data;
269
+ },
270
+ enabled: (queryOptions?.enabled ?? true) && (action === 'edit' || action === 'clone') && currentId != null,
271
+ staleTime: queryOptions?.staleTime,
272
+ }));
266
273
 
267
274
  // Auto-populate values from query data
268
- let queryInitialized = false;
269
- if (query) {
275
+ let queryInitializedId: string | number | undefined = undefined;
276
+ {
270
277
  $effect.pre(() => {
271
- if (queryInitialized) return;
278
+ if (queryInitializedId === currentId) return;
272
279
  const data = query.data as Record<string, unknown> | undefined;
273
- if (data) {
280
+ const pk = getResource(resource).primaryKey ?? 'id';
281
+ if (data && (currentId == null || String(data[pk]) === String(currentId) || !data[pk])) {
282
+ // In clone mode, strip out identifiers
283
+ const targetData = action === 'clone'
284
+ ? Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id' && k !== '_id' && k !== pk))
285
+ : data;
274
286
  // Merge: defaultValues < query data
275
- const merged = { ...(options.defaultValues ?? {}), ...data } as TVariables;
287
+ const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
276
288
  values = merged;
277
289
  initialValues = { ...merged } as TVariables;
278
- queryInitialized = true;
290
+ queryInitializedId = currentId;
279
291
  }
280
292
  });
281
293
  }
@@ -289,9 +301,14 @@ export function useForm<
289
301
  onSuccess: (data: { data: TData }) => {
290
302
  if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
291
303
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
292
- audit({ action: 'create', resource, recordId: String((data.data as Record<string, unknown>).id) });
304
+ const pk = getResource(resource).primaryKey ?? 'id';
305
+ audit({ action: 'create', resource, recordId: String((data.data as Record<string, unknown>)[pk]) });
293
306
  onMutationSuccess?.(data);
294
- if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
307
+ if (redirectOverride !== false) {
308
+ const newId = (data.data as Record<string, unknown>)[pk];
309
+ if (newId != null) currentId = newId as string | number;
310
+ doRedirect(redirectOverride ?? redirectDefault);
311
+ }
295
312
  },
296
313
  onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
297
314
  }));
@@ -310,9 +327,14 @@ export function useForm<
310
327
  }));
311
328
 
312
329
  function doRedirect(to: 'list' | 'edit' | 'show' | false) {
313
- if (to === 'list') navigate(`/${resource}`);
314
- else if (to === 'edit' && currentId) navigate(`/${resource}/edit/${currentId}`);
315
- else if (to === 'show' && currentId) navigate(`/${resource}/show/${currentId}`);
330
+ const path = currentPath().split('?')[0];
331
+ const parts = path.split('/');
332
+ const resIdx = parts.lastIndexOf(String(resource));
333
+ const base = resIdx >= 0 ? parts.slice(0, resIdx + 1).join('/') : `/${resource}`;
334
+
335
+ if (to === 'list') navigate(base);
336
+ else if (to === 'edit' && currentId) navigate(`${base}/edit/${currentId}`);
337
+ else if (to === 'show' && currentId) navigate(`${base}/show/${currentId}`);
316
338
  }
317
339
 
318
340
  // ─── Submit ─────────────────────────────────────────────────────
@@ -344,7 +366,8 @@ export function useForm<
344
366
  let lastAutoSaveError = $state<unknown>(null);
345
367
 
346
368
  function triggerAutoSave() {
347
- if (!autoSaveOpts?.enabled || action === 'create') return;
369
+ const currentAction = action; // read $state reactively at call time
370
+ if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone') return;
348
371
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
349
372
 
350
373
  autoSaveTimer = setTimeout(async () => {
@@ -410,11 +433,16 @@ export function useForm<
410
433
  reset,
411
434
 
412
435
  // State
413
- get loading() { return query?.isLoading ?? false; },
436
+ get loading() { return query.isLoading ?? false; },
414
437
  get submitting() { return createMut.isPending || updateMut.isPending; },
415
- resource, action,
438
+ get action() { return action; },
439
+ get resource() { return resource; },
416
440
  get id() { return currentId; },
417
- setId, mutationMode, redirect: doRedirect,
441
+ get isDirty() { return Object.keys(tainted).length > 0; },
442
+ setId,
443
+ setAction,
444
+ mutationMode,
445
+ redirect: doRedirect,
418
446
 
419
447
  // AutoSave
420
448
  triggerAutoSave,
@@ -428,6 +456,6 @@ export function useForm<
428
456
 
429
457
  // Raw escape hatches
430
458
  query,
431
- mutation: action === 'edit' ? updateMut : createMut,
459
+ get mutation() { return action === 'edit' ? updateMut : createMut; },
432
460
  };
433
461
  }
@@ -54,26 +54,24 @@ export interface LiveSubscriptionParams {
54
54
  enabled?: boolean;
55
55
  }
56
56
 
57
- export function createLiveSubscription(params: LiveSubscriptionParams): void {
58
- const {
59
- resource,
60
- liveProvider,
61
- liveMode = 'off',
62
- onLiveEvent,
63
- enabled = true,
64
- } = params;
65
-
66
- if (!liveProvider || liveMode === 'off' || !enabled) return;
67
-
57
+ export function createLiveSubscription(paramsFn: () => LiveSubscriptionParams): void {
68
58
  const queryClient = useQueryClient();
69
59
 
70
60
  $effect(() => {
61
+ const params = paramsFn();
62
+ const liveProvider = params.liveProvider;
63
+ const liveMode = params.liveMode ?? 'off';
64
+ const enabled = params.enabled ?? true;
65
+
66
+ if (!liveProvider || liveMode === 'off' || !enabled) return;
67
+
71
68
  const unsubscribe = liveProvider.subscribe({
72
- resource,
69
+ resource: params.resource,
70
+ liveParams: params.liveParams,
73
71
  callback: (event: LiveEvent) => {
74
- onLiveEvent?.(event);
72
+ params.onLiveEvent?.(event);
75
73
  if (liveMode === 'auto') {
76
- queryClient.invalidateQueries({ queryKey: [resource] });
74
+ queryClient.invalidateQueries({ queryKey: [params.resource] });
77
75
  }
78
76
  },
79
77
  });
@@ -54,9 +54,10 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
54
54
  const parsed = useParsed();
55
55
  const resource = options.resource ?? parsed.resource ?? '';
56
56
  const adminOptions = getAdminOptions();
57
- const provider = getDataProviderForResource(resource, options.dataProviderName);
58
57
 
59
- const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => ({
58
+ const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => {
59
+ const provider = getDataProviderForResource(resource, options.dataProviderName);
60
+ return {
60
61
  queryKey: [resource, 'infiniteList', options.sorters, options.filters, options.meta],
61
62
  queryFn: async ({ pageParam = 1 }) => {
62
63
  const result = await provider.getList<TData>({
@@ -76,7 +77,8 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
76
77
  },
77
78
  enabled: options.queryOptions?.enabled ?? true,
78
79
  staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
79
- }));
80
+ };
81
+ });
80
82
 
81
83
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
82
84
 
@@ -108,7 +110,6 @@ export interface UseSelectOptions<TData extends BaseRecord = BaseRecord, TOption
108
110
  export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { label: string; value: string | number }>(options: UseSelectOptions<TData, TOption>) {
109
111
  const { resource, optionLabel = 'title', optionValue = 'id', sorters, filters, pagination, meta, dataProviderName, onSearch, debounce: debounceMs = 300 } = options;
110
112
  const adminOptions = getAdminOptions();
111
- const provider = getDataProviderForResource(resource, dataProviderName);
112
113
 
113
114
  let searchText = $state('');
114
115
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -122,26 +123,32 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
122
123
 
123
124
  const effectivePageSize = options.fetchSize ?? pagination?.pageSize ?? 999;
124
125
 
125
- const query = createQuery<{ data: TData[]; total: number }>(() => ({
126
+ const query = createQuery<{ data: TData[]; total: number }>(() => {
127
+ const provider = getDataProviderForResource(resource, dataProviderName);
128
+ return {
126
129
  queryKey: [resource, 'select', allFilters, sorters, pagination, meta],
127
130
  queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
128
131
  enabled: options.queryOptions?.enabled ?? true,
129
132
  staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
130
- }));
133
+ };
134
+ });
131
135
 
132
136
  // Parallel query to ensure default values are always available in options
133
137
  const defaultValueIds = options.defaultValue ?? [];
134
138
  const defaultValueQuery = defaultValueIds.length > 0
135
- ? createQuery<{ data: TData[] }>(() => ({
136
- queryKey: [resource, 'select-defaults', defaultValueIds],
137
- queryFn: async () => {
138
- if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta });
139
- const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta })));
140
- return { data: results.map(r => r.data) };
141
- },
142
- enabled: (options.defaultValueQueryOptions?.enabled ?? true) && defaultValueIds.length > 0,
143
- staleTime: options.defaultValueQueryOptions?.staleTime ?? Infinity,
144
- }))
139
+ ? createQuery<{ data: TData[] }>(() => {
140
+ const provider = getDataProviderForResource(resource, dataProviderName);
141
+ return {
142
+ queryKey: [resource, 'select-defaults', defaultValueIds],
143
+ queryFn: async () => {
144
+ if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta });
145
+ const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta })));
146
+ return { data: results.map(r => r.data) };
147
+ },
148
+ enabled: (options.defaultValueQueryOptions?.enabled ?? true) && defaultValueIds.length > 0,
149
+ staleTime: options.defaultValueQueryOptions?.staleTime ?? Infinity,
150
+ };
151
+ })
145
152
  : null;
146
153
 
147
154
  const selectOptions = $derived.by(() => {
@@ -151,14 +158,14 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
151
158
  const defaultQueryResult = defaultValueQuery as { data: { data: TData[] } | undefined } | null;
152
159
  const defaultData: TData[] = defaultQueryResult?.data?.data ?? [];
153
160
  const allData: TData[] = [...data];
154
- const existingIds = new Set(data.map((d: TData) => String((d as Record<string, unknown>)[typeof optionValue === 'string' ? optionValue : 'id'])));
161
+ const resolveValue = (item: TData) => typeof optionValue === 'function' ? optionValue(item) : (item as Record<string, unknown>)[optionValue];
162
+ const existingIds = new Set(data.map((d: TData) => String(resolveValue(d))));
155
163
  for (const item of defaultData) {
156
- const itemId = String((item as Record<string, unknown>)[typeof optionValue === 'string' ? optionValue : 'id']);
157
- if (!existingIds.has(itemId)) allData.push(item);
164
+ if (!existingIds.has(String(resolveValue(item)))) allData.push(item);
158
165
  }
159
166
  return allData.map((item: TData) => {
160
167
  const label = typeof optionLabel === 'function' ? optionLabel(item) : String((item as Record<string, unknown>)[optionLabel] ?? '');
161
- const value = typeof optionValue === 'function' ? optionValue(item) : (item as Record<string, unknown>)[optionValue];
168
+ const value = resolveValue(item);
162
169
  return { label, value } as unknown as TOption;
163
170
  });
164
171
  });
@@ -199,9 +206,10 @@ export interface UseCustomOptions<TData = unknown, TError = HttpError> {
199
206
 
200
207
  export function useCustom<TData = unknown, TError = HttpError>(options: UseCustomOptions<TData, TError>) {
201
208
  const adminOptions = getAdminOptions();
202
- const provider = getDataProvider(options.dataProviderName);
203
209
 
204
- const query = createQuery<{ data: TData }, TError>(() => ({
210
+ const query = createQuery<{ data: TData }, TError>(() => {
211
+ const provider = getDataProvider(options.dataProviderName);
212
+ return {
205
213
  queryKey: ['custom', options.url, options.method, options.config, options.meta],
206
214
  queryFn: async () => {
207
215
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
@@ -218,7 +226,8 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
218
226
  },
219
227
  enabled: options.queryOptions?.enabled ?? true,
220
228
  staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
221
- }));
229
+ };
230
+ });
222
231
 
223
232
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
224
233
 
@@ -228,11 +237,11 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
228
237
  // ─── useCustomMutation ──────────────────────────────────────────────
229
238
 
230
239
  export function useCustomMutation<TData = unknown, TError = HttpError, TVariables = unknown>(dataProviderName?: string) {
231
- const provider = getDataProvider(dataProviderName);
232
240
  const queryClient = useQueryClient();
233
241
 
234
242
  const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; meta?: Record<string, unknown> }>(() => ({
235
243
  mutationFn: async (params) => {
244
+ const provider = getDataProvider(dataProviderName);
236
245
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
237
246
  return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, meta: params.meta });
238
247
  },
@@ -328,7 +337,14 @@ export function useInvalidate() {
328
337
  queryClient.invalidateQueries();
329
338
  return;
330
339
  }
331
- queryClient.invalidateQueries({ queryKey: [params.resource] });
340
+ const scopes = params.invalidates || ['resourceAll'];
341
+ for (const scope of scopes) {
342
+ if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [params.resource] });
343
+ else if ((scope === 'detail' || scope === 'one') && params.id) queryClient.invalidateQueries({ queryKey: [params.resource, 'one', params.id] });
344
+ else if (scope === 'one') queryClient.invalidateQueries({ queryKey: [params.resource, 'one'] });
345
+ else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [params.resource, 'list'] });
346
+ else if (scope === 'many') queryClient.invalidateQueries({ queryKey: [params.resource, 'many'] });
347
+ }
332
348
  };
333
349
  }
334
350
 
@@ -46,6 +46,7 @@ const locales: Record<string, Record<string, string>> = {
46
46
  'common.darkMode': '暗色模式',
47
47
  'common.lightMode': '亮色模式',
48
48
  'common.detail': '详情',
49
+ 'common.show': '查看',
49
50
  'common.loadFailed': '加载失败: {message}',
50
51
  'common.pageNotFound': '页面未找到',
51
52
  'common.error': '出错了',
@@ -283,6 +284,7 @@ const locales: Record<string, Record<string, string>> = {
283
284
  'common.darkMode': 'Dark mode',
284
285
  'common.lightMode': 'Light mode',
285
286
  'common.detail': 'Detail',
287
+ 'common.show': 'Show',
286
288
  'common.loadFailed': 'Load failed: {message}',
287
289
  'common.pageNotFound': 'Page not found',
288
290
  'common.error': 'Something went wrong',
@@ -513,7 +515,7 @@ export function t(key: string, params?: Record<string, string | number>): string
513
515
 
514
516
  if (params) {
515
517
  for (const [k, v] of Object.entries(params)) {
516
- text = text.replace(`{${k}}`, String(v));
518
+ text = text.replaceAll(`{${k}}`, String(v));
517
519
  }
518
520
  }
519
521
  return text;
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  setAuthProvider, getAuthProvider,
6
6
  setResources, getResources, getResource,
7
7
  setRouterProvider, getRouterProvider,
8
+ setLiveProvider, getLiveProvider,
8
9
  } from './context.svelte';
9
10
  export type { DataProviderInput } from './context.svelte';
10
11
  export {
package/src/live-sse.ts CHANGED
@@ -29,6 +29,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
29
29
  const subscribers = new Map<string, Set<Callback>>();
30
30
  let eventSource: EventSource | null = null;
31
31
  let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
32
+ const registeredListeners = new Set<string>();
32
33
 
33
34
  function connect() {
34
35
  if (eventSource) return;
@@ -64,7 +65,8 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
64
65
  }
65
66
 
66
67
  function addNamedListener(resource: string) {
67
- if (!eventSource) return;
68
+ if (!eventSource || registeredListeners.has(resource)) return;
69
+ registeredListeners.add(resource);
68
70
  eventSource.addEventListener(resource, ((event: MessageEvent) => {
69
71
  try {
70
72
  const parsed = JSON.parse(event.data);
@@ -109,12 +111,15 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
109
111
  eventSource = null;
110
112
  }
111
113
  status = 'disconnected';
114
+ registeredListeners.clear();
112
115
  }
113
116
 
114
117
  return {
115
118
  subscribe({ resource, callback }) {
119
+ let isNewResource = false;
116
120
  if (!subscribers.has(resource)) {
117
121
  subscribers.set(resource, new Set());
122
+ isNewResource = true;
118
123
  }
119
124
  subscribers.get(resource)!.add(callback);
120
125
 
@@ -123,7 +128,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
123
128
  connect();
124
129
  }
125
130
  // Add named listener for this resource if connected
126
- if (eventSource && resource !== '*') {
131
+ if (eventSource && resource !== '*' && isNewResource) {
127
132
  addNamedListener(resource);
128
133
  }
129
134
 
@@ -104,10 +104,7 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
104
104
  status = 'disconnected';
105
105
  }
106
106
 
107
- // Auto-connect on creation
108
- if (typeof WebSocket !== 'undefined') {
109
- connect();
110
- }
107
+
111
108
 
112
109
  return {
113
110
  subscribe({ resource, callback }) {
@@ -13,8 +13,8 @@ export interface LiveEvent {
13
13
  }
14
14
 
15
15
  export interface LiveProvider {
16
- subscribe(params: { resource: string; callback: (event: LiveEvent) => void }): () => void;
17
- unsubscribe?(params: { resource: string }): void;
16
+ subscribe(params: { resource: string; liveParams?: Record<string, unknown>; callback: (event: LiveEvent) => void }): () => void;
17
+ unsubscribe?(params: { resource: string; liveParams?: Record<string, unknown> }): void;
18
18
  publish?(event: LiveEvent): void;
19
19
  }
20
20