@svadmin/core 0.49.1 → 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.
- package/README.md +32 -0
- package/package.json +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -774
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -28
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -286
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -103
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
package/src/router-provider.ts
CHANGED
|
@@ -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
|
|
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,
|
|
10
|
+
query?: Record<string,string>;
|
|
10
11
|
hash?: string;
|
|
11
|
-
type?: 'push'
|
|
12
|
-
}) => 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,
|
|
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,
|
|
32
|
-
if
|
|
33
|
-
let url
|
|
34
|
-
if
|
|
35
|
-
const params
|
|
36
|
-
if
|
|
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
|
|
39
|
-
if
|
|
40
|
-
const urlObj
|
|
41
|
-
urlObj.hash
|
|
42
|
-
window.history.replaceState(null,
|
|
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
|
|
46
|
+
window.location.hash=url;
|
|
46
47
|
}
|
|
47
48
|
},
|
|
48
49
|
back() {
|
|
49
|
-
if
|
|
50
|
+
if(typeof window==='undefined') return;
|
|
50
51
|
history.back();
|
|
51
52
|
},
|
|
52
53
|
parse() {
|
|
53
|
-
if
|
|
54
|
-
return {
|
|
54
|
+
if(typeof window==='undefined') {
|
|
55
|
+
return { params: {},pathname: '/' };
|
|
55
56
|
}
|
|
56
|
-
const hash
|
|
57
|
-
const [pathname,
|
|
58
|
-
const params: Record<string,
|
|
59
|
-
if
|
|
60
|
-
for
|
|
61
|
-
params[k]
|
|
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
|
|
65
|
+
const segments=pathname.split('/').filter(Boolean);
|
|
65
66
|
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 '#'
|
|
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
|
|
82
|
-
const normalizedBase
|
|
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,
|
|
85
|
-
if
|
|
86
|
-
let url
|
|
87
|
-
if
|
|
88
|
-
const params
|
|
89
|
-
if
|
|
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
|
|
92
|
-
if
|
|
93
|
-
history.replaceState(null,
|
|
94
|
+
if(hash) url+=`#${hash}`;
|
|
95
|
+
if(type==='replace') {
|
|
96
|
+
history.replaceState(null,'',url);
|
|
94
97
|
} else {
|
|
95
|
-
history.pushState(null,
|
|
98
|
+
history.pushState(null,'',url);
|
|
96
99
|
}
|
|
97
100
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
98
101
|
},
|
|
99
102
|
back() {
|
|
100
|
-
if
|
|
103
|
+
if(typeof window==='undefined') return;
|
|
101
104
|
history.back();
|
|
102
105
|
},
|
|
103
106
|
parse() {
|
|
104
|
-
if
|
|
105
|
-
return {
|
|
107
|
+
if(typeof window==='undefined') {
|
|
108
|
+
return { params: {},pathname: '/' };
|
|
106
109
|
}
|
|
107
|
-
const fullPath
|
|
108
|
-
const pathname
|
|
109
|
-
&&
|
|
110
|
-
? fullPath.slice(normalizedBase.length)
|
|
111
|
-
:
|
|
112
|
-
const params: Record<string,
|
|
113
|
-
for
|
|
114
|
-
params[k]
|
|
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
|
|
119
|
+
const segments=pathname.split('/').filter(Boolean);
|
|
117
120
|
return {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
9
|
-
const parsed
|
|
9
|
+
const adminContext=captureAdminContext();
|
|
10
|
+
const parsed=useParsed();
|
|
10
11
|
|
|
11
|
-
return (options: { resource?: string; action?: 'list'
|
|
12
|
-
const { resource,
|
|
13
|
-
if
|
|
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
|
|
18
|
-
basePath
|
|
19
|
-
} else if
|
|
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
|
|
22
|
+
basePath=`/${parsed.resourcePath}`;
|
|
22
23
|
} else {
|
|
23
|
-
basePath
|
|
24
|
+
basePath=`/${resource}`;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
let path
|
|
27
|
-
if
|
|
28
|
-
else if
|
|
29
|
-
else if
|
|
30
|
-
else if
|
|
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
|
|
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
|
|
42
|
-
const getToPath
|
|
43
|
-
|
|
44
|
-
return (options: { to?: string; query?: Record<string,
|
|
45
|
-
let targetUrl
|
|
46
|
-
if
|
|
47
|
-
targetUrl
|
|
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
|
|
55
|
+
if(!targetUrl) targetUrl='/';
|
|
50
56
|
|
|
51
|
-
if
|
|
52
|
-
const qs
|
|
53
|
-
if
|
|
54
|
-
targetUrl
|
|
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,
|
|
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
|
|
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
|
|
78
|
+
const adminContext=captureAdminContext();
|
|
73
79
|
return () => {
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
|
81
|
-
const parsed
|
|
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
|
|
85
|
-
const target
|
|
86
|
-
if
|
|
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
|
|
98
|
-
/**
|
|
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,
|
|
101
|
-
//
|
|
102
|
-
for
|
|
103
|
-
if
|
|
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
|
|
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
|
|
111
|
-
return { resource: res,
|
|
118
|
+
const res=adminContext.getResource(name);
|
|
119
|
+
return { resource: res,identifier: res.identifier??res.name };
|
|
112
120
|
} catch {
|
|
113
|
-
return { resource: 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
|
|
121
|
-
const parsed
|
|
122
|
-
const listQuery
|
|
123
|
-
const target
|
|
124
|
-
const targetResource
|
|
125
|
-
if
|
|
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({
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|