@svadmin/core 0.50.0 → 0.53.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 (98) 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 +64 -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/inferencer.ts +9 -0
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -288
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +220 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +103 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +190 -180
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +68 -110
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
  98. package/src/schema-form.ts +0 -31
package/src/http.ts CHANGED
@@ -1,85 +1,82 @@
1
1
  /**
2
- * HTTP fetch 工具 — 带 CSRF 防护、401 自动跳转和结构化错误语义。
2
+ * HTTP fetch utilities with CSRF protection, automatic 401 redirects, and structured error semantics.
3
3
  */
4
4
 
5
- import { HttpError, type ValidationErrors } from './types';
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
- /** 是否自动添加 X-Requested-With header (CSRF 防护) */
15
+ /** Whether to add the X-Requested-With header automatically for CSRF protection. */
13
16
  csrfProtection?: boolean;
14
- /** 自定义 fetch 实现,便于测试或 SSR 环境注入 */
17
+ /** Custom fetch implementation for testing or SSR injection. */
15
18
  fetchImpl?: FetchWithInterceptor;
16
- /** 自定义 401 重定向处理,便于测试或 SSR 环境注入 */
17
- onUnauthorized?: (loginPath: string, returnTo: string) => void;
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 = (url: string, init?: RequestInit) => Promise<Response>;
23
+ export type FetchWithInterceptor=(url: string,init?: RequestInit) => Promise<Response>;
21
24
 
