@makehq/forman-schema 1.16.0 → 1.18.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 +43 -1
- package/dist/index.cjs +96 -14
- package/dist/index.d.cts +51 -3
- package/dist/index.d.ts +51 -3
- package/dist/index.js +94 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -291,11 +291,53 @@ const result = await validateFormanWithDomains(
|
|
|
291
291
|
- object → collection
|
|
292
292
|
- array → array
|
|
293
293
|
|
|
294
|
+
## Field type resolution
|
|
295
|
+
|
|
296
|
+
Field types resolve through three steps, so schemas authored with loose casing or common synonyms
|
|
297
|
+
still convert:
|
|
298
|
+
|
|
299
|
+
1. **Exact match** against `FORMAN_TYPE_MAP`.
|
|
300
|
+
2. **Case-insensitive match** — `fileName`, `Boolean`, `URL`, `Select` resolve to their canonical
|
|
301
|
+
lowercase types. The index is derived from the map itself, so new entries get this for free.
|
|
302
|
+
3. **Aliases** — `string→text`, `bool→boolean`, `datetime→date`, `float→number`,
|
|
303
|
+
`upload→filestorage`. Only unambiguous, information-preserving synonyms are aliased.
|
|
304
|
+
|
|
305
|
+
A `type:kind` suffix (`account:google`, `device:apn`) resolves on its base type and keeps the kind,
|
|
306
|
+
which drives the `api://` store expansion.
|
|
307
|
+
|
|
308
|
+
### Unconvertible fields
|
|
309
|
+
|
|
310
|
+
A field whose type is **missing or unresolvable** is degraded to a permissive typeless schema (the
|
|
311
|
+
same shape `any` produces) instead of aborting the conversion, and its dot-notation path is reported
|
|
312
|
+
on `toJSONSchemaAdvanced`'s `skippedPaths.unconvertible`:
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
const { schema, skippedPaths } = toJSONSchemaAdvanced({
|
|
316
|
+
name: 'wrapper',
|
|
317
|
+
type: 'collection',
|
|
318
|
+
spec: [
|
|
319
|
+
{ name: 'good', type: 'text' },
|
|
320
|
+
{ name: 'odd', type: 'somethingNew' },
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
// schema.properties → { good: { type: 'string' }, odd: {} }
|
|
324
|
+
// skippedPaths → { unconvertible: ['wrapper.odd (unknown type: somethingNew)'] }
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
This is deliberate: the throw was fatal at any depth, so a single unrecognized leaf field destroyed
|
|
328
|
+
the whole schema and left consumers with nothing. Types requiring a guess about intent (`tags`,
|
|
329
|
+
`category`, `object`) are degraded rather than aliased — a degraded field is honest, a wrongly
|
|
330
|
+
aliased one is a lie the consumer will act on.
|
|
331
|
+
|
|
332
|
+
Pass `{ strictFieldTypes: true }` to restore fail-fast throwing.
|
|
333
|
+
|
|
294
334
|
## Error Handling
|
|
295
335
|
|
|
296
336
|
### SchemaConversionError
|
|
297
337
|
|
|
298
|
-
`SchemaConversionError` is thrown when schema conversion fails
|
|
338
|
+
`SchemaConversionError` is thrown when schema conversion fails, and for unresolvable field types
|
|
339
|
+
only when `strictFieldTypes: true` is set. It carries a message and the `field` that caused the
|
|
340
|
+
error.
|
|
299
341
|
|
|
300
342
|
## Testing
|
|
301
343
|
|
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
SchemaConversionError: () => SchemaConversionError,
|
|
24
|
+
resolveFormanFieldType: () => resolveFormanFieldType,
|
|
23
25
|
toFormanSchema: () => toFormanSchema,
|
|
24
26
|
toJSONSchema: () => toJSONSchema,
|
|
25
27
|
toJSONSchemaAdvanced: () => toJSONSchemaAdvanced,
|
|
@@ -56,6 +58,9 @@ function noEmpty(text) {
|
|
|
56
58
|
function isObject(value) {
|
|
57
59
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
60
|
}
|
|
61
|
+
function isBooleanBranchNested(nested) {
|
|
62
|
+
return isObject(nested) && !("store" in nested) && ("true" in nested || "false" in nested);
|
|
63
|
+
}
|
|
59
64
|
function isOptionGroup(value) {
|
|
60
65
|
return "options" in value && Array.isArray(value.options);
|
|
61
66
|
}
|
|
@@ -543,11 +548,13 @@ var SchemaConversionError = class extends Error {
|
|
|
543
548
|
constructor(message, field) {
|
|
544
549
|
super(message);
|
|
545
550
|
this.name = "SchemaConversionError";
|
|
551
|
+
this.field = field;
|
|
546
552
|
}
|
|
547
553
|
};
|
|
548
554
|
var FORMAN_TYPE_MAP = {
|
|
549
555
|
account: "number",
|
|
550
556
|
hook: "number",
|
|
557
|
+
device: "number",
|
|
551
558
|
keychain: "number",
|
|
552
559
|
datastore: "number",
|
|
553
560
|
aiagent: "string",
|
|
@@ -591,14 +598,29 @@ var FORMAN_TYPE_MAP = {
|
|
|
591
598
|
uuid: "string",
|
|
592
599
|
any: void 0
|
|
593
600
|
};
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
601
|
+
var FORMAN_TYPE_LOOKUP = new Map(
|
|
602
|
+
Object.keys(FORMAN_TYPE_MAP).map((key) => [key.toLowerCase(), key])
|
|
603
|
+
);
|
|
604
|
+
var FORMAN_TYPE_ALIASES = {
|
|
605
|
+
// Forman's plain-string primitive is `text`; there is no `string` type.
|
|
606
|
+
string: "text",
|
|
607
|
+
bool: "boolean",
|
|
608
|
+
// Forman's `date` already carries time (see `time` on FormanSchemaField, default true).
|
|
609
|
+
datetime: "date",
|
|
610
|
+
// JSON Schema has no float; `number` is the only target.
|
|
611
|
+
float: "number",
|
|
612
|
+
// `upload` is the validator's existing name for the same array-shaped type.
|
|
613
|
+
upload: "filestorage"
|
|
614
|
+
};
|
|
615
|
+
function resolveFormanFieldType(rawType) {
|
|
616
|
+
if (!rawType) return void 0;
|
|
617
|
+
const base = rawType.includes(":") ? rawType.split(":")[0] : rawType;
|
|
618
|
+
const lower = base.toLowerCase();
|
|
619
|
+
return FORMAN_TYPE_LOOKUP.get(lower) ?? FORMAN_TYPE_ALIASES[lower];
|
|
620
|
+
}
|
|
621
|
+
function canonicalTypeWithKind(rawType, canonicalType) {
|
|
622
|
+
const separatorIndex = rawType.indexOf(":");
|
|
623
|
+
return separatorIndex === -1 ? canonicalType : `${canonicalType}${rawType.slice(separatorIndex)}`;
|
|
602
624
|
}
|
|
603
625
|
function appendQueryString(path, domain, tail) {
|
|
604
626
|
if (path.startsWith("api://")) return path;
|
|
@@ -616,6 +638,7 @@ function createDefaultContext(options) {
|
|
|
616
638
|
roots: {},
|
|
617
639
|
definitions: {},
|
|
618
640
|
excludeAdvancedFields: options?.excludeAdvancedFields ?? false,
|
|
641
|
+
strictFieldTypes: options?.strictFieldTypes ?? false,
|
|
619
642
|
skippedPaths: {},
|
|
620
643
|
addConditionalFields: () => {
|
|
621
644
|
throw new SchemaConversionError("Cannot serialize nested fields without parent field.");
|
|
@@ -627,8 +650,19 @@ var compositeHandlers = {
|
|
|
627
650
|
udttype: { expand: udttypeExpand, extractInner: udttypeExtractInner, wrapRef: udttypeWrapRef }
|
|
628
651
|
};
|
|
629
652
|
function toJSONSchemaInternal(field, context) {
|
|
630
|
-
|
|
631
|
-
|
|
653
|
+
const canonicalType = resolveFormanFieldType(field.type);
|
|
654
|
+
if (!canonicalType) {
|
|
655
|
+
if (context.strictFieldTypes) {
|
|
656
|
+
throw new SchemaConversionError(
|
|
657
|
+
field.type ? `Unknown field type: ${field.type}` : "Field type is required",
|
|
658
|
+
field
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
return degradeUnconvertibleField(field, context);
|
|
662
|
+
}
|
|
663
|
+
const normalizedField = normalizeFormanFieldType(
|
|
664
|
+
canonicalType === field.type ? field : { ...field, type: canonicalTypeWithKind(field.type, canonicalType) }
|
|
665
|
+
);
|
|
632
666
|
const handler = compositeHandlers[normalizedField.type];
|
|
633
667
|
if (handler) {
|
|
634
668
|
const type = normalizedField.type;
|
|
@@ -1043,6 +1077,16 @@ function handleJsonType(field, result) {
|
|
|
1043
1077
|
});
|
|
1044
1078
|
return result;
|
|
1045
1079
|
}
|
|
1080
|
+
function degradeUnconvertibleField(field, context) {
|
|
1081
|
+
const path = field.name ? [...context.path, field.name].join(".") : context.path.join(".");
|
|
1082
|
+
const reason = field.type ? `unknown type: ${field.type}` : "missing type";
|
|
1083
|
+
(context.skippedPaths.unconvertible ||= []).push(`${path} (${reason})`);
|
|
1084
|
+
return {
|
|
1085
|
+
type: FORMAN_TYPE_MAP["any"],
|
|
1086
|
+
title: noEmpty(field.label),
|
|
1087
|
+
description: noEmpty(field.help)
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1046
1090
|
function handlePrimitiveType(field, result, context) {
|
|
1047
1091
|
if (field.default !== "" && field.default != null) {
|
|
1048
1092
|
result.default = field.default;
|
|
@@ -1083,7 +1127,7 @@ function processRpcDirective(field, result, context) {
|
|
|
1083
1127
|
return result;
|
|
1084
1128
|
}
|
|
1085
1129
|
function extractNestedAndDomain(field) {
|
|
1086
|
-
const nested = isObject(field.options) ? isObject(field.options.nested) ? field.options.nested.store : field.options.nested : isObject(field.nested) ? field.nested.store : field.nested;
|
|
1130
|
+
const nested = isObject(field.options) ? isObject(field.options.nested) ? field.options.nested.store : field.options.nested : isBooleanBranchNested(field.nested) ? void 0 : isObject(field.nested) ? field.nested.store : field.nested;
|
|
1087
1131
|
const domain = isObject(field.options) ? isObject(field.options.nested) && field.options.nested.domain ? field.options.nested.domain : void 0 : isObject(field.nested) && field.nested.domain ? field.nested.domain : void 0;
|
|
1088
1132
|
return { nested, domain };
|
|
1089
1133
|
}
|
|
@@ -1352,6 +1396,13 @@ async function validateFormanWithDomainsInternal(domains, options) {
|
|
|
1352
1396
|
};
|
|
1353
1397
|
}
|
|
1354
1398
|
async function validateFormanValue(value, field, context) {
|
|
1399
|
+
if (context.registerOnly) {
|
|
1400
|
+
return {
|
|
1401
|
+
valid: true,
|
|
1402
|
+
errors: [],
|
|
1403
|
+
warnings: []
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1355
1406
|
if (isVisualType(field.type)) {
|
|
1356
1407
|
return {
|
|
1357
1408
|
valid: true,
|
|
@@ -1359,8 +1410,21 @@ async function validateFormanValue(value, field, context) {
|
|
|
1359
1410
|
warnings: []
|
|
1360
1411
|
};
|
|
1361
1412
|
}
|
|
1413
|
+
if (!field.type) {
|
|
1414
|
+
return {
|
|
1415
|
+
valid: false,
|
|
1416
|
+
errors: [
|
|
1417
|
+
{
|
|
1418
|
+
domain: context.domain,
|
|
1419
|
+
path: context.path.join("."),
|
|
1420
|
+
message: "Field type is required."
|
|
1421
|
+
}
|
|
1422
|
+
],
|
|
1423
|
+
warnings: []
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1362
1426
|
const normalizedField = normalizeFormanFieldType(field);
|
|
1363
|
-
if (normalizedField.required && (value == null || value === "")) {
|
|
1427
|
+
if (normalizedField.required && !context.suppressRequired && (value == null || value === "")) {
|
|
1364
1428
|
return {
|
|
1365
1429
|
valid: false,
|
|
1366
1430
|
errors: [
|
|
@@ -1530,7 +1594,7 @@ async function handleCollectionType2(value, field, context) {
|
|
|
1530
1594
|
continue;
|
|
1531
1595
|
}
|
|
1532
1596
|
if (context2.strict && !seen.has(subField2.name)) seen.add(subField2.name);
|
|
1533
|
-
if (path.length === 0) {
|
|
1597
|
+
if (path.length === 0 && !context2.registerOnly) {
|
|
1534
1598
|
context2.roots[context2.domain].schemaFields.push(clampFieldForSchema(subField2));
|
|
1535
1599
|
}
|
|
1536
1600
|
const result2 = await validateFormanValue(value[subField2.name], subField2, {
|
|
@@ -2123,7 +2187,7 @@ async function handlePrimitiveType2(value, field, context) {
|
|
|
2123
2187
|
}
|
|
2124
2188
|
const nested = extractNestedFromField(field);
|
|
2125
2189
|
if (nested) {
|
|
2126
|
-
const result = await handleNestedFields(nested, value, field, context);
|
|
2190
|
+
const result = FORMAN_TYPE_MAP2[field.type] === "boolean" ? await handleBooleanNestedFields(nested, value, field, context) : await handleNestedFields(nested, value, field, context);
|
|
2127
2191
|
errors.push(...result.errors);
|
|
2128
2192
|
warnings.push(...result.warnings);
|
|
2129
2193
|
}
|
|
@@ -2133,6 +2197,19 @@ async function handlePrimitiveType2(value, field, context) {
|
|
|
2133
2197
|
warnings
|
|
2134
2198
|
};
|
|
2135
2199
|
}
|
|
2200
|
+
async function handleBooleanNestedFields(nested, value, field, context) {
|
|
2201
|
+
if (isBooleanBranchNested(nested)) {
|
|
2202
|
+
const active2 = value === false ? nested.false : nested.true;
|
|
2203
|
+
const inactive = value === false ? nested.true : nested.false;
|
|
2204
|
+
const result = active2 ? await handleNestedFields(active2, value, field, context) : { valid: true, errors: [], warnings: [] };
|
|
2205
|
+
if (context.strict && inactive) {
|
|
2206
|
+
await handleNestedFields(inactive, value, field, { ...context, registerOnly: true });
|
|
2207
|
+
}
|
|
2208
|
+
return result;
|
|
2209
|
+
}
|
|
2210
|
+
const active = field.reversedNested === true ? value === false : value === true;
|
|
2211
|
+
return handleNestedFields(nested, value, field, active ? context : { ...context, suppressRequired: true });
|
|
2212
|
+
}
|
|
2136
2213
|
|
|
2137
2214
|
// src/json.ts
|
|
2138
2215
|
var JSON_PRIMITIVE_TYPE_MAP = {
|
|
@@ -2304,6 +2381,9 @@ function toJSONSchemaAdvanced(field, options) {
|
|
|
2304
2381
|
if (context.skippedPaths.advanced?.length) {
|
|
2305
2382
|
skippedPaths.advanced = context.skippedPaths.advanced;
|
|
2306
2383
|
}
|
|
2384
|
+
if (context.skippedPaths.unconvertible?.length) {
|
|
2385
|
+
skippedPaths.unconvertible = context.skippedPaths.unconvertible;
|
|
2386
|
+
}
|
|
2307
2387
|
return {
|
|
2308
2388
|
schema,
|
|
2309
2389
|
...Object.keys(skippedPaths).length > 0 ? { skippedPaths } : {}
|
|
@@ -2323,6 +2403,8 @@ function validateForman(values, schema, options, restoreExtras) {
|
|
|
2323
2403
|
}
|
|
2324
2404
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2325
2405
|
0 && (module.exports = {
|
|
2406
|
+
SchemaConversionError,
|
|
2407
|
+
resolveFormanFieldType,
|
|
2326
2408
|
toFormanSchema,
|
|
2327
2409
|
toJSONSchema,
|
|
2328
2410
|
toJSONSchemaAdvanced,
|
package/dist/index.d.cts
CHANGED
|
@@ -47,8 +47,8 @@ type FormanSchemaField = {
|
|
|
47
47
|
advanced?: boolean;
|
|
48
48
|
/** Human readable label for the field */
|
|
49
49
|
label?: string;
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
nested?: FormanSchemaNested | FormanSchemaBooleanNested;
|
|
51
|
+
reversedNested?: boolean;
|
|
52
52
|
/** Validation rules */
|
|
53
53
|
validate?: FormanSchemaValidation;
|
|
54
54
|
/** Whether the field is disabled (`false` by default) */
|
|
@@ -186,6 +186,10 @@ type FormanSchemaExtendedNested = {
|
|
|
186
186
|
/** Domain for the nested fields */
|
|
187
187
|
domain?: string;
|
|
188
188
|
};
|
|
189
|
+
type FormanSchemaBooleanNested = {
|
|
190
|
+
true?: (FormanSchemaField | string)[] | string;
|
|
191
|
+
false?: (FormanSchemaField | string)[] | string;
|
|
192
|
+
};
|
|
189
193
|
/**
|
|
190
194
|
* Validation result
|
|
191
195
|
*/
|
|
@@ -240,6 +244,14 @@ type FormanJsonSchemaOptions = {
|
|
|
240
244
|
* `toJSONSchemaAdvanced`'s `skippedPaths.advanced` so the caller can re-request them.
|
|
241
245
|
*/
|
|
242
246
|
excludeAdvancedFields?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* Throw a `SchemaConversionError` when a field's type cannot be resolved, instead of degrading
|
|
249
|
+
* it to a permissive typeless schema. Defaults to `false` — by default, unresolvable fields are
|
|
250
|
+
* degraded and their dot-notation paths reported on `toJSONSchemaAdvanced`'s
|
|
251
|
+
* `skippedPaths.unconvertible`, so one unrecognized field can no longer abort the whole
|
|
252
|
+
* conversion. Set `true` only when a caller genuinely wants fail-fast behaviour.
|
|
253
|
+
*/
|
|
254
|
+
strictFieldTypes?: boolean;
|
|
243
255
|
};
|
|
244
256
|
/**
|
|
245
257
|
* Result of converting a Forman Schema to JSON Schema
|
|
@@ -251,6 +263,12 @@ type FormanJsonSchemaResult = {
|
|
|
251
263
|
skippedPaths?: {
|
|
252
264
|
/** Dot-notation paths of advanced fields that were skipped. Present only when at least one advanced field was skipped. */
|
|
253
265
|
advanced?: string[];
|
|
266
|
+
/**
|
|
267
|
+
* Dot-notation paths of fields whose type could not be resolved and were therefore degraded
|
|
268
|
+
* to a permissive typeless schema. Each entry is suffixed with the reason —
|
|
269
|
+
* `(unknown type: X)` or `(missing type)`. Present only when at least one field was degraded.
|
|
270
|
+
*/
|
|
271
|
+
unconvertible?: string[];
|
|
254
272
|
};
|
|
255
273
|
};
|
|
256
274
|
/**
|
|
@@ -294,6 +312,26 @@ type FormanValidationOptions = {
|
|
|
294
312
|
*/
|
|
295
313
|
declare function toFormanSchema(field: JSONSchema7): FormanSchemaField;
|
|
296
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Error thrown when schema conversion fails
|
|
317
|
+
*/
|
|
318
|
+
declare class SchemaConversionError extends Error {
|
|
319
|
+
/** Field that caused the error */
|
|
320
|
+
readonly field?: FormanSchemaField | JSONSchema7;
|
|
321
|
+
/**
|
|
322
|
+
* @param message The error message
|
|
323
|
+
* @param field The field that caused the error
|
|
324
|
+
*/
|
|
325
|
+
constructor(message: string, field?: FormanSchemaField | JSONSchema7);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Resolves a raw Forman field type to a canonical `FORMAN_TYPE_MAP` key, handling `type:kind`
|
|
329
|
+
* prefixes, casing and known aliases.
|
|
330
|
+
* @param rawType The raw field type from the Forman schema
|
|
331
|
+
* @returns The canonical type key, or `undefined` when the type is missing or unresolvable
|
|
332
|
+
*/
|
|
333
|
+
declare function resolveFormanFieldType(rawType: string | undefined): string | undefined;
|
|
334
|
+
|
|
297
335
|
/**
|
|
298
336
|
* Converts a Forman Schema field to its JSON Schema equivalent and reports the paths of any
|
|
299
337
|
* fields that were skipped during conversion.
|
|
@@ -311,6 +349,13 @@ declare function toFormanSchema(field: JSONSchema7): FormanSchemaField;
|
|
|
311
349
|
* `definitions[type]`; advanced fields inside a composite template are recorded with the
|
|
312
350
|
* path of the FIRST usage only. See the comment near `compositeHandlers` in `src/forman.ts`.
|
|
313
351
|
*
|
|
352
|
+
* **Unresolvable field types** are tolerated by default. A field whose type is missing, or is not a
|
|
353
|
+
* known type (after case-insensitive and alias resolution), is degraded to a permissive typeless
|
|
354
|
+
* schema rather than aborting the whole conversion, and its dot-notation path is reported on
|
|
355
|
+
* `skippedPaths.unconvertible` with the reason. This matters because the throw was fatal at any
|
|
356
|
+
* depth: a single unrecognized leaf field destroyed the entire schema, leaving consumers with
|
|
357
|
+
* nothing. Pass `{ strictFieldTypes: true }` to restore fail-fast throwing.
|
|
358
|
+
*
|
|
314
359
|
* If you don't need `skippedPaths`, use {@link toJSONSchema} which returns just the schema.
|
|
315
360
|
*
|
|
316
361
|
* @param field The Forman Schema field to convert
|
|
@@ -326,6 +371,9 @@ declare function toJSONSchemaAdvanced(field: FormanSchemaField, options?: Forman
|
|
|
326
371
|
* fields were dropped (e.g. to render a "show advanced" toggle), use {@link toJSONSchemaAdvanced}
|
|
327
372
|
* which returns `{ schema, skippedPaths? }`.
|
|
328
373
|
*
|
|
374
|
+
* Fields with an unresolvable type are degraded to a permissive schema rather than throwing; use
|
|
375
|
+
* {@link toJSONSchemaAdvanced} to see which, or `{ strictFieldTypes: true }` to throw instead.
|
|
376
|
+
*
|
|
329
377
|
* @param field The Forman Schema field to convert
|
|
330
378
|
* @param options Conversion options
|
|
331
379
|
* @returns The equivalent JSON Schema
|
|
@@ -358,4 +406,4 @@ declare function validateFormanWithDomains(domains: Record<string, {
|
|
|
358
406
|
*/
|
|
359
407
|
declare function validateForman(values: Record<string, unknown>, schema: FormanSchemaField[], options?: FormanValidationOptions, restoreExtras?: Record<string, Record<string, unknown>>): Promise<FormanValidationResult>;
|
|
360
408
|
|
|
361
|
-
export { type FormanExternalValidationResult, type FormanJsonSchemaOptions, type FormanJsonSchemaResult, type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, toFormanSchema, toJSONSchema, toJSONSchemaAdvanced, validateForman, validateFormanWithDomains };
|
|
409
|
+
export { type FormanExternalValidationResult, type FormanJsonSchemaOptions, type FormanJsonSchemaResult, type FormanSchemaBooleanNested, type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, SchemaConversionError, resolveFormanFieldType, toFormanSchema, toJSONSchema, toJSONSchemaAdvanced, validateForman, validateFormanWithDomains };
|
package/dist/index.d.ts
CHANGED
|
@@ -47,8 +47,8 @@ type FormanSchemaField = {
|
|
|
47
47
|
advanced?: boolean;
|
|
48
48
|
/** Human readable label for the field */
|
|
49
49
|
label?: string;
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
nested?: FormanSchemaNested | FormanSchemaBooleanNested;
|
|
51
|
+
reversedNested?: boolean;
|
|
52
52
|
/** Validation rules */
|
|
53
53
|
validate?: FormanSchemaValidation;
|
|
54
54
|
/** Whether the field is disabled (`false` by default) */
|
|
@@ -186,6 +186,10 @@ type FormanSchemaExtendedNested = {
|
|
|
186
186
|
/** Domain for the nested fields */
|
|
187
187
|
domain?: string;
|
|
188
188
|
};
|
|
189
|
+
type FormanSchemaBooleanNested = {
|
|
190
|
+
true?: (FormanSchemaField | string)[] | string;
|
|
191
|
+
false?: (FormanSchemaField | string)[] | string;
|
|
192
|
+
};
|
|
189
193
|
/**
|
|
190
194
|
* Validation result
|
|
191
195
|
*/
|
|
@@ -240,6 +244,14 @@ type FormanJsonSchemaOptions = {
|
|
|
240
244
|
* `toJSONSchemaAdvanced`'s `skippedPaths.advanced` so the caller can re-request them.
|
|
241
245
|
*/
|
|
242
246
|
excludeAdvancedFields?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* Throw a `SchemaConversionError` when a field's type cannot be resolved, instead of degrading
|
|
249
|
+
* it to a permissive typeless schema. Defaults to `false` — by default, unresolvable fields are
|
|
250
|
+
* degraded and their dot-notation paths reported on `toJSONSchemaAdvanced`'s
|
|
251
|
+
* `skippedPaths.unconvertible`, so one unrecognized field can no longer abort the whole
|
|
252
|
+
* conversion. Set `true` only when a caller genuinely wants fail-fast behaviour.
|
|
253
|
+
*/
|
|
254
|
+
strictFieldTypes?: boolean;
|
|
243
255
|
};
|
|
244
256
|
/**
|
|
245
257
|
* Result of converting a Forman Schema to JSON Schema
|
|
@@ -251,6 +263,12 @@ type FormanJsonSchemaResult = {
|
|
|
251
263
|
skippedPaths?: {
|
|
252
264
|
/** Dot-notation paths of advanced fields that were skipped. Present only when at least one advanced field was skipped. */
|
|
253
265
|
advanced?: string[];
|
|
266
|
+
/**
|
|
267
|
+
* Dot-notation paths of fields whose type could not be resolved and were therefore degraded
|
|
268
|
+
* to a permissive typeless schema. Each entry is suffixed with the reason —
|
|
269
|
+
* `(unknown type: X)` or `(missing type)`. Present only when at least one field was degraded.
|
|
270
|
+
*/
|
|
271
|
+
unconvertible?: string[];
|
|
254
272
|
};
|
|
255
273
|
};
|
|
256
274
|
/**
|
|
@@ -294,6 +312,26 @@ type FormanValidationOptions = {
|
|
|
294
312
|
*/
|
|
295
313
|
declare function toFormanSchema(field: JSONSchema7): FormanSchemaField;
|
|
296
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Error thrown when schema conversion fails
|
|
317
|
+
*/
|
|
318
|
+
declare class SchemaConversionError extends Error {
|
|
319
|
+
/** Field that caused the error */
|
|
320
|
+
readonly field?: FormanSchemaField | JSONSchema7;
|
|
321
|
+
/**
|
|
322
|
+
* @param message The error message
|
|
323
|
+
* @param field The field that caused the error
|
|
324
|
+
*/
|
|
325
|
+
constructor(message: string, field?: FormanSchemaField | JSONSchema7);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Resolves a raw Forman field type to a canonical `FORMAN_TYPE_MAP` key, handling `type:kind`
|
|
329
|
+
* prefixes, casing and known aliases.
|
|
330
|
+
* @param rawType The raw field type from the Forman schema
|
|
331
|
+
* @returns The canonical type key, or `undefined` when the type is missing or unresolvable
|
|
332
|
+
*/
|
|
333
|
+
declare function resolveFormanFieldType(rawType: string | undefined): string | undefined;
|
|
334
|
+
|
|
297
335
|
/**
|
|
298
336
|
* Converts a Forman Schema field to its JSON Schema equivalent and reports the paths of any
|
|
299
337
|
* fields that were skipped during conversion.
|
|
@@ -311,6 +349,13 @@ declare function toFormanSchema(field: JSONSchema7): FormanSchemaField;
|
|
|
311
349
|
* `definitions[type]`; advanced fields inside a composite template are recorded with the
|
|
312
350
|
* path of the FIRST usage only. See the comment near `compositeHandlers` in `src/forman.ts`.
|
|
313
351
|
*
|
|
352
|
+
* **Unresolvable field types** are tolerated by default. A field whose type is missing, or is not a
|
|
353
|
+
* known type (after case-insensitive and alias resolution), is degraded to a permissive typeless
|
|
354
|
+
* schema rather than aborting the whole conversion, and its dot-notation path is reported on
|
|
355
|
+
* `skippedPaths.unconvertible` with the reason. This matters because the throw was fatal at any
|
|
356
|
+
* depth: a single unrecognized leaf field destroyed the entire schema, leaving consumers with
|
|
357
|
+
* nothing. Pass `{ strictFieldTypes: true }` to restore fail-fast throwing.
|
|
358
|
+
*
|
|
314
359
|
* If you don't need `skippedPaths`, use {@link toJSONSchema} which returns just the schema.
|
|
315
360
|
*
|
|
316
361
|
* @param field The Forman Schema field to convert
|
|
@@ -326,6 +371,9 @@ declare function toJSONSchemaAdvanced(field: FormanSchemaField, options?: Forman
|
|
|
326
371
|
* fields were dropped (e.g. to render a "show advanced" toggle), use {@link toJSONSchemaAdvanced}
|
|
327
372
|
* which returns `{ schema, skippedPaths? }`.
|
|
328
373
|
*
|
|
374
|
+
* Fields with an unresolvable type are degraded to a permissive schema rather than throwing; use
|
|
375
|
+
* {@link toJSONSchemaAdvanced} to see which, or `{ strictFieldTypes: true }` to throw instead.
|
|
376
|
+
*
|
|
329
377
|
* @param field The Forman Schema field to convert
|
|
330
378
|
* @param options Conversion options
|
|
331
379
|
* @returns The equivalent JSON Schema
|
|
@@ -358,4 +406,4 @@ declare function validateFormanWithDomains(domains: Record<string, {
|
|
|
358
406
|
*/
|
|
359
407
|
declare function validateForman(values: Record<string, unknown>, schema: FormanSchemaField[], options?: FormanValidationOptions, restoreExtras?: Record<string, Record<string, unknown>>): Promise<FormanValidationResult>;
|
|
360
408
|
|
|
361
|
-
export { type FormanExternalValidationResult, type FormanJsonSchemaOptions, type FormanJsonSchemaResult, type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, toFormanSchema, toJSONSchema, toJSONSchemaAdvanced, validateForman, validateFormanWithDomains };
|
|
409
|
+
export { type FormanExternalValidationResult, type FormanJsonSchemaOptions, type FormanJsonSchemaResult, type FormanSchemaBooleanNested, type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, SchemaConversionError, resolveFormanFieldType, toFormanSchema, toJSONSchema, toJSONSchemaAdvanced, validateForman, validateFormanWithDomains };
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,9 @@ function noEmpty(text) {
|
|
|
26
26
|
function isObject(value) {
|
|
27
27
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
28
|
}
|
|
29
|
+
function isBooleanBranchNested(nested) {
|
|
30
|
+
return isObject(nested) && !("store" in nested) && ("true" in nested || "false" in nested);
|
|
31
|
+
}
|
|
29
32
|
function isOptionGroup(value) {
|
|
30
33
|
return "options" in value && Array.isArray(value.options);
|
|
31
34
|
}
|
|
@@ -513,11 +516,13 @@ var SchemaConversionError = class extends Error {
|
|
|
513
516
|
constructor(message, field) {
|
|
514
517
|
super(message);
|
|
515
518
|
this.name = "SchemaConversionError";
|
|
519
|
+
this.field = field;
|
|
516
520
|
}
|
|
517
521
|
};
|
|
518
522
|
var FORMAN_TYPE_MAP = {
|
|
519
523
|
account: "number",
|
|
520
524
|
hook: "number",
|
|
525
|
+
device: "number",
|
|
521
526
|
keychain: "number",
|
|
522
527
|
datastore: "number",
|
|
523
528
|
aiagent: "string",
|
|
@@ -561,14 +566,29 @@ var FORMAN_TYPE_MAP = {
|
|
|
561
566
|
uuid: "string",
|
|
562
567
|
any: void 0
|
|
563
568
|
};
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
569
|
+
var FORMAN_TYPE_LOOKUP = new Map(
|
|
570
|
+
Object.keys(FORMAN_TYPE_MAP).map((key) => [key.toLowerCase(), key])
|
|
571
|
+
);
|
|
572
|
+
var FORMAN_TYPE_ALIASES = {
|
|
573
|
+
// Forman's plain-string primitive is `text`; there is no `string` type.
|
|
574
|
+
string: "text",
|
|
575
|
+
bool: "boolean",
|
|
576
|
+
// Forman's `date` already carries time (see `time` on FormanSchemaField, default true).
|
|
577
|
+
datetime: "date",
|
|
578
|
+
// JSON Schema has no float; `number` is the only target.
|
|
579
|
+
float: "number",
|
|
580
|
+
// `upload` is the validator's existing name for the same array-shaped type.
|
|
581
|
+
upload: "filestorage"
|
|
582
|
+
};
|
|
583
|
+
function resolveFormanFieldType(rawType) {
|
|
584
|
+
if (!rawType) return void 0;
|
|
585
|
+
const base = rawType.includes(":") ? rawType.split(":")[0] : rawType;
|
|
586
|
+
const lower = base.toLowerCase();
|
|
587
|
+
return FORMAN_TYPE_LOOKUP.get(lower) ?? FORMAN_TYPE_ALIASES[lower];
|
|
588
|
+
}
|
|
589
|
+
function canonicalTypeWithKind(rawType, canonicalType) {
|
|
590
|
+
const separatorIndex = rawType.indexOf(":");
|
|
591
|
+
return separatorIndex === -1 ? canonicalType : `${canonicalType}${rawType.slice(separatorIndex)}`;
|
|
572
592
|
}
|
|
573
593
|
function appendQueryString(path, domain, tail) {
|
|
574
594
|
if (path.startsWith("api://")) return path;
|
|
@@ -586,6 +606,7 @@ function createDefaultContext(options) {
|
|
|
586
606
|
roots: {},
|
|
587
607
|
definitions: {},
|
|
588
608
|
excludeAdvancedFields: options?.excludeAdvancedFields ?? false,
|
|
609
|
+
strictFieldTypes: options?.strictFieldTypes ?? false,
|
|
589
610
|
skippedPaths: {},
|
|
590
611
|
addConditionalFields: () => {
|
|
591
612
|
throw new SchemaConversionError("Cannot serialize nested fields without parent field.");
|
|
@@ -597,8 +618,19 @@ var compositeHandlers = {
|
|
|
597
618
|
udttype: { expand: udttypeExpand, extractInner: udttypeExtractInner, wrapRef: udttypeWrapRef }
|
|
598
619
|
};
|
|
599
620
|
function toJSONSchemaInternal(field, context) {
|
|
600
|
-
|
|
601
|
-
|
|
621
|
+
const canonicalType = resolveFormanFieldType(field.type);
|
|
622
|
+
if (!canonicalType) {
|
|
623
|
+
if (context.strictFieldTypes) {
|
|
624
|
+
throw new SchemaConversionError(
|
|
625
|
+
field.type ? `Unknown field type: ${field.type}` : "Field type is required",
|
|
626
|
+
field
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return degradeUnconvertibleField(field, context);
|
|
630
|
+
}
|
|
631
|
+
const normalizedField = normalizeFormanFieldType(
|
|
632
|
+
canonicalType === field.type ? field : { ...field, type: canonicalTypeWithKind(field.type, canonicalType) }
|
|
633
|
+
);
|
|
602
634
|
const handler = compositeHandlers[normalizedField.type];
|
|
603
635
|
if (handler) {
|
|
604
636
|
const type = normalizedField.type;
|
|
@@ -1013,6 +1045,16 @@ function handleJsonType(field, result) {
|
|
|
1013
1045
|
});
|
|
1014
1046
|
return result;
|
|
1015
1047
|
}
|
|
1048
|
+
function degradeUnconvertibleField(field, context) {
|
|
1049
|
+
const path = field.name ? [...context.path, field.name].join(".") : context.path.join(".");
|
|
1050
|
+
const reason = field.type ? `unknown type: ${field.type}` : "missing type";
|
|
1051
|
+
(context.skippedPaths.unconvertible ||= []).push(`${path} (${reason})`);
|
|
1052
|
+
return {
|
|
1053
|
+
type: FORMAN_TYPE_MAP["any"],
|
|
1054
|
+
title: noEmpty(field.label),
|
|
1055
|
+
description: noEmpty(field.help)
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1016
1058
|
function handlePrimitiveType(field, result, context) {
|
|
1017
1059
|
if (field.default !== "" && field.default != null) {
|
|
1018
1060
|
result.default = field.default;
|
|
@@ -1053,7 +1095,7 @@ function processRpcDirective(field, result, context) {
|
|
|
1053
1095
|
return result;
|
|
1054
1096
|
}
|
|
1055
1097
|
function extractNestedAndDomain(field) {
|
|
1056
|
-
const nested = isObject(field.options) ? isObject(field.options.nested) ? field.options.nested.store : field.options.nested : isObject(field.nested) ? field.nested.store : field.nested;
|
|
1098
|
+
const nested = isObject(field.options) ? isObject(field.options.nested) ? field.options.nested.store : field.options.nested : isBooleanBranchNested(field.nested) ? void 0 : isObject(field.nested) ? field.nested.store : field.nested;
|
|
1057
1099
|
const domain = isObject(field.options) ? isObject(field.options.nested) && field.options.nested.domain ? field.options.nested.domain : void 0 : isObject(field.nested) && field.nested.domain ? field.nested.domain : void 0;
|
|
1058
1100
|
return { nested, domain };
|
|
1059
1101
|
}
|
|
@@ -1322,6 +1364,13 @@ async function validateFormanWithDomainsInternal(domains, options) {
|
|
|
1322
1364
|
};
|
|
1323
1365
|
}
|
|
1324
1366
|
async function validateFormanValue(value, field, context) {
|
|
1367
|
+
if (context.registerOnly) {
|
|
1368
|
+
return {
|
|
1369
|
+
valid: true,
|
|
1370
|
+
errors: [],
|
|
1371
|
+
warnings: []
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1325
1374
|
if (isVisualType(field.type)) {
|
|
1326
1375
|
return {
|
|
1327
1376
|
valid: true,
|
|
@@ -1329,8 +1378,21 @@ async function validateFormanValue(value, field, context) {
|
|
|
1329
1378
|
warnings: []
|
|
1330
1379
|
};
|
|
1331
1380
|
}
|
|
1381
|
+
if (!field.type) {
|
|
1382
|
+
return {
|
|
1383
|
+
valid: false,
|
|
1384
|
+
errors: [
|
|
1385
|
+
{
|
|
1386
|
+
domain: context.domain,
|
|
1387
|
+
path: context.path.join("."),
|
|
1388
|
+
message: "Field type is required."
|
|
1389
|
+
}
|
|
1390
|
+
],
|
|
1391
|
+
warnings: []
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1332
1394
|
const normalizedField = normalizeFormanFieldType(field);
|
|
1333
|
-
if (normalizedField.required && (value == null || value === "")) {
|
|
1395
|
+
if (normalizedField.required && !context.suppressRequired && (value == null || value === "")) {
|
|
1334
1396
|
return {
|
|
1335
1397
|
valid: false,
|
|
1336
1398
|
errors: [
|
|
@@ -1500,7 +1562,7 @@ async function handleCollectionType2(value, field, context) {
|
|
|
1500
1562
|
continue;
|
|
1501
1563
|
}
|
|
1502
1564
|
if (context2.strict && !seen.has(subField2.name)) seen.add(subField2.name);
|
|
1503
|
-
if (path.length === 0) {
|
|
1565
|
+
if (path.length === 0 && !context2.registerOnly) {
|
|
1504
1566
|
context2.roots[context2.domain].schemaFields.push(clampFieldForSchema(subField2));
|
|
1505
1567
|
}
|
|
1506
1568
|
const result2 = await validateFormanValue(value[subField2.name], subField2, {
|
|
@@ -2093,7 +2155,7 @@ async function handlePrimitiveType2(value, field, context) {
|
|
|
2093
2155
|
}
|
|
2094
2156
|
const nested = extractNestedFromField(field);
|
|
2095
2157
|
if (nested) {
|
|
2096
|
-
const result = await handleNestedFields(nested, value, field, context);
|
|
2158
|
+
const result = FORMAN_TYPE_MAP2[field.type] === "boolean" ? await handleBooleanNestedFields(nested, value, field, context) : await handleNestedFields(nested, value, field, context);
|
|
2097
2159
|
errors.push(...result.errors);
|
|
2098
2160
|
warnings.push(...result.warnings);
|
|
2099
2161
|
}
|
|
@@ -2103,6 +2165,19 @@ async function handlePrimitiveType2(value, field, context) {
|
|
|
2103
2165
|
warnings
|
|
2104
2166
|
};
|
|
2105
2167
|
}
|
|
2168
|
+
async function handleBooleanNestedFields(nested, value, field, context) {
|
|
2169
|
+
if (isBooleanBranchNested(nested)) {
|
|
2170
|
+
const active2 = value === false ? nested.false : nested.true;
|
|
2171
|
+
const inactive = value === false ? nested.true : nested.false;
|
|
2172
|
+
const result = active2 ? await handleNestedFields(active2, value, field, context) : { valid: true, errors: [], warnings: [] };
|
|
2173
|
+
if (context.strict && inactive) {
|
|
2174
|
+
await handleNestedFields(inactive, value, field, { ...context, registerOnly: true });
|
|
2175
|
+
}
|
|
2176
|
+
return result;
|
|
2177
|
+
}
|
|
2178
|
+
const active = field.reversedNested === true ? value === false : value === true;
|
|
2179
|
+
return handleNestedFields(nested, value, field, active ? context : { ...context, suppressRequired: true });
|
|
2180
|
+
}
|
|
2106
2181
|
|
|
2107
2182
|
// src/json.ts
|
|
2108
2183
|
var JSON_PRIMITIVE_TYPE_MAP = {
|
|
@@ -2274,6 +2349,9 @@ function toJSONSchemaAdvanced(field, options) {
|
|
|
2274
2349
|
if (context.skippedPaths.advanced?.length) {
|
|
2275
2350
|
skippedPaths.advanced = context.skippedPaths.advanced;
|
|
2276
2351
|
}
|
|
2352
|
+
if (context.skippedPaths.unconvertible?.length) {
|
|
2353
|
+
skippedPaths.unconvertible = context.skippedPaths.unconvertible;
|
|
2354
|
+
}
|
|
2277
2355
|
return {
|
|
2278
2356
|
schema,
|
|
2279
2357
|
...Object.keys(skippedPaths).length > 0 ? { skippedPaths } : {}
|
|
@@ -2292,6 +2370,8 @@ function validateForman(values, schema, options, restoreExtras) {
|
|
|
2292
2370
|
);
|
|
2293
2371
|
}
|
|
2294
2372
|
export {
|
|
2373
|
+
SchemaConversionError,
|
|
2374
|
+
resolveFormanFieldType,
|
|
2295
2375
|
toFormanSchema,
|
|
2296
2376
|
toJSONSchema,
|
|
2297
2377
|
toJSONSchemaAdvanced,
|