@svadmin/core 0.50.0 → 0.52.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.
Files changed (97) hide show
  1. package/package.json +21 -4
  2. package/src/access-control-contract.ts +250 -0
  3. package/src/adapters/casbin.ts +20 -14
  4. package/src/adapters/casl.ts +22 -19
  5. package/src/admin-tool.ts +7 -7
  6. package/src/audit-contract.ts +105 -0
  7. package/src/audit.ts +88 -128
  8. package/src/auth-hooks.svelte.ts +593 -261
  9. package/src/auth-mutation-contract.ts +143 -0
  10. package/src/auth-query-contract.ts +97 -0
  11. package/src/captured-mutation.svelte.ts +45 -0
  12. package/src/chatProvider.svelte.ts +1 -1
  13. package/src/command-contract.ts +195 -0
  14. package/src/context.svelte.ts +217 -205
  15. package/src/contract-context.svelte.ts +37 -0
  16. package/src/contract-form.svelte.ts +566 -0
  17. package/src/create-contract.ts +56 -0
  18. package/src/create-hooks.svelte.ts +347 -0
  19. package/src/create-many-contract.ts +90 -0
  20. package/src/create-many-hooks.svelte.ts +396 -0
  21. package/src/data-transfer.svelte.ts +1 -215
  22. package/src/defined-options.ts +50 -0
  23. package/src/delete-contract.ts +50 -0
  24. package/src/delete-hooks.svelte.ts +403 -0
  25. package/src/delete-many-contract.ts +60 -0
  26. package/src/delete-many-hooks.svelte.ts +402 -0
  27. package/src/enterprise.ts +4 -4
  28. package/src/export-format.ts +16 -14
  29. package/src/export-hooks.svelte.ts +191 -0
  30. package/src/feature-gate-contract.ts +53 -0
  31. package/src/hook-utils.svelte.ts +53 -105
  32. package/src/hooks.svelte.ts +19 -776
  33. package/src/http.ts +88 -87
  34. package/src/i18n.svelte.ts +56 -2
  35. package/src/import-contract.ts +106 -0
  36. package/src/import-hooks.svelte.ts +310 -0
  37. package/src/index.ts +77 -30
  38. package/src/inferencer-graphql.ts +7 -6
  39. package/src/infinite-query.svelte.ts +228 -0
  40. package/src/invalidation-contract.ts +26 -0
  41. package/src/live-sse.ts +91 -127
  42. package/src/live-subscription.svelte.ts +130 -0
  43. package/src/live-transport.ts +127 -0
  44. package/src/live-websocket.ts +85 -118
  45. package/src/live.svelte.ts +66 -68
  46. package/src/notification.svelte.ts +17 -15
  47. package/src/permission-catalog.ts +121 -0
  48. package/src/permission-hints-contract.ts +48 -0
  49. package/src/permissions.svelte.ts +112 -59
  50. package/src/plain-data.ts +61 -0
  51. package/src/provider-bundle.ts +85 -79
  52. package/src/provider-response.ts +35 -0
  53. package/src/query-hooks.svelte.ts +241 -288
  54. package/src/query-invalidation.ts +172 -0
  55. package/src/query-keys.ts +221 -110
  56. package/src/query-session.svelte.ts +216 -0
  57. package/src/query-snapshot.ts +144 -0
  58. package/src/reactive-projection.ts +53 -0
  59. package/src/record-decoder.ts +63 -0
  60. package/src/resource-binding.svelte.ts +19 -0
  61. package/src/resource-contract.ts +595 -0
  62. package/src/resource-mutation-types.ts +30 -0
  63. package/src/resource-schemas.ts +371 -0
  64. package/src/resource-transport-config.ts +156 -0
  65. package/src/route-parsing.ts +11 -11
  66. package/src/router-provider.ts +64 -59
  67. package/src/router.ts +8 -3
  68. package/src/routing-hooks.svelte.ts +90 -62
  69. package/src/schema-validation.ts +92 -0
  70. package/src/schema.ts +13 -0
  71. package/src/select-options.ts +95 -0
  72. package/src/select-query.svelte.ts +191 -0
  73. package/src/session-query.svelte.ts +88 -0
  74. package/src/strict-command-hooks.svelte.ts +333 -0
  75. package/src/strict-hooks.svelte.ts +198 -0
  76. package/src/table-hooks.svelte.ts +180 -174
  77. package/src/table-state.svelte.ts +117 -117
  78. package/src/task-contract.ts +87 -0
  79. package/src/task-hooks.svelte.ts +193 -149
  80. package/src/task-provider.ts +115 -0
  81. package/src/task-subscription.ts +132 -0
  82. package/src/theme.svelte.ts +155 -154
  83. package/src/toast.svelte.ts +38 -37
  84. package/src/types.ts +55 -108
  85. package/src/unsafe.ts +7 -0
  86. package/src/update-contract.ts +65 -0
  87. package/src/update-hooks.svelte.ts +347 -0
  88. package/src/update-many-contract.ts +79 -0
  89. package/src/update-many-hooks.svelte.ts +373 -0
  90. package/src/url-sync.ts +11 -10
  91. package/src/useCan.ts +96 -31
  92. package/src/useParsed.svelte.ts +18 -14
  93. package/src/useStepsForm.svelte.ts +123 -63
  94. package/src/utility-hooks.svelte.ts +101 -158
  95. package/src/form-hooks.svelte.ts +0 -889
  96. package/src/mutation-hooks.svelte.ts +0 -561
  97. package/src/schema-form.ts +0 -31
