@svadmin/core 0.21.1 → 0.22.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.21.1",
3
+ "version": "0.22.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,13 +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
 
147
- const defaultRedirectOpt = action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate;
149
+ const defaultRedirectOpt = $derived(action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate);
148
150
  const {
149
- redirect: redirectDefault = defaultRedirectOpt ?? 'list',
151
+ redirect: redirectOption,
150
152
  successNotification, errorNotification,
151
153
  onMutationSuccess, onMutationError,
152
154
  meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
@@ -159,13 +161,17 @@ export function useForm<
159
161
  onChange: onChangeFn,
160
162
  onSubmit: onSubmitFn,
161
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');
162
167
 
163
- const provider = getDataProviderForResource(resource, dataProviderName);
164
- 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 || {};
165
170
  const queryMeta = { ...parsedMeta, ...hookMeta, ...hookQueryMeta };
166
171
  const mutationMeta = { ...parsedMeta, ...hookMeta, ...hookMutationMeta };
167
172
 
168
- 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; }
169
175
 
170
176
  // ─── Form values (single source of truth) ───────────────────────
171
177
  let values = $state<TVariables>((options.defaultValues ?? {}) as TVariables);
@@ -253,28 +259,29 @@ export function useForm<
253
259
  }
254
260
 
255
261
  // ─── Query (edit/clone mode) ────────────────────────────────────
256
- const query = (action === 'edit' || action === 'clone')
257
- ? createQuery(() => ({
258
- queryKey: [resource, 'one', currentId],
259
- queryFn: async () => {
260
- const result = await provider.getOne<BaseRecord>({ resource, id: currentId!, meta: queryMeta });
261
- return result.data;
262
- },
263
- enabled: (queryOptions?.enabled ?? true) && currentId != null,
264
- staleTime: queryOptions?.staleTime,
265
- }))
266
- : 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
+ }));
267
273
 
268
274
  // Auto-populate values from query data
269
275
  let queryInitializedId: string | number | undefined = undefined;
270
- if (query) {
276
+ {
271
277
  $effect.pre(() => {
272
278
  if (queryInitializedId === currentId) return;
273
279
  const data = query.data as Record<string, unknown> | undefined;
274
- if (data && (currentId == null || String(data.id) === String(currentId) || !data.id)) {
280
+ const pk = getResource(resource).primaryKey ?? 'id';
281
+ if (data && (currentId == null || String(data[pk]) === String(currentId) || !data[pk])) {
275
282
  // In clone mode, strip out identifiers
276
283
  const targetData = action === 'clone'
277
- ? Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id' && k !== '_id'))
284
+ ? Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id' && k !== '_id' && k !== pk))
278
285
  : data;
279
286
  // Merge: defaultValues < query data
280
287
  const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
@@ -294,10 +301,11 @@ export function useForm<
294
301
  onSuccess: (data: { data: TData }) => {
295
302
  if (invalidateScopes !== false) queryClient.invalidateQueries({ queryKey: [resource] });
296
303
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
297
- 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]) });
298
306
  onMutationSuccess?.(data);
299
307
  if (redirectOverride !== false) {
300
- const newId = (data.data as Record<string, unknown>).id;
308
+ const newId = (data.data as Record<string, unknown>)[pk];
301
309
  if (newId != null) currentId = newId as string | number;
302
310
  doRedirect(redirectOverride ?? redirectDefault);
303
311
  }
@@ -319,9 +327,14 @@ export function useForm<
319
327
  }));
320
328
 
