@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/http.ts
CHANGED
|
@@ -1,85 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP fetch
|
|
2
|
+
* HTTP fetch utilities with CSRF protection, automatic 401 redirects, and structured error semantics.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { HttpError,
|
|
5
|
+
import { HttpError,type ValidationErrors } from './types';
|
|
6
|
+
import { Type } from '@sinclair/typebox';
|
|
7
|
+
import { Value } from '@sinclair/typebox/value';
|
|
8
|
+
import { definedOptions } from './defined-options';
|
|
6
9
|
|
|
7
10
|
export interface FetchWithInterceptorOptions {
|
|
8
|
-
/** 401
|
|
11
|
+
/** Login path used for 401 redirects. */
|
|
9
12
|
loginPath?: string;
|
|
10
|
-
/** 403
|
|
13
|
+
/** Error message used when a 403 response has no server-provided message. */
|
|
11
14
|
forbiddenMessage?: string;
|
|
12
|
-
/**
|
|
15
|
+
/** Whether to add the X-Requested-With header automatically for CSRF protection. */
|
|
13
16
|
csrfProtection?: boolean;
|
|
14
|
-
/**
|
|
17
|
+
/** Custom fetch implementation for testing or SSR injection. */
|
|
15
18
|
fetchImpl?: FetchWithInterceptor;
|
|
16
|
-
/**
|
|
17
|
-
onUnauthorized?: (loginPath: string,
|
|
19
|
+
/** Custom 401 redirect handler for testing or SSR injection. */
|
|
20
|
+
onUnauthorized?: (loginPath: string,returnTo: string) => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export type FetchWithInterceptor
|
|
23
|
+
export type FetchWithInterceptor=(url: string,init?: RequestInit) => Promise<Response>;
|
|
21
24
|
|
|
22
|
-
const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions,
|
|
25
|
+
const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions,'fetchImpl'|'onUnauthorized'>>&{
|
|
23
26
|
fetchImpl: FetchWithInterceptor;
|
|
24
|
-
onUnauthorized: (loginPath: string,
|
|
25
|
-
}
|
|
27
|
+
onUnauthorized: (loginPath: string,returnTo: string) => void;
|
|
28
|
+
}={
|
|
26
29
|
loginPath: '/auth/login',
|
|
27
30
|
forbiddenMessage: 'Forbidden: 该操作已被安全策略拒绝',
|
|
28
31
|
csrfProtection: true,
|
|
29
32
|
get fetchImpl() {
|
|
30
|
-
if
|
|
31
|
-
return (url: string,
|
|
33
|
+
if(typeof globalThis!=='undefined'&&typeof globalThis.fetch==='function') {
|
|
34
|
+
return (url: string,init?: RequestInit) => globalThis.fetch(url,init);
|
|
32
35
|
}
|
|
33
36
|
return async () => {
|
|
34
37
|
throw new Error('No fetch implementation found. Pass fetchImpl in options.');
|
|
35
38
|
};
|
|
36
39
|
},
|
|
37
|
-
onUnauthorized: (loginPath,
|
|
38
|
-
if
|
|
39
|
-
window.location.href
|
|
40
|
+
onUnauthorized: (loginPath,returnTo) => {
|
|
41
|
+
if(typeof window!=='undefined') {
|
|
42
|
+
window.location.href=`${loginPath}?returnTo=${returnTo}`;
|
|
40
43
|
}
|
|
41
44
|
},
|
|
42
45
|
};
|
|
43
46
|
|
|
44
|
-
function asRecord(value: unknown): Record<string,
|
|
45
|
-
return
|
|
46
|
-
? value as Record<string, unknown>
|
|
47
|
-
: null;
|
|
47
|
+
function asRecord(value: unknown): Record<string,unknown>|null {
|
|
48
|
+
return Value.Check(Type.Record(Type.String(),Type.Unknown()),value)? value:null;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function asStructuredHttpError(error: unknown): HttpError
|
|
51
|
-
const record
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
function asStructuredHttpError(error: unknown): HttpError|null {
|
|
52
|
+
const record=asRecord(error);
|
|
53
|
+
const status=record?.['statusCode'];
|
|
54
|
+
if(!record||typeof status!=='number'||!Number.isInteger(status)||status<100||status>599) return null;
|
|
55
|
+
const message=typeof record['message']==='string'? record['message']:'HTTP request failed';
|
|
56
|
+
const errors=asValidationErrors(record['errors']);
|
|
57
|
+
return new HttpError(message,status,errors,definedOptions({
|
|
58
|
+
code: typeof record['code']==='string'? record['code']:undefined,
|
|
59
|
+
details: record['details'],
|
|
60
|
+
body: record['body'],
|
|
59
61
|
cause: error,
|
|
60
|
-
});
|
|
62
|
+
}));
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
function firstString(...values: unknown[]): string
|
|
64
|
-
return values.find((value): value is string => typeof value
|
|
65
|
+
function firstString(...values: unknown[]): string|undefined {
|
|
66
|
+
return values.find((value): value is string => typeof value==='string');
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
function asValidationErrors(value: unknown): ValidationErrors
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const valid = Object.values(record).every((entry) => (
|
|
72
|
-
typeof entry === 'string'
|
|
73
|
-
|| (Array.isArray(entry) && entry.every((item) => typeof item === 'string'))
|
|
74
|
-
));
|
|
75
|
-
return valid ? record as ValidationErrors : undefined;
|
|
69
|
+
function asValidationErrors(value: unknown): ValidationErrors|undefined {
|
|
70
|
+
const schema=Type.Record(Type.String(),Type.Union([Type.String(),Type.Array(Type.String())]));
|
|
71
|
+
return Value.Check(schema,value)? value:undefined;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
async function readErrorBody(response: Response): Promise<unknown> {
|
|
79
|
-
const text
|
|
80
|
-
if
|
|
75
|
+
const text=await response.text();
|
|
76
|
+
if(!text) return undefined;
|
|
81
77
|
try {
|
|
82
|
-
|
|
78
|
+
const parsed: unknown=JSON.parse(text);
|
|
79
|
+
return parsed;
|
|
83
80
|
} catch {
|
|
84
81
|
return text;
|
|
85
82
|
}
|
|
@@ -89,81 +86,85 @@ async function createResponseError(
|
|
|
89
86
|
response: Response,
|
|
90
87
|
forbiddenMessage: string,
|
|
91
88
|
): Promise<HttpError> {
|
|
92
|
-
const body
|
|
93
|
-
const record
|
|
94
|
-
const nestedError
|
|
95
|
-
const code
|
|
89
|
+
const body=await readErrorBody(response);
|
|
90
|
+
const record=asRecord(body);
|
|
91
|
+
const nestedError=asRecord(record?.['error']);
|
|
92
|
+
const code=response.headers.get('X-Svadmin-Auth-Retry')==='exhausted'
|
|
96
93
|
? 'auth_retry_exhausted'
|
|
97
|
-
:
|
|
98
|
-
record?.code,
|
|
99
|
-
record?.error_code,
|
|
100
|
-
nestedError?.code,
|
|
101
|
-
nestedError?.error_code,
|
|
102
|
-
record?.error,
|
|
94
|
+
:firstString(
|
|
95
|
+
record?.['code'],
|
|
96
|
+
record?.['error_code'],
|
|
97
|
+
nestedError?.['code'],
|
|
98
|
+
nestedError?.['error_code'],
|
|
99
|
+
record?.['error'],
|
|
103
100
|
);
|
|
104
|
-
const details
|
|
105
|
-
const message
|
|
106
|
-
record?.message,
|
|
107
|
-
nestedError?.message,
|
|
108
|
-
record?.error_description,
|
|
109
|
-
nestedError?.error_description,
|
|
101
|
+
const details=record?.['details']??nestedError?.['details'];
|
|
102
|
+
const message=firstString(
|
|
103
|
+
record?.['message'],
|
|
104
|
+
nestedError?.['message'],
|
|
105
|
+
record?.['error_description'],
|
|
106
|
+
nestedError?.['error_description'],
|
|
110
107
|
body,
|
|
111
|
-
)
|
|
108
|
+
)??(response.status===401? 'Unauthorized':forbiddenMessage);
|
|
112
109
|
|
|
113
110
|
return new HttpError(
|
|
114
111
|
message,
|
|
115
112
|
response.status,
|
|
116
|
-
asValidationErrors(record?.errors
|
|
117
|
-
{
|
|
113
|
+
asValidationErrors(record?.['errors']??nestedError?.['errors']),
|
|
114
|
+
definedOptions({
|
|
115
|
+
code,
|
|
116
|
+
details,
|
|
117
|
+
body,
|
|
118
|
+
}),
|
|
118
119
|
);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
/**
|
|
122
|
-
*
|
|
123
|
+
* Fetch with interceptors. Core owns error semantics; the auth provider handles refresh and request replay.
|
|
123
124
|
*/
|
|
124
125
|
export function createFetchWithInterceptor(
|
|
125
|
-
options: FetchWithInterceptorOptions
|
|
126
|
+
options: FetchWithInterceptorOptions={},
|
|
126
127
|
): FetchWithInterceptor {
|
|
127
|
-
const opts
|
|
128
|
+
const opts={ ...DEFAULT_OPTIONS,...options };
|
|
128
129
|
|
|
129
|
-
return async (url: string,
|
|
130
|
-
const headers
|
|
131
|
-
if
|
|
130
|
+
return async (url: string,init: RequestInit={}): Promise<Response> => {
|
|
131
|
+
const headers=new Headers(init.headers||{});
|
|
132
|
+
if(
|
|
132
133
|
opts.csrfProtection
|
|
133
|
-
&&
|
|
134
|
-
&&
|
|
134
|
+
&&init.method
|
|
135
|
+
&&['POST','PUT','DELETE','PATCH'].includes(init.method.toUpperCase())
|
|
135
136
|
) {
|
|
136
|
-
headers.set('X-Requested-With',
|
|
137
|
+
headers.set('X-Requested-With','XMLHttpRequest');
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
let response: Response;
|
|
140
141
|
try {
|
|
141
|
-
response
|
|
142
|
-
} catch
|
|
143
|
-
if
|
|
144
|
-
const structuredError
|
|
145
|
-
if
|
|
142
|
+
response=await opts.fetchImpl(url,{ ...init,headers });
|
|
143
|
+
} catch(error) {
|
|
144
|
+
if(error instanceof HttpError) throw error;
|
|
145
|
+
const structuredError=asStructuredHttpError(error);
|
|
146
|
+
if(structuredError) throw structuredError;
|
|
146
147
|
throw error;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
if
|
|
150
|
+
if(response.ok||(response.status!==401&&response.status!==403)) {
|
|
150
151
|
return response;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
const httpError
|
|
154
|
-
if
|
|
155
|
-
const returnTo
|
|
154
|
+
const httpError=await createResponseError(response,opts.forbiddenMessage);
|
|
155
|
+
if(response.status===401) {
|
|
156
|
+
const returnTo=typeof window!=='undefined'
|
|
156
157
|
? encodeURIComponent(window.location.pathname)
|
|
157
|
-
:
|
|
158
|
+
:'';
|
|
158
159
|
try {
|
|
159
|
-
opts.onUnauthorized(opts.loginPath,
|
|
160
|
+
opts.onUnauthorized(opts.loginPath,returnTo);
|
|
160
161
|
} catch {
|
|
161
|
-
//
|
|
162
|
+
// Redirect adapter failures must not replace the server's structured 401 error.
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
throw httpError;
|
|
165
166
|
};
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
/**
|
|
169
|
-
export const fetchWithInterceptor
|
|
169
|
+
/** Convenient global instance using the default configuration. */
|
|
170
|
+
export const fetchWithInterceptor=createFetchWithInterceptor();
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -53,6 +53,31 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
53
53
|
'common.page': '{current} / {total}',
|
|
54
54
|
'common.export': '导出',
|
|
55
55
|
'common.import': '导入',
|
|
56
|
+
'common.chooseFile': '选择文件',
|
|
57
|
+
'common.noFileChosen': '未选择文件',
|
|
58
|
+
'common.filesSelected': '已选择 {count} 个文件',
|
|
59
|
+
'common.uploadFile': '上传文件',
|
|
60
|
+
'common.mapColumns': '映射列',
|
|
61
|
+
'common.importProgress': '导入与校验',
|
|
62
|
+
'common.dropFileHere': '将 CSV 或 JSON 文件拖放到这里',
|
|
63
|
+
'common.fileFormatHint': '支持 UTF-8 编码的 .csv、.json 文件',
|
|
64
|
+
'common.selectFile': '浏览文件',
|
|
65
|
+
'common.detectedRows': '已检测',
|
|
66
|
+
'common.records': '条记录',
|
|
67
|
+
'common.ignoreColumn': '忽略',
|
|
68
|
+
'common.startImport': '开始导入',
|
|
69
|
+
'common.importing': '导入中...',
|
|
70
|
+
'common.completed': '已完成',
|
|
71
|
+
'common.succeeded': '成功',
|
|
72
|
+
'common.failed': '失败',
|
|
73
|
+
'common.failedRowsHint': '条记录导入失败。',
|
|
74
|
+
'common.downloadErrors': '下载失败 CSV',
|
|
75
|
+
'common.importAnother': '再次导入',
|
|
76
|
+
'common.selectOption': '请选择',
|
|
77
|
+
'common.yesTrue': '是 / 真',
|
|
78
|
+
'common.noFalse': '否 / 假',
|
|
79
|
+
'common.startDate': '开始日期',
|
|
80
|
+
'common.endDate': '结束日期',
|
|
56
81
|
'common.clone': '克隆',
|
|
57
82
|
'common.autoSaving': '自动保存中...',
|
|
58
83
|
'common.autoSaved': '已自动保存',
|
|
@@ -115,6 +140,8 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
115
140
|
'common.saveView': '保存',
|
|
116
141
|
'common.perPage': '每页',
|
|
117
142
|
'common.copyId': '复制 ID',
|
|
143
|
+
'common.moreActions': '更多操作',
|
|
144
|
+
'common.quickEdit': '快速编辑',
|
|
118
145
|
'common.showMore': '展开更多',
|
|
119
146
|
'common.showLess': '收起',
|
|
120
147
|
'common.showPassword': '显示密码',
|
|
@@ -724,6 +751,31 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
724
751
|
'common.page': '{current} / {total}',
|
|
725
752
|
'common.export': 'Export',
|
|
726
753
|
'common.import': 'Import',
|
|
754
|
+
'common.chooseFile': 'Choose file',
|
|
755
|
+
'common.noFileChosen': 'No file chosen',
|
|
756
|
+
'common.filesSelected': '{count} files selected',
|
|
757
|
+
'common.uploadFile': 'Upload file',
|
|
758
|
+
'common.mapColumns': 'Map columns',
|
|
759
|
+
'common.importProgress': 'Import & verify',
|
|
760
|
+
'common.dropFileHere': 'Drag and drop CSV or JSON file here',
|
|
761
|
+
'common.fileFormatHint': 'Supports .csv, .json with UTF-8 encoding',
|
|
762
|
+
'common.selectFile': 'Browse file',
|
|
763
|
+
'common.detectedRows': 'Detected',
|
|
764
|
+
'common.records': 'records',
|
|
765
|
+
'common.ignoreColumn': 'Ignore',
|
|
766
|
+
'common.startImport': 'Start import',
|
|
767
|
+
'common.importing': 'Importing...',
|
|
768
|
+
'common.completed': 'Completed',
|
|
769
|
+
'common.succeeded': 'Succeeded',
|
|
770
|
+
'common.failed': 'Failed',
|
|
771
|
+
'common.failedRowsHint': 'records failed to import.',
|
|
772
|
+
'common.downloadErrors': 'Download failed CSV',
|
|
773
|
+
'common.importAnother': 'Import another',
|
|
774
|
+
'common.selectOption': 'Select an option...',
|
|
775
|
+
'common.yesTrue': 'Yes / True',
|
|
776
|
+
'common.noFalse': 'No / False',
|
|
777
|
+
'common.startDate': 'Start date',
|
|
778
|
+
'common.endDate': 'End date',
|
|
727
779
|
'common.clone': 'Clone',
|
|
728
780
|
'common.autoSaving': 'Auto-saving...',
|
|
729
781
|
'common.autoSaved': 'Saved',
|
|
@@ -786,6 +838,8 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
786
838
|
'common.saveView': 'Save',
|
|
787
839
|
'common.perPage': 'per page',
|
|
788
840
|
'common.copyId': 'Copy ID',
|
|
841
|
+
'common.moreActions': 'More actions',
|
|
842
|
+
'common.quickEdit': 'Quick edit',
|
|
789
843
|
'common.showMore': 'Show more',
|
|
790
844
|
'common.showLess': 'Show less',
|
|
791
845
|
'common.showPassword': 'Show password',
|
|
@@ -1355,7 +1409,7 @@ function detectLocale(): string {
|
|
|
1355
1409
|
if (typeof navigator === 'undefined') return 'en';
|
|
1356
1410
|
const browserLang = navigator.language || navigator.languages?.[0] || 'zh-CN';
|
|
1357
1411
|
if (locales[browserLang]) return browserLang;
|
|
1358
|
-
const prefix = browserLang.split('-')
|
|
1412
|
+
const [prefix = browserLang] = browserLang.split('-');
|
|
1359
1413
|
for (const key of Object.keys(locales)) {
|
|
1360
1414
|
if (key === browserLang || key.startsWith(prefix + '-')) return key;
|
|
1361
1415
|
}
|
|
@@ -1407,7 +1461,7 @@ function translateFromTables(
|
|
|
1407
1461
|
params?: Record<string, string | number>,
|
|
1408
1462
|
): string {
|
|
1409
1463
|
const localeTable = locales[locale];
|
|
1410
|
-
let text = localeTable?.[key] ?? locales
|
|
1464
|
+
let text = localeTable?.[key] ?? locales['en']?.[key] ?? key;
|
|
1411
1465
|
|
|
1412
1466
|
if (params) {
|
|
1413
1467
|
for (const [param, value] of Object.entries(params)) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import Papa from 'papaparse';
|
|
2
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
import { snapshotPlainData } from './plain-data';
|
|
5
|
+
import { HttpError } from './types';
|
|
6
|
+
import { parseContractId, parseContractRecord, type ContractSchemas, type ResourceContract } from './resource-contract';
|
|
7
|
+
import { decodeOneResult, decodeManyResult, rejectProviderResponse } from './record-decoder';
|
|
8
|
+
|
|
9
|
+
export type ImportFormat = 'auto' | 'csv' | 'json';
|
|
10
|
+
const progressSchema = Type.Object({
|
|
11
|
+
totalAmount: Type.Integer({ minimum: 0 }),
|
|
12
|
+
attemptedAmount: Type.Integer({ minimum: 0 }),
|
|
13
|
+
processedAmount: Type.Integer({ minimum: 0 }),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
export type ImportOperationProgress = Static<typeof progressSchema>;
|
|
16
|
+
|
|
17
|
+
export function newImportProgress(): ImportOperationProgress {
|
|
18
|
+
return { totalAmount: 0, attemptedAmount: 0, processedAmount: 0 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function snapshotImportProgress(value: unknown): ImportOperationProgress {
|
|
22
|
+
const progress = snapshotPlainData(value);
|
|
23
|
+
if (!checkExact(progressSchema, progress) || progress.processedAmount > progress.attemptedAmount ||
|
|
24
|
+
progress.attemptedAmount > progress.totalAmount ||
|
|
25
|
+
!Object.values(progress).every(Number.isSafeInteger)) throw new HttpError('Invalid import progress', 500);
|
|
26
|
+
return progress;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function invalidImportInput(row?: number): never {
|
|
30
|
+
throw new HttpError('Invalid import input', 422, undefined, {
|
|
31
|
+
code: 'INVALID_RESOURCE_INPUT',
|
|
32
|
+
details: { phase: 'input', writeMayHaveSucceeded: false, ...(row === undefined ? {} : { row }) },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Files are the only non-JSON input; do not invoke caller-supplied getters or text methods. */
|
|
37
|
+
export function snapshotImportFile(value: unknown) {
|
|
38
|
+
try {
|
|
39
|
+
if (typeof value !== 'object' || value === null ||
|
|
40
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value)) ||
|
|
41
|
+
Reflect.ownKeys(value).length !== 1) return invalidImportInput();
|
|
42
|
+
const property = Object.getOwnPropertyDescriptor(value, 'file');
|
|
43
|
+
const file: unknown = property && 'value' in property ? property.value : undefined;
|
|
44
|
+
if (!(file instanceof File)) return invalidImportInput();
|
|
45
|
+
const ownName = Object.getOwnPropertyDescriptor(file, 'name');
|
|
46
|
+
if (ownName && !('value' in ownName)) return invalidImportInput();
|
|
47
|
+
const name: unknown = ownName ? ownName.value : Object.getOwnPropertyDescriptor(File.prototype, 'name')?.get?.call(file);
|
|
48
|
+
if (typeof name !== 'string') return invalidImportInput();
|
|
49
|
+
const read = Blob.prototype.text.bind(file);
|
|
50
|
+
return { file, name, read };
|
|
51
|
+
} catch {
|
|
52
|
+
return invalidImportInput();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rowsSchema = Type.Array(Type.Record(Type.String(), Type.Unknown()));
|
|
57
|
+
const csvSchema = Type.Array(Type.Array(Type.String()));
|
|
58
|
+
|
|
59
|
+
/** CSV values stay strings; only an explicit mapper may supply business conversions. */
|
|
60
|
+
export function parseImportRows(text: string, name: string, format: ImportFormat): Record<string, unknown>[] {
|
|
61
|
+
try {
|
|
62
|
+
const source = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
63
|
+
const selected = format === 'auto' ? name.toLowerCase().endsWith('.json') ? 'json'
|
|
64
|
+
: name.toLowerCase().endsWith('.csv') ? 'csv' : undefined : format;
|
|
65
|
+
if (selected === 'json') {
|
|
66
|
+
const data = snapshotPlainData(JSON.parse(source));
|
|
67
|
+
return checkExact(rowsSchema, data) ? data : invalidImportInput();
|
|
68
|
+
}
|
|
69
|
+
if (selected !== 'csv') return invalidImportInput();
|
|
70
|
+
const parsed = Papa.parse<unknown>(source, { delimiter: ',', dynamicTyping: false, skipEmptyLines: true });
|
|
71
|
+
const data = snapshotPlainData(parsed.data);
|
|
72
|
+
if (parsed.errors.length || !checkExact(csvSchema, data)) return invalidImportInput();
|
|
73
|
+
const [headers, ...rows] = data;
|
|
74
|
+
if (!headers) return [];
|
|
75
|
+
if (headers.some(header => header.trim() === '') || new Set(headers).size !== headers.length) return invalidImportInput();
|
|
76
|
+
return rows.map((row, index) => {
|
|
77
|
+
if (row.length !== headers.length) return invalidImportInput(index + 1);
|
|
78
|
+
return Object.fromEntries(headers.map((header, column) => [header, row[column]]));
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return invalidImportInput();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function decodeImportReceipt<S extends ContractSchemas>(
|
|
86
|
+
contract: ResourceContract<S>, response: unknown, inputs: readonly unknown[],
|
|
87
|
+
batch: boolean, seen: ReadonlySet<string | number>,
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
const receipt = snapshotPlainData(response);
|
|
91
|
+
const rows = batch
|
|
92
|
+
? decodeManyResult(receipt, value => parseContractRecord(contract, value), true).data
|
|
93
|
+
: [decodeOneResult(receipt, value => parseContractRecord(contract, value), true).data];
|
|
94
|
+
if (rows.length !== inputs.length || new Set(rows.map(row => row.id)).size !== rows.length) rejectProviderResponse(true);
|
|
95
|
+
for (const [index, row] of rows.entries()) {
|
|
96
|
+
const recordId = parseContractId(contract, row.id);
|
|
97
|
+
const submitted = inputs[index];
|
|
98
|
+
const id: unknown = typeof submitted === 'object' && submitted !== null
|
|
99
|
+
? Object.getOwnPropertyDescriptor(submitted, 'id')?.value : undefined;
|
|
100
|
+
if ((id !== undefined && id !== recordId) || seen.has(recordId)) rejectProviderResponse(true);
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
} catch {
|
|
104
|
+
return rejectProviderResponse(true);
|
|
105
|
+
}
|
|
106
|
+
}
|