@@ -1,15 +1,16 @@
1
1
  // Router Provider — abstracts routing strategy (hash, history, SvelteKit, etc.)
2
+ import { definedOptions } from './defined-options';
2
3
 
3
- export type RouterNavigationResult = false | ReturnType<() => void>;
4
+ export type RouterNavigationResult=false|ReturnType<() => void>;
4
5
 
5
6
  export interface RouterProvider {
6
7
  /** Navigate to a path */
7
8
  go: (options: {
8
9
  to: string;
9
- query?: Record<string, string>;
10
+ query?: Record<string,string>;
10
11
  hash?: string;
11
- type?: 'push' | 'replace';
12
- }) => RouterNavigationResult | Promise<RouterNavigationResult>;
12
+ type?: 'push'|'replace';
13
+ }) => RouterNavigationResult|Promise<RouterNavigationResult>;
13
14
  /** Navigate back */
14
15
  back: () => void;
15
16
  /** Parse current URL into structured route info */
@@ -17,7 +18,7 @@ export interface RouterProvider {
17
18
  resource?: string;
18
19
  action?: string;
19
20
  id?: string;
20
- params: Record<string, string>;
21
+ params: Record<string,string>;
21
22
  pathname: string;
22
23
  };
23
24
  /** Formats an internal path into an href string for <a> tags */