321
329
  function doRedirect(to: 'list' | 'edit' | 'show' | false) {
322
- if (to === 'list') navigate(`/${resource}`);
323
- else if (to === 'edit' && currentId) navigate(`/${resource}/edit/${currentId}`);
324
- 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}`);
325
338
  }
326
339
 
327
340
  // ─── Submit ─────────────────────────────────────────────────────
@@ -353,7 +366,8 @@ export function useForm<
353
366
  let lastAutoSaveError = $state<unknown>(null);
354
367
 
355
368
  function triggerAutoSave() {
356
- if (!autoSaveOpts?.enabled || action === 'create' || action === 'clone') return;
369
+ const currentAction = action; // read $state reactively at call time
370
+ if (!autoSaveOpts?.enabled || currentAction === 'create' || currentAction === 'clone') return;
357
371
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
358
372
 
359
373
  autoSaveTimer = setTimeout(async () => {
@@ -419,11 +433,16 @@ export function useForm<
419
433
  reset,
420
434
 
421
435
  // State
422
- get loading() { return query?.isLoading ?? false; },
436
+ get loading() { return query.isLoading ?? false; },
423
437
  get submitting() { return createMut.isPending || updateMut.isPending; },
424
- resource, action,
438
+ get action() { return action; },
439
+ get resource() { return resource; },
425
440
  get id() { return currentId; },
426
- setId, mutationMode, redirect: doRedirect,
441
+ get isDirty() { return Object.keys(tainted).length > 0; },
442
+ setId,
443
+ setAction,
444
+ mutationMode,
445
+ redirect: doRedirect,
427
446
 
428
447
  // AutoSave
429
448
  triggerAutoSave,
@@ -437,6 +456,6 @@ export function useForm<
437
456
 
438
457
  // Raw escape hatches
439
458
  query,
440
- mutation: action === 'edit' ? updateMut : createMut,
459
+ get mutation() { return action === 'edit' ? updateMut : createMut; },
441
460
  };
442
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
  },
@@ -331,8 +340,10 @@ export function useInvalidate() {
331
340
  const scopes = params.invalidates || ['resourceAll'];
332
341
  for (const scope of scopes) {
333
342
  if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [params.resource] });
334
- else if (scope === 'detail' && params.id) queryClient.invalidateQueries({ queryKey: [params.resource, 'one', params.id] });
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'] });
335
345
  else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [params.resource, 'list'] });
346
+ else if (scope === 'many') queryClient.invalidateQueries({ queryKey: [params.resource, 'many'] });
336
347
  }
337
348
  };
338
349
  }
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 {
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
2
  import { getAdminOptions } from './options.svelte';
3
- import { getDataProviderForResource } from './context.svelte';
3
+ import { getDataProviderForResource, getResource } from './context.svelte';
4
4
  import { useParsed } from './useParsed.svelte';
5
5
  import { audit } from './audit';
6
6
  import { UndoError } from './types';
@@ -63,7 +63,9 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
63
63
  queryClient.invalidateQueries({ queryKey: [resName] });
64
64
  }
65
65
  fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
66
- audit({ action: 'create', resource: resName, recordId: String((data.data as Record<string, unknown>).id) });
66
+ const res = getResource(resName);
67
+ const pk = res.primaryKey ?? 'id';
68
+ audit({ action: 'create', resource: resName, recordId: String((data.data as Record<string, unknown>)[pk]) });
67
69
  if (typeof options.mutationOptions?.onSuccess === 'function') {
68
70
  (options.mutationOptions.onSuccess as Function)(data, params, context);
69
71
  }
@@ -152,7 +154,10 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
152
154
  }
153
155
  },
154
156
  onMutate: async (params) => {
155
- if (mutationMode === 'pessimistic') return undefined;
157
+ const userOnMutate = (options.mutationOptions as any)?.onMutate;
158
+ const userContext = typeof userOnMutate === 'function' ? await userOnMutate(params) : undefined;
159
+
160
+ if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
156
161
  const resName = params.resource ?? defaultResource;
157
162
  const targetId = params.id ?? defaultId;
158
163
 
@@ -160,7 +165,9 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
160
165
  const previousDetail = queryClient.getQueryData([resName, 'one', targetId]);
161
166
  const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
162
167
 
168
+ const pk = getResource(resName).primaryKey ?? 'id';
163
169
  const updater = params.optimisticUpdateMap;
170
+
164
171
  if (updater?.detail) {
165
172
  queryClient.setQueryData([resName, 'one', targetId], (old: unknown) => updater.detail!(old, params.variables, targetId));
166
173
  } else {
@@ -173,13 +180,14 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
173
180
  queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
174
181
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
175
182
  const o = old as { data: Record<string, unknown>[] };
176
- return { ...o, data: o.data.map((item) => String(item.id) === String(targetId) ? { ...item, ...params.variables } : item) };
183
+ return { ...o, data: o.data.map((item) => String(item[pk]) === String(targetId) ? { ...item, ...params.variables } : item) };
177
184
  });
178
185
  }
179
186
 
180
- return { previousDetail, previousList };
187
+ return { _svadmin_ctx: true, userContext, previousDetail, previousList };
181
188
  },
182
189
  onSuccess: (data, params, context) => {
190
+ const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
183
191
  const resName = params.resource ?? defaultResource;
184
192
  const targetId = params.id ?? defaultId;
185
193
  if (params.invalidates !== false) {
@@ -188,10 +196,11 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
188
196
  fireSuccessNotification(params.successNotification, 'Updated successfully', data.data, params.variables, resName);
189
197
  audit({ action: 'update', resource: resName, recordId: String(targetId) });
190
198
  if (typeof options.mutationOptions?.onSuccess === 'function') {
191
- (options.mutationOptions.onSuccess as Function)(data, params, context);
199
+ (options.mutationOptions.onSuccess as Function)(data, params, extractedCtx);
192
200
  }
193
201
  },
194
202
  onError: (error, params, context: unknown) => {
203
+ const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
195
204
  const resName = params.resource ?? defaultResource;
196
205
  const targetId = params.id ?? defaultId;
197
206
  const ctx = context as { previousDetail?: unknown; previousList?: [unknown, unknown][] } | undefined;
@@ -202,7 +211,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
202
211
  if (error instanceof UndoError) return;
203
212
  fireErrorNotification(params.errorNotification, 'Update failed', error);
204
213
  if (typeof options.mutationOptions?.onError === 'function') {
205
- (options.mutationOptions.onError as Function)(error, params, context);
214
+ (options.mutationOptions.onError as Function)(error, params, extractedCtx);
206
215
  }
207
216
  },
208
217
  }));
@@ -279,22 +288,28 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
279
288
  }
280
289
  },
281
290
  onMutate: async (params) => {
282
- if (mutationMode === 'pessimistic') return undefined;
291
+ const userOnMutate = (options.mutationOptions as any)?.onMutate;
292
+ const userContext = typeof userOnMutate === 'function' ? await userOnMutate(params) : undefined;
293
+
294
+ if (mutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
283
295
  const resName = params.resource ?? defaultResource;
284
296
  const targetId = params.id ?? defaultId;
285
297
 
286
298
  await queryClient.cancelQueries({ queryKey: [resName] });
287
299
  const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
288
300
 
301
+ const pk = getResource(resName).primaryKey ?? 'id';
302
+
289
303
  queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
290
304
  if (!old || typeof old !== 'object' || !('data' in old)) return old;
291
305
  const o = old as { data: Record<string, unknown>[] };
292
- return { ...o, data: o.data.filter((item) => String(item.id) !== String(targetId)) };
306
+ return { ...o, data: o.data.filter((item) => String(item[pk]) !== String(targetId)) };
293
307
  });
294
308
 
295
- return { previousList };
309
+ return { _svadmin_ctx: true, userContext, previousList };
296
310
  },
297
311
  onSuccess: (data, params, context) => {
312
+ const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
298
313
  const resName = params.resource ?? defaultResource;
299
314
  const targetId = params.id ?? defaultId;
300
315
  if (params.invalidates !== false) {
@@ -303,10 +318,11 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
303
318
  fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
304
319
  audit({ action: 'delete', resource: resName, recordId: String(targetId) });
305
320
  if (typeof options.mutationOptions?.onSuccess === 'function') {
306
- (options.mutationOptions.onSuccess as Function)(data, params, context);
321
+ (options.mutationOptions.onSuccess as Function)(data, params, extractedCtx);
307
322
  }
308
323
  },
309
324
  onError: (error, params, context: unknown) => {
325
+ const extractedCtx = (context as any)?._svadmin_ctx ? (context as any).userContext : context;
310
326
  const ctx = context as { previousList?: [unknown, unknown][] } | undefined;
311
327
  if (ctx?.previousList) {
312
328
  ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
@@ -314,7 +330,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
314
330
  if (error instanceof UndoError) return;
315
331
  fireErrorNotification(params.errorNotification, 'Delete failed', error);
316
332
  if (typeof options.mutationOptions?.onError === 'function') {
317
- (options.mutationOptions.onError as Function)(error, params, context);
333
+ (options.mutationOptions.onError as Function)(error, params, extractedCtx);
318
334
  }
319
335
  },
320
336
  }));
@@ -25,13 +25,16 @@ function extendQuery<Q extends object, E extends Record<string, unknown>>(
25
25
  extensions: () => E,
26
26
  ): Q & E {
27
27
  return new Proxy(query, {
28
- get(target, prop, receiver) {
29
- const ext = extensions();
30
- if (prop in ext) return ext[prop as keyof E];
31
- return Reflect.get(target, prop, receiver);
28
+ get(target, prop) {
29
+ if (typeof prop === 'string') {
30
+ const ext = extensions();
31
+ if (prop in ext) return ext[prop as keyof E];
32
+ }
33
+ return target[prop as keyof Q];
32
34
  },
33
35
  has(target, prop) {
34
- return prop in extensions() || Reflect.has(target, prop);
36
+ if (typeof prop === 'string' && prop in extensions()) return true;
37
+ return prop in target;
35
38
  },
36
39
  }) as Q & E;
37
40
  }
@@ -82,17 +85,17 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
82
85
 
83
86
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
84
87
 
85
- createLiveSubscription({
88
+ createLiveSubscription((): LiveSubscriptionParams => ({
86
89
  resource,
87
90
  liveProvider: getLiveProvider(),
88
91
  liveMode: options.liveMode ?? adminOptions.liveMode,
89
- onLiveEvent: (e) => {
92
+ onLiveEvent: (e: LiveEvent) => {
90
93
  options.onLiveEvent?.(e);
91
94
  adminOptions.onLiveEvent?.(e);
92
95
  },
93
96
  liveParams: options.liveParams,
94
97
  enabled: queryOptions?.enabled ?? true,
95
- });
98
+ }));
96
99
 
97
100
  $effect(() => {
98
101
  if (query.isSuccess && options.successNotification) {
@@ -148,18 +151,6 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
148
151
 
149
152
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
150
153
 
151
- createLiveSubscription({
152
- resource,
153
- liveProvider: getLiveProvider(),
154
- liveMode: options.liveMode ?? adminOptions.liveMode,
155
- onLiveEvent: (e) => {
156
- options.onLiveEvent?.(e);
157
- adminOptions.onLiveEvent?.(e);
158
- },
159
- liveParams: options.liveParams,
160
- enabled: (queryOptions?.enabled ?? true) && id != null,
161
- });
162
-
163
154
  $effect(() => {
164
155
  if (query.isSuccess && options.successNotification) {
165
156
  fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
@@ -225,18 +216,6 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
225
216
 
226
217
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
227
218
 
228
- createLiveSubscription({
229
- resource,
230
- liveProvider: getLiveProvider(),
231
- liveMode: options.liveMode ?? adminOptions.liveMode,
232
- onLiveEvent: (e) => {
233
- options.onLiveEvent?.(e);
234
- adminOptions.onLiveEvent?.(e);
235
- },
236
- liveParams: options.liveParams,
237
- enabled: (queryOptions?.enabled ?? true) && ids.length > 0,
238
- });
239
-
240
219
  $effect(() => {
241
220
  if (query.isSuccess && options.successNotification) fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
242
221
  else if (query.isError) fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
@@ -18,6 +18,8 @@ export interface RouterProvider {
18
18
  params: Record<string, string>;
19
19
  pathname: string;
20
20
  };
21
+ /** Formats an internal path into an href string for <a> tags */
22
+ formatLink?: (path: string) => string;
21
23
  }
22
24
 
23
25
  // ─── Hash Router (default) ──────────────────────────────────
@@ -25,6 +27,7 @@ export interface RouterProvider {
25
27
  export function createHashRouterProvider(): RouterProvider {
26
28
  return {
27
29
  go({ to, query, type = 'push' }) {
30
+ if (typeof window === 'undefined') return;
28
31
  let url = to;
29
32
  if (query) {
30
33
  const params = new URLSearchParams(query).toString();
@@ -34,14 +37,19 @@ export function createHashRouterProvider(): RouterProvider {
34
37
  const urlObj = new URL(window.location.href);
35
38
  urlObj.hash = url;
36
39
  window.history.replaceState(null, '', urlObj.href);
40
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
37
41
  } else {
38
42
  window.location.hash = url;
39
43
  }
40
44
  },
41
45
  back() {
46
+ if (typeof window === 'undefined') return;
42
47
  history.back();
43
48
  },
44
49
  parse() {
50
+ if (typeof window === 'undefined') {
51
+ return { resource: undefined, action: undefined, id: undefined, params: {}, pathname: '/' };
52
+ }
45
53
  const hash = window.location.hash.slice(1) || '/';
46
54
  const [pathname, queryString] = hash.split('?');
47
55
  const params: Record<string, string> = {};
@@ -59,6 +67,9 @@ export function createHashRouterProvider(): RouterProvider {
59
67
  pathname,
60
68
  };
61
69
  },
70
+ formatLink(path) {
71
+ return '#' + path.replace(/^#/, '');
72
+ },
62
73
  };
63
74
  }
64
75
 
@@ -67,6 +78,7 @@ export function createHashRouterProvider(): RouterProvider {
67
78
  export function createHistoryRouterProvider(basePath = ''): RouterProvider {
68
79
  return {
69
80
  go({ to, query, type = 'push' }) {
81
+ if (typeof window === 'undefined') return;
70
82
  let url = `${basePath}${to}`;
71
83
  if (query) {
72
84
  const params = new URLSearchParams(query).toString();
@@ -81,9 +93,13 @@ export function createHistoryRouterProvider(basePath = ''): RouterProvider {
81
93
  window.dispatchEvent(new PopStateEvent('popstate'));
82
94
  },
83
95
  back() {
96
+ if (typeof window === 'undefined') return;
84
97
  history.back();
85
98
  },
86
99
  parse() {
100
+ if (typeof window === 'undefined') {
101
+ return { resource: undefined, action: undefined, id: undefined, params: {}, pathname: '/' };
102
+ }
87
103
  const pathname = window.location.pathname.replace(basePath, '') || '/';
88
104
  const params: Record<string, string> = {};
89
105
  for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
@@ -98,5 +114,8 @@ export function createHistoryRouterProvider(basePath = ''): RouterProvider {
98
114
  pathname,
99
115
  };
100
116
  },
117
+ formatLink(path) {
118
+ return basePath + path;
119
+ },
101
120
  };
102
121
  }
package/src/router.ts CHANGED
@@ -50,9 +50,6 @@ export function matchRoute(
50
50
  return null;
51
51
  }
52
52
 
53
- /**
54
- * Navigate to a path. Uses RouterProvider if available, falls back to hash.
55
- */
56
53
  export function navigate(path: string, options?: { replaceState?: boolean }): void {
57
54
  if (_routerProvider) {
58
55
  _routerProvider.go({ to: path, type: options?.replaceState ? 'replace' : 'push' });
@@ -61,6 +58,16 @@ export function navigate(path: string, options?: { replaceState?: boolean }): vo
61
58
  }
62
59
  }
63
60
 
61
+ /**
62
+ * Format a path into an href suitable for <a> tags.
63
+ */
64
+ export function formatLink(path: string): string {
65
+ if (_routerProvider?.formatLink) {
66
+ return _routerProvider.formatLink(path);
67
+ }
68
+ return '#' + path.replace(/^#/, '');
69
+ }
70
+
64
71
  export function currentPath(): string {
65
72
  if (_routerProvider) {
66
73
  const parsed = _routerProvider.parse();
@@ -94,12 +94,13 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
94
94
  this.currentFilters = initFilters;
95
95
 
96
96
  // Create query
97
+ const _this = this;
97
98
  const { meta, syncWithLocation: _s, dataProviderName, pagination: _p, sorters: _sr, filters: _f, ...restOptions } = options;
98
99
  this._queryResult = useList<TData, TError>({
99
100
  resource,
100
- pagination: this.queryPagination,
101
- sorters: this.effectiveSorters,
102
- filters: this.effectiveFilters,
101
+ get pagination() { return _this.queryPagination; },
102
+ get sorters() { return _this.effectiveSorters; },
103
+ get filters() { return _this.effectiveFilters; },
103
104
  meta,
104
105
  dataProviderName,
105
106
  ...restOptions,
@@ -335,8 +335,10 @@ function applyTheme(m: ThemeMode): void {
335
335
  }
336
336
  }
337
337
 
338
- // Apply on init
339
- applyTheme(getStoredTheme());
338
+ // Apply on init (browser only)
339
+ if (typeof window !== 'undefined') {
340
+ applyTheme(getStoredTheme());
341
+ }
340
342
 
341
343
  // Listen for system preference changes
342
344
  if (typeof window !== 'undefined') {
@@ -390,9 +392,9 @@ function applyColorTheme(ct: ColorTheme): void {
390
392
  }
391
393
  }
392
394
 
393
- // Apply on init
394
- applyColorTheme(getStoredColorTheme());
395
- {
395
+ // Apply on init (browser only)
396
+ if (typeof window !== 'undefined') {
397
+ applyColorTheme(getStoredColorTheme());
396
398
  const initPreset = builtinPresets[getStoredColorTheme()];
397
399
  if (initPreset) applyColorPreset(initPreset);
398
400
  }
package/src/url-sync.ts CHANGED
@@ -1,6 +1,5 @@
1
- // URL state sync — sync pagination/sort/filters with hash params
2
-
3
1
  import type { Filter } from './types';
2
+ import { getRouterProvider } from './context.svelte';
4
3
 
5
4
  export interface URLState {
6
5
  page?: number;
@@ -13,29 +12,28 @@ export interface URLState {
13
12
 
14
13
  export function readURLState(): URLState {
15
14
  if (typeof window === 'undefined') return {};
16
- const hash = window.location.hash;
17
- const queryIdx = hash.indexOf('?');
18
- if (queryIdx === -1) return {};
15
+ const rp = getRouterProvider();
16
+ if (!rp) return {};
19
17
 
20
- const params = new URLSearchParams(hash.slice(queryIdx + 1));
18
+ const { params } = rp.parse();
21
19
  const state: URLState = {};
22
20
 
23
- const page = params.get('page');
21
+ const page = params['page'];
24
22
  if (page) state.page = parseInt(page, 10);
25
23
 
26
- const pageSize = params.get('pageSize');
24
+ const pageSize = params['pageSize'];
27
25
  if (pageSize) state.pageSize = parseInt(pageSize, 10);
28
26
 
29
- const sortField = params.get('sort');
27
+ const sortField = params['sort'];
30
28
  if (sortField) state.sortField = sortField;
31
29
 
32
- const sortOrder = params.get('order');
30
+ const sortOrder = params['order'];
33
31
  if (sortOrder === 'asc' || sortOrder === 'desc') state.sortOrder = sortOrder;
34
32
 
35
- const search = params.get('q');
33
+ const search = params['q'];
36
34
  if (search) state.search = search;
37
35
 
38
- const filtersRaw = params.get('filters');
36
+ const filtersRaw = params['filters'];
39
37
  if (filtersRaw) {
40
38
  try {
41
39
  state.filters = JSON.parse(filtersRaw);
@@ -47,24 +45,42 @@ export function readURLState(): URLState {
47
45
 
48
46
  export function writeURLState(state: URLState): void {
49
47
  if (typeof window === 'undefined') return;
50
- const hash = window.location.hash;
51
- const pathIdx = hash.indexOf('?');
52
- const basePath = pathIdx === -1 ? hash : hash.slice(0, pathIdx);
53
-
54
- const params = new URLSearchParams();
55
- if (state.page && state.page > 1) params.set('page', String(state.page));
56
- if (state.pageSize && state.pageSize !== 10) params.set('pageSize', String(state.pageSize));
57
- if (state.sortField) params.set('sort', state.sortField);
58
- if (state.sortOrder) params.set('order', state.sortOrder);
59
- if (state.search) params.set('q', state.search);
48
+ const rp = getRouterProvider();
49
+ if (!rp) return;
50
+
51
+ const current = rp.parse();
52
+ const params: Record<string, string> = { ...current.params };
53
+
54
+ if (state.page && state.page > 1) params['page'] = String(state.page);
55
+ else delete params['page'];
56
+
57
+ if (state.pageSize && state.pageSize !== 10) params['pageSize'] = String(state.pageSize);
58
+ else delete params['pageSize'];
59
+
60
+ if (state.sortField) params['sort'] = state.sortField;
61
+ else delete params['sort'];
62
+
63
+ if (state.sortOrder) params['order'] = state.sortOrder;
64
+ else delete params['order'];
65
+
66
+ if (state.search) params['q'] = state.search;
67
+ else delete params['q'];
68
+
60
69
  if (state.filters && state.filters.length > 0) {
61
- params.set('filters', JSON.stringify(state.filters));
70
+ params['filters'] = JSON.stringify(state.filters);
71
+ } else {
72
+ delete params['filters'];
62
73
  }
63
74
 
64
- const qs = params.toString();
65
- const newHash = qs ? `${basePath}?${qs}` : basePath;
66
-
67
- if (window.location.hash !== newHash) {
68
- history.replaceState(null, '', newHash);
75
+ // Prevent redundant navigation if nothing actually changed
76
+ const qsOld = new URLSearchParams(current.params).toString();
77
+ const qsNew = new URLSearchParams(params).toString();
78
+
79
+ if (qsOld !== qsNew) {
80
+ rp.go({
81
+ to: current.pathname,
82
+ query: params,
83
+ type: 'replace'
84
+ });
69
85
  }
70
86
  }
@@ -10,6 +10,15 @@ interface ParsedRoute {
10
10
  params: Record<string, string>;
11
11
  }
12
12
 
13
+ let globalPath = $state('/');
14
+ if (typeof window !== 'undefined') {
15
+ // Initialize
16
+ globalPath = currentPath();
17
+ // Listen to navigation events
18
+ window.addEventListener('hashchange', () => { globalPath = currentPath(); });
19
+ window.addEventListener('popstate', () => { globalPath = currentPath(); });
20
+ }
21
+
13
22
  /**
14
23
  * Parse the current hash URL into a structured route object.
15
24
  * Returns reactive properties that update when the URL changes.
@@ -21,11 +30,10 @@ interface ParsedRoute {
21
30
  * // parsed.id === '123'
22
31
  */
23
32
  export function useParsed(): ParsedRoute {
24
- const path = $derived(currentPath());
25
33
  const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
26
34
 
27
35
  const parsed = $derived.by(() => {
28
- const p = path;
36
+ const p = globalPath;
29
37
  const result: ParsedRoute = { params: {} };
30
38
 
31
39
  // Parse query params from hash
@@ -42,7 +42,13 @@ export function useModalForm<
42
42
  let visible = $state(options.defaultVisible ?? false);
43
43
 
44
44
  function show(id?: string | number) {
45
- if (id !== undefined) formState.setId(id);
45
+ if (id !== undefined) {
46
+ formState.setAction('edit');
47
+ formState.setId(id);
48
+ } else {
49
+ formState.setAction('create');
50
+ formState.setId(undefined);
51
+ }
46
52
  visible = true;
47
53
  }
48
54