dsh-lh-data 0.1.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 (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. package/src/view.ts +371 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * 设置页管理接口的入参校验(纯函数,无 IO)。
3
+ *
4
+ * 所有校验失败都抛 `AdminValidationError`,由 `admin-http.ts` 统一转成 400。
5
+ * 校验目标:把浏览器传来的任意字符串收口成「类型正确、长度受控、范围合法」
6
+ * 的值——写库前最后一道闸门,尤其要防止凭空造工作区、列结构越界、字段溢出。
7
+ */
8
+
9
+ import { deduplicateColumnNames, sanitizeColumnName, type ColumnInfo } from './parse'
10
+ import type { DataSourceInput, DataSourcePatch, DataSourceType } from './datasource/types'
11
+ import type { ConnectionDraft } from './datasource/connection'
12
+ import {
13
+ ADMIN_COLUMN_TYPES,
14
+ ADMIN_MAX_COLUMNS,
15
+ ADMIN_MAX_DATABASE_LENGTH,
16
+ ADMIN_MAX_DESCRIPTION_LENGTH,
17
+ ADMIN_MAX_HOST_LENGTH,
18
+ ADMIN_MAX_NAME_LENGTH,
19
+ ADMIN_MAX_PAGE_SIZE,
20
+ ADMIN_MAX_PASSWORD_LENGTH,
21
+ ADMIN_MAX_POOL_MAX,
22
+ ADMIN_MAX_PORT,
23
+ ADMIN_MAX_QUERY_LENGTH,
24
+ ADMIN_MAX_SAMPLE_LENGTH,
25
+ ADMIN_MAX_SAMPLE_VALUES,
26
+ ADMIN_MAX_SOURCE_LENGTH,
27
+ ADMIN_MAX_SOURCE_NAME_LENGTH,
28
+ ADMIN_MAX_USERNAME_LENGTH,
29
+ ADMIN_MIN_PORT,
30
+ ADMIN_PAGE_SIZE,
31
+ ADMIN_ROW_MAX_PAGE_SIZE,
32
+ ADMIN_ROW_PAGE_SIZE,
33
+ ADMIN_SOURCE_DEFAULT_PORTS,
34
+ ADMIN_SOURCE_TYPES,
35
+ type AdminColumnType,
36
+ type AdminErrorCode,
37
+ } from './admin-contract'
38
+
39
+ /** 校验失败:对应 HTTP 400。 */
40
+ export class AdminValidationError extends Error {
41
+ constructor(
42
+ public readonly code: AdminErrorCode,
43
+ message: string,
44
+ ) {
45
+ super(message)
46
+ this.name = 'AdminValidationError'
47
+ }
48
+ }
49
+
50
+ /** 一个可空、可缺省的文本字段(描述 / 来源路径)。 */
51
+ function validateOptionalText(value: unknown, max: number, field: string): string | null {
52
+ if (value === undefined || value === null) return null
53
+ if (typeof value !== 'string') throw new AdminValidationError('BAD_REQUEST', `${field}必须是字符串`)
54
+ const trimmed = value.trim()
55
+ if (trimmed.length === 0) return null
56
+ if (trimmed.length > max) throw new AdminValidationError('BAD_REQUEST', `${field}不超过 ${max} 字`)
57
+ return trimmed
58
+ }
59
+
60
+ export function validateScopeKey(value: unknown): string {
61
+ if (typeof value !== 'string' || value.trim().length === 0) {
62
+ throw new AdminValidationError('BAD_REQUEST', '缺少工作区标识(scopeKey)')
63
+ }
64
+ return value.trim()
65
+ }
66
+
67
+ export function validateDatasetName(value: unknown): string {
68
+ if (typeof value !== 'string' || value.trim().length === 0) {
69
+ throw new AdminValidationError('BAD_REQUEST', '数据集名称不能为空')
70
+ }
71
+ const name = value.trim()
72
+ if (name.length > ADMIN_MAX_NAME_LENGTH) {
73
+ throw new AdminValidationError('BAD_REQUEST', `名称不超过 ${ADMIN_MAX_NAME_LENGTH} 字`)
74
+ }
75
+ return name
76
+ }
77
+
78
+ export function validateDescription(value: unknown): string | null {
79
+ return validateOptionalText(value, ADMIN_MAX_DESCRIPTION_LENGTH, '描述')
80
+ }
81
+
82
+ export function validateSourcePath(value: unknown): string | null {
83
+ return validateOptionalText(value, ADMIN_MAX_SOURCE_LENGTH, '来源路径')
84
+ }
85
+
86
+ /**
87
+ * 校验列定义:非空数组、数量受控、类型白名单、列名消毒并去重。
88
+ * 返回可直接交给 `createDatasetTable` 的 `ColumnInfo[]`。
89
+ */
90
+ export function validateColumnSpecs(raw: unknown): ColumnInfo[] {
91
+ if (!Array.isArray(raw) || raw.length === 0) {
92
+ throw new AdminValidationError('INVALID_COLUMNS', '至少需要定义一列')
93
+ }
94
+ if (raw.length > ADMIN_MAX_COLUMNS) {
95
+ throw new AdminValidationError('INVALID_COLUMNS', `列数不超过 ${ADMIN_MAX_COLUMNS}`)
96
+ }
97
+ const columns: ColumnInfo[] = raw.map((item, index) => {
98
+ if (typeof item !== 'object' || item === null) {
99
+ throw new AdminValidationError('INVALID_COLUMNS', `第 ${index + 1} 列格式错误`)
100
+ }
101
+ const spec = item as Record<string, unknown>
102
+ const name = typeof spec.name === 'string' ? spec.name.trim() : ''
103
+ if (name.length === 0) throw new AdminValidationError('INVALID_COLUMNS', `第 ${index + 1} 列缺少列名`)
104
+ if (!ADMIN_COLUMN_TYPES.includes(spec.type as AdminColumnType)) {
105
+ throw new AdminValidationError('INVALID_COLUMNS', `第 ${index + 1} 列类型非法`)
106
+ }
107
+ const description = typeof spec.description === 'string' ? spec.description.slice(0, ADMIN_MAX_DESCRIPTION_LENGTH) : ''
108
+ return {
109
+ name,
110
+ sanitizedName: sanitizeColumnName(name),
111
+ type: spec.type as AdminColumnType,
112
+ nullable: spec.nullable === true,
113
+ sample: [],
114
+ description,
115
+ }
116
+ })
117
+ deduplicateColumnNames(columns)
118
+ return columns
119
+ }
120
+
121
+ /** 样例值只允许 JSON 标量(columns 是 TEXT 列,不能塞对象 / NaN)。 */
122
+ function sanitizeSampleValue(value: unknown): string | number | boolean | null {
123
+ if (value === null) return null
124
+ if (typeof value === 'string') return value.slice(0, ADMIN_MAX_SAMPLE_LENGTH)
125
+ if (typeof value === 'boolean') return value
126
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
127
+ return String(value)
128
+ }
129
+
130
+ /**
131
+ * 校验列的「说明 / 样例」补丁,返回合并后的完整 `ColumnInfo[]`。
132
+ *
133
+ * 只从补丁里读 `description` 与 `sample` 两项,其余字段(name / sanitizedName /
134
+ * type / nullable)一律沿用既有列——它们对应物理表 DDL,设置页改不了。
135
+ * 按 `sanitizedName` 定位列:原始表头 `name` 在重名列上会重复,不能当键。
136
+ */
137
+ export function validateColumnPatches(raw: unknown, existing: ColumnInfo[]): ColumnInfo[] {
138
+ if (!Array.isArray(raw)) {
139
+ throw new AdminValidationError('INVALID_COLUMNS', '列补丁必须是数组')
140
+ }
141
+ const merged = existing.map(column => ({ ...column }))
142
+ for (const entry of raw) {
143
+ if (typeof entry !== 'object' || entry === null) {
144
+ throw new AdminValidationError('INVALID_COLUMNS', '列补丁格式错误')
145
+ }
146
+ const patch = entry as Record<string, unknown>
147
+ const key = typeof patch.sanitizedName === 'string' ? patch.sanitizedName : ''
148
+ const target = merged.find(column => column.sanitizedName === key)
149
+ if (target === undefined) {
150
+ throw new AdminValidationError('INVALID_COLUMNS', `未找到列:${key}`)
151
+ }
152
+ if (patch.description !== undefined) {
153
+ if (typeof patch.description !== 'string') {
154
+ throw new AdminValidationError('INVALID_COLUMNS', `列「${key}」的说明必须是字符串`)
155
+ }
156
+ target.description = patch.description.slice(0, ADMIN_MAX_DESCRIPTION_LENGTH)
157
+ }
158
+ if (patch.sample !== undefined) {
159
+ if (!Array.isArray(patch.sample)) {
160
+ throw new AdminValidationError('INVALID_COLUMNS', `列「${key}」的样例必须是数组`)
161
+ }
162
+ target.sample = patch.sample.slice(0, ADMIN_MAX_SAMPLE_VALUES).map(sanitizeSampleValue)
163
+ }
164
+ }
165
+ return merged
166
+ }
167
+
168
+ function clampInt(raw: unknown, fallback: number, min: number, max: number): number {
169
+ const parsed = typeof raw === 'string' ? Number.parseInt(raw, 10) : NaN
170
+ if (!Number.isFinite(parsed)) return fallback
171
+ return Math.min(max, Math.max(min, parsed))
172
+ }
173
+
174
+ export interface ParsedListQuery {
175
+ q: string
176
+ page: number
177
+ pageSize: number
178
+ }
179
+
180
+ /** 解析并夹紧列表查询参数(关键字裁剪、页码与页大小封顶)。 */
181
+ export function parseListQuery(params: Record<string, string | undefined>): ParsedListQuery {
182
+ const q = typeof params.q === 'string' ? params.q.trim().slice(0, ADMIN_MAX_QUERY_LENGTH) : ''
183
+ const page = clampInt(params.page, 1, 1, 100000)
184
+ const pageSize = clampInt(params.pageSize, ADMIN_PAGE_SIZE, 1, ADMIN_MAX_PAGE_SIZE)
185
+ return { q, page, pageSize }
186
+ }
187
+
188
+ export interface ParsedRowsQuery {
189
+ page: number
190
+ pageSize: number
191
+ }
192
+
193
+ /**
194
+ * 解析并夹紧表数据分页参数。页码上限只是防御(真实边界由 `COUNT(*)` 决定),
195
+ * 页大小封顶避免一次捞走整表。
196
+ */
197
+ export function parseRowsQuery(params: Record<string, string | undefined>): ParsedRowsQuery {
198
+ const page = clampInt(params.page, 1, 1, 100000)
199
+ const pageSize = clampInt(params.pageSize, ADMIN_ROW_PAGE_SIZE, 1, ADMIN_ROW_MAX_PAGE_SIZE)
200
+ return { page, pageSize }
201
+ }
202
+
203
+ // ── 数据源 ───────────────────────────────────────────────────────────────
204
+
205
+ /** 必填文本:trim 后非空、长度受控。 */
206
+ function requireText(value: unknown, max: number, field: string): string {
207
+ if (typeof value !== 'string' || value.trim().length === 0) {
208
+ throw new AdminValidationError('BAD_REQUEST', `${field}不能为空`)
209
+ }
210
+ const text = value.trim()
211
+ if (text.length > max) throw new AdminValidationError('BAD_REQUEST', `${field}不超过 ${max} 字`)
212
+ return text
213
+ }
214
+
215
+ export function validateSourceName(value: unknown): string {
216
+ return requireText(value, ADMIN_MAX_SOURCE_NAME_LENGTH, '数据源名称')
217
+ }
218
+
219
+ export function validateSourceType(value: unknown): DataSourceType {
220
+ if (typeof value !== 'string' || !ADMIN_SOURCE_TYPES.includes(value)) {
221
+ throw new AdminValidationError('BAD_REQUEST', '数据源类型必须是 mysql 或 postgresql')
222
+ }
223
+ return value as DataSourceType
224
+ }
225
+
226
+ export function validateHost(value: unknown): string {
227
+ return requireText(value, ADMIN_MAX_HOST_LENGTH, '主机地址')
228
+ }
229
+
230
+ export function validatePort(value: unknown, type: DataSourceType): number {
231
+ if (value === undefined || value === null) return ADMIN_SOURCE_DEFAULT_PORTS[type]
232
+ const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value
233
+ if (typeof parsed !== 'number' || !Number.isInteger(parsed)) {
234
+ throw new AdminValidationError('BAD_REQUEST', '端口必须是整数')
235
+ }
236
+ if (parsed < ADMIN_MIN_PORT || parsed > ADMIN_MAX_PORT) {
237
+ throw new AdminValidationError('BAD_REQUEST', `端口必须在 ${ADMIN_MIN_PORT} – ${ADMIN_MAX_PORT} 之间`)
238
+ }
239
+ return parsed
240
+ }
241
+
242
+ export function validateDatabaseName(value: unknown): string {
243
+ return requireText(value, ADMIN_MAX_DATABASE_LENGTH, '数据库名')
244
+ }
245
+
246
+ export function validateUsername(value: unknown): string {
247
+ return requireText(value, ADMIN_MAX_USERNAME_LENGTH, '用户名')
248
+ }
249
+
250
+ /** 密码允许为空串(部分库无口令),但不能超过上限。 */
251
+ export function validatePassword(value: unknown): string {
252
+ if (value === undefined || value === null) return ''
253
+ if (typeof value !== 'string') throw new AdminValidationError('BAD_REQUEST', '密码必须是字符串')
254
+ if (value.length > ADMIN_MAX_PASSWORD_LENGTH) {
255
+ throw new AdminValidationError('BAD_REQUEST', `密码不超过 ${ADMIN_MAX_PASSWORD_LENGTH} 字`)
256
+ }
257
+ return value
258
+ }
259
+
260
+ function validateSslMode(value: unknown): string | null {
261
+ return validateOptionalText(value, 32, 'SSL 模式')
262
+ }
263
+
264
+ function validatePoolMax(value: unknown): number | null {
265
+ if (value === undefined || value === null) return null
266
+ const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value
267
+ if (typeof parsed !== 'number' || !Number.isInteger(parsed) || parsed < 1) {
268
+ throw new AdminValidationError('BAD_REQUEST', '连接池上限必须是正整数')
269
+ }
270
+ return Math.min(ADMIN_MAX_POOL_MAX, parsed)
271
+ }
272
+
273
+ export interface ParsedCreateSource extends DataSourceInput {
274
+ test: boolean
275
+ }
276
+
277
+ export function parseCreateDataSource(raw: unknown): ParsedCreateSource {
278
+ if (typeof raw !== 'object' || raw === null) {
279
+ throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
280
+ }
281
+ const body = raw as Record<string, unknown>
282
+ const type = validateSourceType(body.type)
283
+ return {
284
+ name: validateSourceName(body.name),
285
+ type,
286
+ host: validateHost(body.host),
287
+ port: validatePort(body.port, type),
288
+ database: validateDatabaseName(body.database),
289
+ username: validateUsername(body.username),
290
+ password: validatePassword(body.password),
291
+ sslMode: validateSslMode(body.sslMode),
292
+ poolMax: validatePoolMax(body.poolMax),
293
+ description: validateDescription(body.description),
294
+ test: body.test !== false,
295
+ }
296
+ }
297
+
298
+ export interface ParsedPatchSource extends DataSourcePatch {
299
+ test: boolean
300
+ }
301
+
302
+ /** 只校验出现的字段;`password` 给了字符串才替换。 */
303
+ export function parsePatchDataSource(raw: unknown): ParsedPatchSource {
304
+ if (typeof raw !== 'object' || raw === null) {
305
+ throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
306
+ }
307
+ const body = raw as Record<string, unknown>
308
+ const patch: ParsedPatchSource = { test: body.test !== false }
309
+ if (body.name !== undefined) patch.name = validateSourceName(body.name)
310
+ if (body.host !== undefined) patch.host = validateHost(body.host)
311
+ if (body.database !== undefined) patch.database = validateDatabaseName(body.database)
312
+ if (body.username !== undefined) patch.username = validateUsername(body.username)
313
+ if (body.password !== undefined) patch.password = validatePassword(body.password)
314
+ if (body.sslMode !== undefined) patch.sslMode = validateSslMode(body.sslMode)
315
+ if (body.poolMax !== undefined) patch.poolMax = validatePoolMax(body.poolMax)
316
+ if (body.description !== undefined) patch.description = validateDescription(body.description)
317
+ const type = body.type === undefined ? undefined : validateSourceType(body.type)
318
+ if (type !== undefined) patch.type = type
319
+ if (body.port !== undefined) patch.port = validatePort(body.port, type ?? 'mysql')
320
+ return patch
321
+ }
322
+
323
+ export interface ParsedTestSource {
324
+ /** 测已登记的数据源(id 或登记名)。 */
325
+ reference?: string
326
+ /** 测未保存的草稿。 */
327
+ draft?: ConnectionDraft
328
+ }
329
+
330
+ /** `source` 与「完整连接参数」二选一。 */
331
+ export function parseTestDataSource(raw: unknown): ParsedTestSource {
332
+ if (typeof raw !== 'object' || raw === null) {
333
+ throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
334
+ }
335
+ const body = raw as Record<string, unknown>
336
+ if (typeof body.source === 'string' && body.source.trim().length > 0) {
337
+ return { reference: body.source.trim() }
338
+ }
339
+ const type = validateSourceType(body.type)
340
+ return {
341
+ draft: {
342
+ type,
343
+ host: validateHost(body.host),
344
+ port: validatePort(body.port, type),
345
+ database: validateDatabaseName(body.database),
346
+ username: validateUsername(body.username),
347
+ password: validatePassword(body.password),
348
+ sslMode: validateSslMode(body.sslMode),
349
+ poolMax: validatePoolMax(body.poolMax),
350
+ },
351
+ }
352
+ }
353
+
354
+ export interface ParsedTablesQuery {
355
+ schema: string | null
356
+ q: string
357
+ }
358
+
359
+ export function parseTablesQuery(params: Record<string, string | undefined>): ParsedTablesQuery {
360
+ const schema = typeof params.schema === 'string' && params.schema.trim().length > 0 ? params.schema.trim() : null
361
+ const q = typeof params.q === 'string' ? params.q.trim().slice(0, ADMIN_MAX_QUERY_LENGTH) : ''
362
+ return { schema, q }
363
+ }
364
+
365
+ export interface ParsedImportRequest {
366
+ scopeKey: string
367
+ tableName: string
368
+ schemaName: string | null
369
+ name: string | null
370
+ limit: number | null
371
+ }
372
+
373
+ export function parseImportRequest(raw: unknown): ParsedImportRequest {
374
+ if (typeof raw !== 'object' || raw === null) {
375
+ throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
376
+ }
377
+ const body = raw as Record<string, unknown>
378
+ const tableName = requireText(body.tableName, ADMIN_MAX_DATABASE_LENGTH, '表名')
379
+ const limit = typeof body.limit === 'number' && Number.isInteger(body.limit) && body.limit > 0 ? body.limit : null
380
+ return {
381
+ scopeKey: validateScopeKey(body.scopeKey),
382
+ tableName,
383
+ schemaName: validateOptionalText(body.schemaName, ADMIN_MAX_DATABASE_LENGTH, 'schema'),
384
+ name: validateDatasetNameSafe(body.name),
385
+ limit,
386
+ }
387
+ }
388
+
389
+ /** 数据集登记名可缺省(缺省时取远端表名)。 */
390
+ function validateDatasetNameSafe(value: unknown): string | null {
391
+ if (value === undefined || value === null) return null
392
+ return validateDatasetName(value)
393
+ }