@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 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. It includes a message and optionally the field that caused the error.
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
- function validateFormanField(field) {
595
- if (!field.type) {
596
- throw new SchemaConversionError("Field type is required", field);
597
- }
598
- const normalizedType = field.type.includes(":") ? field.type.split(":")[0] : field.type;
599
- if (!Object.keys(FORMAN_TYPE_MAP).includes(normalizedType)) {
600
- throw new SchemaConversionError(`Unknown field type: ${field.type}`, field);
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
- validateFormanField(field);
631
- const normalizedField = normalizeFormanFieldType(field);
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
- /** Nested fields */
51
- nested?: FormanSchemaNested;
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
- /** Nested fields */
51
- nested?: FormanSchemaNested;
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
- function validateFormanField(field) {
565
- if (!field.type) {
566
- throw new SchemaConversionError("Field type is required", field);
567
- }
568
- const normalizedType = field.type.includes(":") ? field.type.split(":")[0] : field.type;
569
- if (!Object.keys(FORMAN_TYPE_MAP).includes(normalizedType)) {
570
- throw new SchemaConversionError(`Unknown field type: ${field.type}`, field);
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
- validateFormanField(field);
601
- const normalizedField = normalizeFormanFieldType(field);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makehq/forman-schema",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "Forman Schema Tools",
5
5
  "license": "MIT",
6
6
  "author": "Make",