22
- const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions, 'fetchImpl' | 'onUnauthorized'>> & {
25
+ const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions,'fetchImpl'|'onUnauthorized'>>&{
23
26
  fetchImpl: FetchWithInterceptor;
24
- onUnauthorized: (loginPath: string, returnTo: string) => void;
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 (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') {
31
- return (url: string, init?: RequestInit) => globalThis.fetch(url, init);
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, returnTo) => {
38
- if (typeof window !== 'undefined') {
39
- window.location.href = `${loginPath}?returnTo=${returnTo}`;
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, unknown> | null {
45
- return typeof value === 'object' && value !== null
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 | null {
51
- const record = asRecord(error);
52
- if (!record || typeof record.statusCode !== 'number') return null;
53
- const message = typeof record.message === 'string' ? record.message : 'HTTP request failed';
54
- const errors = asValidationErrors(record.errors);
55
- return new HttpError(message, record.statusCode, errors, {
56
- code: typeof record.code === 'string' ? record.code : undefined,
57
- details: record.details,
58
- body: record.body,
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 | undefined {
64
- return values.find((value): value is string => typeof value === 'string');
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 | undefined {
68
- const record = asRecord(value);
69
- if (!record) return undefined;
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 = await response.text();
80
- if (!text) return undefined;
75
+ const text=await response.text();
76
+ if(!text) return undefined;
81
77
  try {
82
- return JSON.parse(text) as unknown;
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 = await readErrorBody(response);
93
- const record = asRecord(body);
94
- const nestedError = asRecord(record?.error);
95
- const code = response.headers.get('X-Svadmin-Auth-Retry') === 'exhausted'
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
- : firstString(
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 = record?.details ?? nestedError?.details;
105
- const message = firstString(
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
- ) ?? (response.status === 401 ? 'Unauthorized' : forbiddenMessage);
108
+ )??(response.status===401? 'Unauthorized':forbiddenMessage);
112
109
 
113
110
  return new HttpError(
114
111
  message,
115
112
  response.status,
116
- asValidationErrors(record?.errors ?? nestedError?.errors),
117
- { code, details, body },
113
+ asValidationErrors(record?.['errors']??nestedError?.['errors']),
114
+ definedOptions({
115
+ code,
116
+ details,
117
+ body,
118
+ }),
118
119
  );
119
120
  }
120
121
 
121
122
  /**
122
- * 带拦截器的 fetch。Core 只保留错误语义;认证刷新和请求重放由认证提供方处理。
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 = { ...DEFAULT_OPTIONS, ...options };
128
+ const opts={ ...DEFAULT_OPTIONS,...options };
128
129
 
129
- return async (url: string, init: RequestInit = {}): Promise<Response> => {
130
- const headers = new Headers(init.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
- && init.method
134
- && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(init.method.toUpperCase())
134
+ &&init.method
135
+ &&['POST','PUT','DELETE','PATCH'].includes(init.method.toUpperCase())
135
136
  ) {
136
- headers.set('X-Requested-With', 'XMLHttpRequest');
137
+ headers.set('X-Requested-With','XMLHttpRequest');
137
138
  }
138
139
 
139
140
  let response: Response;
140
141
  try {
141
- response = await opts.fetchImpl(url, { ...init, headers });
142
- } catch (error) {
143
- if (error instanceof HttpError) throw error;
144
- const structuredError = asStructuredHttpError(error);
145
- if (structuredError) throw structuredError;
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 (response.ok || (response.status !== 401 && response.status !== 403)) {
150
+ if(response.ok||(response.status!==401&&response.status!==403)) {
150
151
  return response;
151
152
  }
152
153
 
153
- const httpError = await createResponseError(response, opts.forbiddenMessage);
154
- if (response.status === 401) {
155
- const returnTo = typeof window !== 'undefined'
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, returnTo);
160
+ opts.onUnauthorized(opts.loginPath,returnTo);
160
161
  } catch {
161
- // 重定向适配器失败不能覆盖服务端的结构化 401 错误。
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 = createFetchWithInterceptor();
169
+ /** Convenient global instance using the default configuration. */
170
+ export const fetchWithInterceptor=createFetchWithInterceptor();
@@ -18,9 +18,13 @@ const baseLocales: Record<string, Record<string, string>> = {
18
18
  'common.moveUp': '上移',
19
19
  'common.moveDown': '下移',
20
20
  'common.treeSelect': '树形选择',
21
+ 'tree.toggleChildren': '展开或折叠子节点',
22
+ 'tree.loadFailed': '子节点加载失败',
21
23
  'common.cascader': '级联选择',
22
24
  'common.transfer': '穿梭框',
23
25
  'common.filterBuilder': '高级筛选',
26
+ 'filter.incomplete': '请补全筛选条件后再应用。',
27
+ 'filter.readonly': '只读筛选条件',
24
28
  'common.addRule': '添加筛选条件',
25
29
  'common.sourceList': '源列表',
26
30
  'common.targetList': '目标列表',
@@ -53,6 +57,31 @@ const baseLocales: Record<string, Record<string, string>> = {
53
57
  'common.page': '{current} / {total}',
54
58
  'common.export': '导出',
55
59
  'common.import': '导入',
60
+ 'common.chooseFile': '选择文件',
61
+ 'common.noFileChosen': '未选择文件',
62
+ 'common.filesSelected': '已选择 {count} 个文件',
63
+ 'common.uploadFile': '上传文件',
64
+ 'common.mapColumns': '映射列',
65
+ 'common.importProgress': '导入与校验',
66
+ 'common.dropFileHere': '将 CSV 或 JSON 文件拖放到这里',
67
+ 'common.fileFormatHint': '支持 UTF-8 编码的 .csv、.json 文件',
68
+ 'common.selectFile': '浏览文件',
69
+ 'common.detectedRows': '已检测',
70
+ 'common.records': '条记录',
71
+ 'common.ignoreColumn': '忽略',
72
+ 'common.startImport': '开始导入',
73
+ 'common.importing': '导入中...',
74
+ 'common.completed': '已完成',
75
+ 'common.succeeded': '成功',
76
+ 'common.failed': '失败',
77
+ 'common.failedRowsHint': '条记录导入失败。',
78
+ 'common.downloadErrors': '下载失败 CSV',
79
+ 'common.importAnother': '再次导入',
80
+ 'common.selectOption': '请选择',
81
+ 'common.yesTrue': '是 / 真',
82
+ 'common.noFalse': '否 / 假',
83
+ 'common.startDate': '开始日期',
84
+ 'common.endDate': '结束日期',
56
85
  'common.clone': '克隆',
57
86
  'common.autoSaving': '自动保存中...',
58
87
  'common.autoSaved': '已自动保存',
@@ -115,6 +144,8 @@ const baseLocales: Record<string, Record<string, string>> = {
115
144
  'common.saveView': '保存',
116
145
  'common.perPage': '每页',
117
146
  'common.copyId': '复制 ID',
147
+ 'common.moreActions': '更多操作',
148
+ 'common.quickEdit': '快速编辑',
118
149
  'common.showMore': '展开更多',
119
150
  'common.showLess': '收起',
120
151
  'common.showPassword': '显示密码',
@@ -689,9 +720,13 @@ const baseLocales: Record<string, Record<string, string>> = {
689
720
  'common.moveUp': 'Move Up',
690
721
  'common.moveDown': 'Move Down',
691
722
  'common.treeSelect': 'Tree Select',
723
+ 'tree.toggleChildren': 'Expand or collapse children',
724
+ 'tree.loadFailed': 'Failed to load child nodes',
692
725
  'common.cascader': 'Cascader',
693
726
  'common.transfer': 'Transfer',
694
727
  'common.filterBuilder': 'Filter Builder',
728
+ 'filter.incomplete': 'Complete the filter conditions before applying.',
729
+ 'filter.readonly': 'Read-only filter condition',
695
730
  'common.addRule': 'Add Filter Rule',
696
731
  'common.sourceList': 'Source',
697
732
  'common.targetList': 'Target',
@@ -724,6 +759,31 @@ const baseLocales: Record<string, Record<string, string>> = {
724
759
  'common.page': '{current} / {total}',
725
760
  'common.export': 'Export',
726
761
  'common.import': 'Import',
762
+ 'common.chooseFile': 'Choose file',
763
+ 'common.noFileChosen': 'No file chosen',
764
+ 'common.filesSelected': '{count} files selected',
765
+ 'common.uploadFile': 'Upload file',
766
+ 'common.mapColumns': 'Map columns',
767
+ 'common.importProgress': 'Import & verify',
768
+ 'common.dropFileHere': 'Drag and drop CSV or JSON file here',
769
+ 'common.fileFormatHint': 'Supports .csv, .json with UTF-8 encoding',
770
+ 'common.selectFile': 'Browse file',
771
+ 'common.detectedRows': 'Detected',
772
+ 'common.records': 'records',
773
+ 'common.ignoreColumn': 'Ignore',
774
+ 'common.startImport': 'Start import',
775
+ 'common.importing': 'Importing...',
776
+ 'common.completed': 'Completed',
777
+ 'common.succeeded': 'Succeeded',
778
+ 'common.failed': 'Failed',
779
+ 'common.failedRowsHint': 'records failed to import.',
780
+ 'common.downloadErrors': 'Download failed CSV',
781
+ 'common.importAnother': 'Import another',
782
+ 'common.selectOption': 'Select an option...',
783
+ 'common.yesTrue': 'Yes / True',
784
+ 'common.noFalse': 'No / False',
785
+ 'common.startDate': 'Start date',
786
+ 'common.endDate': 'End date',
727
787
  'common.clone': 'Clone',
728
788
  'common.autoSaving': 'Auto-saving...',
729
789
  'common.autoSaved': 'Saved',
@@ -786,6 +846,8 @@ const baseLocales: Record<string, Record<string, string>> = {
786
846
  'common.saveView': 'Save',
787
847
  'common.perPage': 'per page',
788
848
  'common.copyId': 'Copy ID',
849
+ 'common.moreActions': 'More actions',
850
+ 'common.quickEdit': 'Quick edit',
789
851
  'common.showMore': 'Show more',
790
852
  'common.showLess': 'Show less',
791
853
  'common.showPassword': 'Show password',
@@ -1355,7 +1417,7 @@ function detectLocale(): string {
1355
1417
  if (typeof navigator === 'undefined') return 'en';
1356
1418
  const browserLang = navigator.language || navigator.languages?.[0] || 'zh-CN';
1357
1419
  if (locales[browserLang]) return browserLang;
1358
- const prefix = browserLang.split('-')[0];
1420
+ const [prefix = browserLang] = browserLang.split('-');
1359
1421
  for (const key of Object.keys(locales)) {
1360
1422
  if (key === browserLang || key.startsWith(prefix + '-')) return key;
1361
1423
  }
@@ -1407,7 +1469,7 @@ function translateFromTables(
1407
1469
  params?: Record<string, string | number>,
1408
1470
  ): string {
1409
1471
  const localeTable = locales[locale];
1410
- let text = localeTable?.[key] ?? locales.en?.[key] ?? key;
1472
+ let text = localeTable?.[key] ?? locales['en']?.[key] ?? key;
1411
1473
 
1412
1474
  if (params) {
1413
1475
  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
+ }