@@ -28,102 +29,106 @@ export interface RouterProvider {
28
29
 
29
30
  export function createHashRouterProvider(): RouterProvider {
30
31
  return {
31
- go({ to, query, hash, type = 'push' }) {
32
- if (typeof window === 'undefined') return;
33
- let url = to;
34
- if (query) {
35
- const params = new URLSearchParams(query).toString();
36
- if (params) url += url.includes('?') ? `&${params}` : `?${params}`;
32
+ go({ to,query,hash,type='push' }) {
33
+ if(typeof window==='undefined') return;
34
+ let url=to;
35
+ if(query) {
36
+ const params=new URLSearchParams(query).toString();
37
+ if(params) url+=url.includes('?')? `&${params}`:`?${params}`;
37
38
  }
38
- if (hash) url += `#${hash}`;
39
- if (type === 'replace') {
40
- const urlObj = new URL(window.location.href);
41
- urlObj.hash = url;
42
- window.history.replaceState(null, '', urlObj.href);
39
+ if(hash) url+=`#${hash}`;
40
+ if(type==='replace') {
41
+ const urlObj=new URL(window.location.href);
42
+ urlObj.hash=url;
43
+ window.history.replaceState(null,'',urlObj.href);
43
44
  window.dispatchEvent(new HashChangeEvent('hashchange'));
44
45
  } else {
45
- window.location.hash = url;
46
+ window.location.hash=url;
46
47
  }
47
48
  },
48
49
  back() {
49
- if (typeof window === 'undefined') return;
50
+ if(typeof window==='undefined') return;
50
51
  history.back();
51
52
  },
52
53
  parse() {
53
- if (typeof window === 'undefined') {
54
- return { resource: undefined, action: undefined, id: undefined, params: {}, pathname: '/' };
54
+ if(typeof window==='undefined') {
55
+ return { params: {},pathname: '/' };
55
56
  }
56
- const hash = window.location.hash.slice(1) || '/';
57
- const [pathname, queryString] = hash.split('?');
58
- const params: Record<string, string> = {};
59
- if (queryString) {
60
- for (const [k, v] of new URLSearchParams(queryString).entries()) {
61
- params[k] = v;
57
+ const hash=window.location.hash.slice(1)||'/';
58
+ const [pathname='/',queryString]=hash.split('?');
59
+ const params: Record<string,string>={};
60
+ if(queryString) {
61
+ for(const [k,v] of new URLSearchParams(queryString).entries()) {
62
+ params[k]=v;
62
63
  }
63
64
  }
64
- const segments = pathname.split('/').filter(Boolean);
65
+ const segments=pathname.split('/').filter(Boolean);
65
66
  return {
66
- resource: segments[0],
67
- action: segments[1],
68
- id: segments[2],
67
+ ...definedOptions({
68
+ resource: segments[0],
69
+ action: segments[1],
70
+ id: segments[2],
71
+ }),
69
72
  params,
70
73
  pathname,
71
74
  };
72
75
  },
73
76
  formatLink(path) {
74
- return '#' + path.replace(/^#/, '');
77
+ return '#'+path.replace(/^#/,'');
75
78
  },
76
79
  };
77
80
  }
78
81
 
79
82
  // ─── History Router (HTML5 pushState) ───────────────────────
80
83
 
81
- export function createHistoryRouterProvider(basePath = ''): RouterProvider {
82
- const normalizedBase = basePath.endsWith('/') && basePath.length > 1 ? basePath.slice(0, -1) : basePath;
84
+ export function createHistoryRouterProvider(basePath=''): RouterProvider {
85
+ const normalizedBase=basePath.endsWith('/')&&basePath.length>1? basePath.slice(0,-1):basePath;
83
86
  return {
84
- go({ to, query, hash, type = 'push' }) {
85
- if (typeof window === 'undefined') return;
86
- let url = `${normalizedBase}${to}`;
87
- if (query) {
88
- const params = new URLSearchParams(query).toString();
89
- if (params) url += url.includes('?') ? `&${params}` : `?${params}`;
87
+ go({ to,query,hash,type='push' }) {
88
+ if(typeof window==='undefined') return;
89
+ let url=`${normalizedBase}${to}`;
90
+ if(query) {
91
+ const params=new URLSearchParams(query).toString();
92
+ if(params) url+=url.includes('?')? `&${params}`:`?${params}`;
90
93
  }
91
- if (hash) url += `#${hash}`;
92
- if (type === 'replace') {
93
- history.replaceState(null, '', url);
94
+ if(hash) url+=`#${hash}`;
95
+ if(type==='replace') {
96
+ history.replaceState(null,'',url);
94
97
  } else {
95
- history.pushState(null, '', url);
98
+ history.pushState(null,'',url);
96
99
  }
97
100
  window.dispatchEvent(new PopStateEvent('popstate'));
98
101
  },
99
102
  back() {
100
- if (typeof window === 'undefined') return;
103
+ if(typeof window==='undefined') return;
101
104
  history.back();
102
105
  },
103
106
  parse() {
104
- if (typeof window === 'undefined') {
105
- return { resource: undefined, action: undefined, id: undefined, params: {}, pathname: '/' };
107
+ if(typeof window==='undefined') {
108
+ return { params: {},pathname: '/' };
106
109
  }
107
- const fullPath = window.location.pathname;
108
- const pathname = (normalizedBase && fullPath.startsWith(normalizedBase)
109
- && (fullPath.length === normalizedBase.length || fullPath[normalizedBase.length] === '/')
110
- ? fullPath.slice(normalizedBase.length) || '/'
111
- : window.location.pathname);
112
- const params: Record<string, string> = {};
113
- for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
114
- params[k] = v;
110
+ const fullPath=window.location.pathname;
111
+ const pathname=(normalizedBase&&fullPath.startsWith(normalizedBase)
112
+ &&(fullPath.length===normalizedBase.length||fullPath[normalizedBase.length]==='/')
113
+ ? fullPath.slice(normalizedBase.length)||'/'
114
+ :window.location.pathname);
115
+ const params: Record<string,string>={};
116
+ for(const [k,v] of new URLSearchParams(window.location.search).entries()) {
117
+ params[k]=v;
115
118
  }
116
- const segments = pathname.split('/').filter(Boolean);
119
+ const segments=pathname.split('/').filter(Boolean);
117
120
  return {
118
- resource: segments[0],
119
- action: segments[1],
120
- id: segments[2],
121
+ ...definedOptions({
122
+ resource: segments[0],
123
+ action: segments[1],
124
+ id: segments[2],
125
+ }),
121
126
  params,
122
127
  pathname,
123
128
  };
124
129
  },
125
130
  formatLink(path) {
126
- return normalizedBase + path;
131
+ return normalizedBase+path;
127
132
  },
128
133
  };
129
134
  }
package/src/router.ts CHANGED
@@ -59,7 +59,8 @@ export function matchRoute(
59
59
  if (match) {
60
60
  const params: Record<string, string> = {};
61
61
  keys.forEach((key, i) => {
62
- params[key] = match[i + 1];
62
+ const value = match[i + 1];
63
+ if (value !== undefined) params[key] = value;
63
64
  });
64
65
  return { route: pattern, params };
65
66
  }
@@ -75,20 +76,24 @@ export async function navigateWithProvider(
75
76
  provider: RouterProvider | undefined,
76
77
  path: string,
77
78
  options?: { replaceState?: boolean },
79
+ isActive: () => boolean = () => true,
78
80
  ): Promise<void> {
81
+ if (!isActive()) return;
79
82
  const from = currentPathWithProvider(provider);
80
83
  for (const guard of _beforeGuards) {
81
84
  const allowed = await guard(path, from);
82
- if (!allowed) return;
85
+ if (!allowed || !isActive()) return;
83
86
  }
87
+ if (!isActive()) return;
84
88
  if (provider) {
85
89
  const navigated = await provider.go({ to: path, type: options?.replaceState ? 'replace' : 'push' });
86
- if (navigated === false) return;
90
+ if (navigated === false || !isActive()) return;
87
91
  } else if (typeof window !== 'undefined') {
88
92
  window.location.hash = '#' + path.replace(/^#/, '');
89
93
  }
90
94
  _syncGlobalPath();
91
95
  for (const guard of _afterGuards) {
96
+ if (!isActive()) return;
92
97
  guard(path, from);
93
98
  }
94
99
  }
@@ -1,3 +1,4 @@
1
+ import { definedOptions } from './defined-options';
1
2
  import { captureAdminContext } from './context.svelte';
2
3
  import { useParsed } from './useParsed.svelte';
3
4
  import { sanitizeListQueryParams } from './url-sync';
@@ -5,32 +6,32 @@ import { sanitizeListQueryParams } from './url-sync';
5
6
  // ─── routing-hooks.svelte.ts ───────────────────────────────────
6
7
 
7
8
  export function useGetToPath() {
8
- const adminContext = captureAdminContext();
9
- const parsed = useParsed();
9
+ const adminContext=captureAdminContext();
10
+ const parsed=useParsed();
10
11
 
11
- return (options: { resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number; meta?: Record<string, unknown> }) => {
12
- const { resource, action, id, meta } = options;
13
- if (!resource) return '/';
12
+ return (options: { resource?: string; action?: 'list'|'create'|'edit'|'show'|'clone'; id?: string|number; meta?: Record<string,unknown> }) => {
13
+ const { resource,action,id,meta }=options;
14
+ if(!resource) return '/';
14
15
 
15
16
  // Support nested resources: use meta.parentPath or auto-detect from current URL
16
17
  let basePath: string;
17
- if (meta?.parentPath) {
18
- basePath = `${meta.parentPath}/${resource}`;
19
- } else if (parsed.resourcePath && parsed.resource === resource) {
18
+ if(meta?.['parentPath']) {
19
+ basePath=`${meta['parentPath']}/${resource}`;
20
+ } else if(parsed.resourcePath&&parsed.resource===resource) {
20
21
  // Re-use the current nested path context (e.g., /teams/123/users)
21
- basePath = `/${parsed.resourcePath}`;
22
+ basePath=`/${parsed.resourcePath}`;
22
23
  } else {
23
- basePath = `/${resource}`;
24
+ basePath=`/${resource}`;
24
25
  }
25
26
 
26
- let path = basePath;
27
- if (action === 'create') path += '/create';
28
- else if (action === 'edit' && id) path += `/edit/${id}`;
29
- else if (action === 'show' && id) path += `/show/${id}`;
30
- else if (action === 'clone' && id) path += `/clone/${id}`;
27
+ let path=basePath;
28
+ if(action==='create') path+='/create';
29
+ else if(action==='edit'&&id) path+=`/edit/${id}`;
30
+ else if(action==='show'&&id) path+=`/show/${id}`;
31
+ else if(action==='clone'&&id) path+=`/clone/${id}`;
31
32
 
32
33
  // Apply router mapping if the router provider defines custom path transformers
33
- if (adminContext.routerProvider?.go) {
34
+ if(adminContext.routerProvider?.go) {
34
35
  // SvelteKit path generation can be injected here.
35
36
  }
36
37
  return path;
@@ -38,29 +39,34 @@ export function useGetToPath() {
38
39
  }
39
40
 
40
41
  export function useGo() {
41
- const adminContext = captureAdminContext();
42
- const getToPath = useGetToPath();
43
-
44
- return (options: { to?: string; query?: Record<string, unknown>; type?: 'push' | 'replace'; resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number }) => {
45
- let targetUrl = options.to;
46
- if (!targetUrl && options.resource) {
47
- targetUrl = getToPath({ resource: options.resource, action: options.action, id: options.id });
42
+ const adminContext=captureAdminContext();
43
+ const getToPath=useGetToPath();
44
+
45
+ return (options: { to?: string; query?: Record<string,unknown>; type?: 'push'|'replace'; resource?: string; action?: 'list'|'create'|'edit'|'show'|'clone'; id?: string|number }) => {
46
+ let targetUrl=options.to;
47
+ if(!targetUrl&&options.resource) {
48
+ targetUrl=getToPath({
49
+ resource: options.resource,...definedOptions({
50
+ action: options.action,
51
+ id: options.id,
52
+ }),
53
+ });
48
54
  }
49
- if (!targetUrl) targetUrl = '/';
55
+ if(!targetUrl) targetUrl='/';
50
56
 
51
- if (options.query && Object.keys(options.query).length > 0) {
52
- const qs = new URLSearchParams(Object.entries(options.query).map(([k, v]) => [k, String(v)])).toString();
53
- if (qs) {
54
- targetUrl += targetUrl.includes('?') ? `&${qs}` : `?${qs}`;
57
+ if(options.query&&Object.keys(options.query).length>0) {
58
+ const qs=new URLSearchParams(Object.entries(options.query).map(([k,v]) => [k,String(v)])).toString();
59
+ if(qs) {
60
+ targetUrl+=targetUrl.includes('?')? `&${qs}`:`?${qs}`;
55
61
  }
56
62
  }
57
63
 
58
- void adminContext.navigate(targetUrl, options.type === 'replace' ? { replaceState: true } : undefined);
64
+ void adminContext.navigate(targetUrl,options.type==='replace'? { replaceState: true }:undefined);
59
65
  };
60
66
  }
61
67
 
62
68
  export function useBack() {
63
- const adminContext = captureAdminContext();
69
+ const adminContext=captureAdminContext();
64
70
  return () => adminContext.back();
65
71
  }
66
72
 
@@ -69,21 +75,23 @@ export function useBack() {
69
75
  * (to avoid full page reloads in SSR apps)
70
76
  */
71
77
  export function useLink() {
72
- const adminContext = captureAdminContext();
78
+ const adminContext=captureAdminContext();
73
79
  return () => {
74
- const routerProvider = adminContext.routerProvider as unknown as Record<string, unknown> | undefined;
75
- return (routerProvider?.Link as string) ?? 'a';
80
+ const provider=adminContext.routerProvider;
81
+ if(!provider||!('Link' in provider)||provider.Link===undefined) return 'a';
82
+ if(typeof provider.Link!=='string') throw new TypeError('Router Link must be a tag name');
83
+ return provider.Link;
76
84
  };
77
85
  }
78
86
 
79
87
  export function useResource(resourceName?: string) {
80
- const adminContext = captureAdminContext();
81
- const parsed = useParsed();
88
+ const adminContext=captureAdminContext();
89
+ const parsed=useParsed();
82
90
 
83
91
  // Derived resource — re-evaluates when parsed.resource or resourceName changes
84
- const resolvedResource = $derived.by(() => {
85
- const target = resourceName ?? parsed.resource;
86
- if (!target) return undefined;
92
+ const resolvedResource=$derived.by(() => {
93
+ const target=resourceName??parsed.resource;
94
+ if(!target) return undefined;
87
95
  try {
88
96
  return adminContext.getResource(target);
89
97
  } catch {
@@ -94,46 +102,66 @@ export function useResource(resourceName?: string) {
94
102
  return {
95
103
  get resource() { return resolvedResource; },
96
104
  get resources() { return adminContext.resources; },
97
- get identifier() { return resolvedResource?.identifier ?? resolvedResource?.name; },
98
- /** 当前 URL 解析出的 parent 资源信息(嵌套资源路由) */
105
+ get identifier() { return resolvedResource?.identifier??resolvedResource?.name; },
106
+ /** Parent resource information parsed from the current nested-resource URL. */
99
107
  get parent() {
100
- const parentParams: Record<string, string> = {};
101
- // 只取路径解析出的 parent params,排除 query params 的误判
102
- for (const [k, v] of Object.entries(parsed.parentParams)) {
103
- if (v) parentParams[k] = v;
108
+ const parentParams: Record<string,string>={};
109
+ // Use only parent params parsed from the path and exclude query params.
110
+ for(const [k,v] of Object.entries(parsed.parentParams)) {
111
+ if(v) parentParams[k]=v;
104
112
  }
105
- return Object.keys(parentParams).length > 0 ? parentParams : undefined;
113
+ return Object.keys(parentParams).length>0? parentParams:undefined;
106
114
  },
107
115
  /** Resolve a resource by name — useful for dynamic lookups inside callbacks */
108
116
  select: (name: string) => {
109
117
  try {
110
- const res = adminContext.getResource(name);
111
- return { resource: res, identifier: res.identifier ?? res.name };
118
+ const res=adminContext.getResource(name);
119
+ return { resource: res,identifier: res.identifier??res.name };
112
120
  } catch {
113
- return { resource: undefined, identifier: undefined };
121
+ return { resource: undefined,identifier: undefined };
114
122
  }
115
123
  },
116
124
  };
117
125
  }
118
126
 
119
127
  export function useNavigation() {
120
- const go = useGo();
121
- const parsed = useParsed();
122
- const listQuery = (resource?: string) => {
123
- const target = resource ?? parsed.resourcePath ?? parsed.resource;
124
- const targetResource = target?.split('/').filter(Boolean).at(-1);
125
- if (targetResource && parsed.resource && targetResource !== parsed.resource) return {};
126
- return sanitizeListQueryParams(parsed.params ?? {});
128
+ const go=useGo();
129
+ const parsed=useParsed();
130
+ const listQuery=(resource?: string) => {
131
+ const target=resource??parsed.resourcePath??parsed.resource;
132
+ const targetResource=target?.split('/').filter(Boolean).at(-1);
133
+ if(targetResource&&parsed.resource&&targetResource!==parsed.resource) return {};
134
+ return sanitizeListQueryParams(parsed.params??{});
127
135
  };
128
136
 
129
137
  return {
130
- create: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'create', query: listQuery(resource) }),
131
- edit: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'edit', id: id ?? parsed.id, query: listQuery(resource) }),
132
- clone: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'clone', id: id ?? parsed.id, query: listQuery(resource) }),
133
- show: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'show', id: id ?? parsed.id, query: listQuery(resource) }),
134
- list: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'list', query: listQuery(resource) }),
135
- push: (url: string) => go({ to: url, type: 'push' }),
136
- replace: (url: string) => go({ to: url, type: 'replace' }),
138
+ create: (resource?: string) => go({
139
+ type: 'push',...definedOptions({ resource: resource??parsed.resourcePath??parsed.resource }),action: 'create',...definedOptions({
140
+ query: listQuery(resource)
141
+ })
142
+ }),
143
+ edit: (resource?: string,id?: string|number) => go({
144
+ type: 'push',...definedOptions({ resource: resource??parsed.resourcePath??parsed.resource }),action: 'edit',...definedOptions({
145
+ id: id??parsed.id,query: listQuery(resource)
146
+ })
147
+ }),
148
+ clone: (resource?: string,id?: string|number) => go({
149
+ type: 'push',...definedOptions({ resource: resource??parsed.resourcePath??parsed.resource }),action: 'clone',...definedOptions({
150
+ id: id??parsed.id,query: listQuery(resource)
151
+ })
152
+ }),
153
+ show: (resource?: string,id?: string|number) => go({
154
+ type: 'push',...definedOptions({ resource: resource??parsed.resourcePath??parsed.resource }),action: 'show',...definedOptions({
155
+ id: id??parsed.id,query: listQuery(resource)
156
+ })
157
+ }),
158
+ list: (resource?: string) => go({
159
+ type: 'push',...definedOptions({ resource: resource??parsed.resourcePath??parsed.resource }),action: 'list',...definedOptions({
160
+ query: listQuery(resource)
161
+ })
162
+ }),
163
+ push: (url: string) => go({ to: url,type: 'push' }),
164
+ replace: (url: string) => go({ to: url,type: 'replace' }),
137
165
  goBack: useBack(),
138
166
  };
139
167
  }
@@ -0,0 +1,92 @@
1
+ import { CloneType, TransformKind, Type, type Static, type TSchema } from '@sinclair/typebox';
2
+ import { TypeGuard } from '@sinclair/typebox/type';
3
+ import { Value } from '@sinclair/typebox/value';
4
+
5
+ /**
6
+ * Strengthen optional-property presence with ordinary schemas, without changing
7
+ * TypeBox's process-wide policy or maintaining a second value validator.
8
+ */
9
+ function exactSchema(schema: TSchema): TSchema {
10
+ if (TypeGuard.IsObject(schema)) {
11
+ schema.properties = Object.fromEntries(
12
+ Object.entries(schema.properties).map(([key, property]) => [key, exactSchema(property)]),
13
+ );
14
+ if (TypeGuard.IsSchema(schema.additionalProperties)) {
15
+ schema.additionalProperties = exactSchema(schema.additionalProperties);
16
+ }
17
+ const optional = Object.entries(schema.properties)
18
+ .filter(([key]) => !schema.required?.includes(key));
19
+ if (optional.length === 0) return schema;
20
+
21
+ const constraints = optional.map(([key, property]) => Type.Union([
22
+ Type.Required(Type.Object({ [key]: property })),
23
+ Type.Not(Type.Object({ [key]: Type.Unknown() })),
24
+ ]));
25
+ // The presence constraint owns each optional value's validation. Leaving it
26
+ // in both places would validate deep optional objects exponentially often.
27
+ for (const [key] of optional) schema.properties[key] = Type.Unknown();
28
+ // References must resolve to the strengthened object, not its inner copy.
29
+ const id = schema.$id;
30
+ delete schema.$id;
31
+ const transform = Object.getOwnPropertyDescriptor(schema, TransformKind);
32
+ Reflect.deleteProperty(schema, TransformKind);
33
+ const result = Type.Intersect([schema, ...constraints], id === undefined ? {} : { $id: id });
34
+ if (transform !== undefined) Object.defineProperty(result, TransformKind, transform);
35
+ return result;
36
+ }
37
+ if (TypeGuard.IsArray(schema)) {
38
+ schema.items = exactSchema(schema.items);
39
+ if (TypeGuard.IsSchema(schema.contains)) schema.contains = exactSchema(schema.contains);
40
+ } else if (TypeGuard.IsTuple(schema)) {
41
+ if (schema.items) schema.items = schema.items.map(exactSchema);
42
+ } else if (TypeGuard.IsUnion(schema)) {
43
+ schema.anyOf = schema.anyOf.map(exactSchema);
44
+ } else if (TypeGuard.IsIntersect(schema)) {
45
+ schema.allOf = schema.allOf.map(exactSchema);
46
+ if (TypeGuard.IsSchema(schema.unevaluatedProperties)) {
47
+ schema.unevaluatedProperties = exactSchema(schema.unevaluatedProperties);
48
+ }
49
+ } else if (TypeGuard.IsNot(schema)) {
50
+ schema.not = exactSchema(schema.not);
51
+ } else if (TypeGuard.IsRecord(schema)) {
52
+ schema.patternProperties = Object.fromEntries(
53
+ Object.entries(schema.patternProperties).map(([key, property]) => [key, exactSchema(property)]),
54
+ );
55
+ if (TypeGuard.IsSchema(schema.additionalProperties)) {
56
+ schema.additionalProperties = exactSchema(schema.additionalProperties);
57
+ }
58
+ } else if (TypeGuard.IsImport(schema)) {
59
+ schema.$defs = Object.fromEntries(
60
+ Object.entries(schema.$defs).map(([key, definition]) => {
61
+ if (!TypeGuard.IsSchema(definition)) throw new TypeError('Invalid imported schema definition');
62
+ return [key, exactSchema(definition)];
63
+ }),
64
+ );
65
+ }
66
+ return schema;
67
+ }
68
+
69
+ export function createExactSchemaValidator<S extends TSchema>(schema: S, references: readonly TSchema[] = []) {
70
+ const checked = exactSchema(CloneType(schema));
71
+ const checkedReferences = references.map(reference => exactSchema(CloneType(reference)));
72
+ return {
73
+ Check(value: unknown): value is Static<S> {
74
+ return Value.Check(checked, checkedReferences, value);
75
+ },
76
+ Errors(value: unknown) {
77
+ return Value.Errors(checked, checkedReferences, value);
78
+ },
79
+ };
80
+ }
81
+
82
+ const validators = new WeakMap<TSchema, ReturnType<typeof createExactSchemaValidator>>();
83
+
84
+ /** Schemas are snapshotted on first use; later edits cannot weaken a live boundary. */
85
+ export function checkExact<S extends TSchema>(schema: S, value: unknown): value is Static<S> {
86
+ let validator = validators.get(schema);
87
+ if (!validator) {
88
+ validator = createExactSchemaValidator(schema);
89
+ validators.set(schema, validator);
90
+ }
91
+ return validator.Check(value);
92
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { createExactSchemaValidator, checkExact } from './schema-validation';
2
+ export { snapshotPlainData } from './plain-data';
3
+ export type { JsonValue } from './plain-data';
4
+ export { decodeAuditEntry, decodeAuditCreate, decodeAuditQuery, decodeAuditEntries } from './audit-contract';
5
+ export { decodeTaskRecord, decodeTaskList, decodeTaskSubmitOptions, TaskError } from './task-contract';
6
+ export { validatedTaskSubscription, taskClientField, requiredTaskClientMethod } from './task-subscription';
7
+ export { closeContractSchema, parseContractRouteId, formatContractRouteId } from './resource-contract';
8
+ export type { SafeSchema, SchemaValue } from './resource-contract';
9
+ export { decodeBaseRecord, decodeOneResult, decodeManyResult, decodeListResult, decodeCustomResult } from './record-decoder';
10
+ export type { RecordDecoder } from './record-decoder';
11
+ export { withValidatedResponses } from './provider-response';
12
+ export { rejectProviderResponse } from './record-decoder';
13
+ export type { DataTransport } from './provider-response';
@@ -0,0 +1,95 @@
1
+ import type { BaseRecord } from './types';
2
+ import { HttpError } from './types';
3
+ import type { RecordDecoder } from './record-decoder';
4
+ import { snapshotPlainData, type JsonValue } from './plain-data';
5
+
6
+ export interface SelectOption {
7
+ label: string;
8
+ value: string | number;
9
+ }
10
+
11
+ export function selectOptionValue(value: unknown): string | number {
12
+ if (typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))) return value;
13
+ throw new HttpError('Invalid select option', 422, undefined, { code: 'INVALID_SELECT_OPTION' });
14
+ }
15
+
16
+ /** Each user mapper receives an independent record, never a cached query object. */
17
+ export function mapSelectOptions<T extends BaseRecord>(
18
+ records: T[],
19
+ label: string | ((item: T) => string),
20
+ value: string | ((item: T) => string | number),
21
+ decode: RecordDecoder<T>,
22
+ isCurrent: () => boolean,
23
+ ): SelectOption[] {
24
+ try {
25
+ return records.map(record => {
26
+ if (!isCurrent()) throw new Error('Obsolete select mapping');
27
+ const labelRecord = decode(snapshotPlainData(record));
28
+ const mappedLabel = typeof label === 'function' ? label(labelRecord) : labelRecord[label];
29
+ if (!isCurrent()) throw new Error('Obsolete select mapping');
30
+ if (typeof label === 'function' && typeof mappedLabel !== 'string') {
31
+ throw new TypeError('Select label callbacks must return strings');
32
+ }
33
+ const valueRecord = decode(snapshotPlainData(record));
34
+ const mappedValue = typeof value === 'function' ? value(valueRecord) : valueRecord[value];
35
+ if (!isCurrent()) throw new Error('Obsolete select mapping');
36
+ return { label: String(selectOptionValue(mappedLabel)), value: selectOptionValue(mappedValue) };
37
+ });
38
+ } catch {
39
+ throw new HttpError('Invalid select option', 422, undefined, { code: 'INVALID_SELECT_OPTION' });
40
+ }
41
+ }
42
+
43
+ function sameSnapshot(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
44
+ if (Object.is(left, right)) return true;
45
+ if (Array.isArray(left)) {
46
+ return Array.isArray(right) && left.length === right.length &&
47
+ left.every((value, index) => sameSnapshot(value, right[index]));
48
+ }
49
+ if (left === null || right === null || typeof left !== 'object' ||
50
+ typeof right !== 'object' || Array.isArray(right)) return false;
51
+ const keys = Object.keys(left);
52
+ const otherKeys = Object.keys(right);
53
+ return keys.length === otherKeys.length &&
54
+ keys.every((key, index) => key === otherKeys[index] && sameSnapshot(left[key], right[key]));
55
+ }
56
+
57
+ /** Memoization includes detached contents because external cache edits may not advance its revision. */
58
+ export function createSelectProjection<T extends BaseRecord, R extends { data: T[] }>(
59
+ decodeResult: (input: unknown) => R,
60
+ label: string | ((item: T) => string),
61
+ value: string | ((item: T) => string | number),
62
+ decode: RecordDecoder<T>,
63
+ isCurrent: () => boolean,
64
+ ): (input: unknown, version: number) => R & { options: SelectOption[] } {
65
+ let cached: { input: unknown; version: number; records: JsonValue; options: SelectOption[] | undefined } | undefined;
66
+ return (input, version) => {
67
+ const result = decodeResult(input);
68
+ if (!isCurrent()) throw new HttpError('Obsolete select mapping', 409, undefined, { code: 'QUERY_SESSION_SUPERSEDED' });
69
+ let records: JsonValue;
70
+ try { records = snapshotPlainData(result.data); }
71
+ catch { throw new HttpError('Invalid select option', 422, undefined, { code: 'INVALID_SELECT_OPTION' }); }
72
+ let entry = cached;
73
+ if (!entry || entry.input !== input || entry.version !== version || !sameSnapshot(entry.records, records)) {
74
+ // Publish an incomplete entry first so a same-input recursive mapper cannot recurse indefinitely.
75
+ entry = { input, version, records, options: undefined };
76
+ cached = entry;
77
+ try {
78
+ entry.options = mapSelectOptions(result.data, label, value, decode, isCurrent);
79
+ } catch {
80
+ // Keep this failure memoized until the input, revision or validated contents change.
81
+ }
82
+ }
83
+ if (!entry.options) throw new HttpError('Invalid select option', 422, undefined, { code: 'INVALID_SELECT_OPTION' });
84
+ return { ...result, options: entry.options.map(option => ({ ...option })) };
85
+ };
86
+ }
87
+
88
+ export function mergeSelectOptions(...groups: SelectOption[][]): SelectOption[] {
89
+ const seen = new Set<string | number>();
90
+ return groups.flatMap(group => group.filter(option => {
91
+ if (seen.has(option.value)) return false;
92
+ seen.add(option.value);
93
+ return true;
94
+ }));
95
+ }