@quazardous/qdadm 2.4.5 → 2.6.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
@@ -137,6 +137,8 @@ This is the canonical Vite/Webpack pattern for Vue plugin singletons. The `file:
137
137
 
138
138
  Installing qdadm from npm (`@quazardous/qdadm@^1.19.3`) doesn't have this issue: the published tarball ships no `node_modules/`, so peers always resolve from the host. The trap is specific to `file:` / `workspace:` links onto a package that lives inside another monorepo.
139
139
 
140
+ One more caveat: qdadm ships TypeScript sources, so with a `file:` link your `vue-tsc` / lint / test counters measure qdadm's **working tree**, not a released version. Any local qdadm edit moves your numbers with zero change on your side — don't calibrate quality ratchets or CI thresholds against a linked checkout; pin them to the published package your CI actually installs.
141
+
140
142
  ## License
141
143
 
142
144
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.4.5",
3
+ "version": "2.6.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -22,7 +22,13 @@ type JsonValue = Record<string, unknown> | unknown[] | string | null
22
22
 
23
23
  interface Props {
24
24
  modelValue?: JsonValue
25
- mode?: Mode
25
+ /**
26
+ * Editor mode. String literals are accepted (#1253) so consumers never
27
+ * need to import the `Mode` enum from vanilla-jsoneditor (which is not
28
+ * a direct dependency of theirs); `Mode` itself is re-exported from
29
+ * '@quazardous/qdadm/editors' for those who want it.
30
+ */
31
+ mode?: Mode | 'tree' | 'text' | 'table'
26
32
  height?: string
27
33
  readOnly?: boolean
28
34
  mainMenuBar?: boolean
@@ -40,7 +46,7 @@ interface Props {
40
46
 
41
47
  const props = withDefaults(defineProps<Props>(), {
42
48
  modelValue: () => ({}),
43
- mode: 'tree' as Mode,
49
+ mode: 'tree',
44
50
  height: '400px',
45
51
  readOnly: false,
46
52
  mainMenuBar: true,
@@ -100,7 +106,7 @@ onMounted(() => {
100
106
  target: containerRef.value,
101
107
  props: {
102
108
  content,
103
- mode: props.mode,
109
+ mode: props.mode as Mode,
104
110
  readOnly: props.readOnly,
105
111
  mainMenuBar: props.mainMenuBar,
106
112
  navigationBar: props.navigationBar,
@@ -161,9 +167,9 @@ watch(() => props.modelValue, (newVal: JsonValue | undefined) => {
161
167
  }, { deep: true })
162
168
 
163
169
  // Watch mode changes
164
- watch(() => props.mode, (newMode: Mode | undefined) => {
170
+ watch(() => props.mode, (newMode) => {
165
171
  if (editor && newMode) {
166
- editor.updateProps({ mode: newMode })
172
+ editor.updateProps({ mode: newMode as Mode })
167
173
  }
168
174
  })
169
175
 
@@ -35,6 +35,7 @@ import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } fr
35
35
  import { useActiveStack } from '../chain/useActiveStack.js'
36
36
  import { useI18n } from '../i18n/useI18n'
37
37
  import { formatDateOnly } from '../utils/formatters'
38
+ import { humanizeFieldName } from '../utils/humanize'
38
39
 
39
40
  // Import types from dedicated types file
40
41
  import type {
@@ -260,26 +261,47 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
260
261
  // when the i18n bundle has no translation for the field.
261
262
  const columnInlineHeaders = new Map<string, string | undefined>()
262
263
 
264
+ // One-shot per entity+field (#1255): the catalog winning over an explicit
265
+ // header is by design, but it must never happen silently — the day a key
266
+ // appears, the page author learns which inline header it shadows.
267
+ const warnedHeaderShadows = new Set<string>()
268
+ function warnHeaderShadow(field: string, i18nValue: string, explicit: string | undefined): void {
269
+ if (explicit === undefined || explicit === i18nValue) return
270
+ const key = `${entity}.${field}`
271
+ if (warnedHeaderShadows.has(key)) return
272
+ warnedHeaderShadows.add(key)
273
+ console.warn(
274
+ `[qdadm] column '${field}' (${entity}): i18n key entities.${entity}.fields.${field} ` +
275
+ `("${i18nValue}") shadows explicit header ("${explicit}") — the catalog wins by design; ` +
276
+ `drop the inline header or remove the key.`
277
+ )
278
+ }
279
+
263
280
  /**
264
- * Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins,
281
+ * Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins
282
+ * (the inline `header` is the no-key fallback, not an absolute override),
265
283
  * otherwise the inline `header` option, otherwise a capitalized field name.
266
284
  */
267
285
  function resolveColumnHeader(field: string, inline?: string): string {
268
286
  if (entity && kernelI18n) {
269
287
  const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
270
- if (trace.hit) return trace.result
288
+ if (trace.hit) {
289
+ warnHeaderShadow(field, trace.result, inline)
290
+ return trace.result
291
+ }
271
292
  }
272
293
  return inline || field.charAt(0).toUpperCase() + field.slice(1)
273
294
  }
274
295
 
275
296
  function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
276
297
  columnInlineHeaders.set(field, columnConfig.header)
298
+ // Resolved header LAST so the catalog wins at registration too (#1255) —
299
+ // previously the inline header survived until the first locale change
300
+ // flipped it to the i18n value, silently and inconsistently.
277
301
  columnsMap.value.set(field, {
278
302
  field,
279
- header: resolveColumnHeader(field, columnConfig.header),
280
303
  ...columnConfig,
281
- // Re-apply resolved header after spread so it wins over a stale inline.
282
- ...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
304
+ header: resolveColumnHeader(field, columnConfig.header),
283
305
  })
284
306
  }
285
307
 
@@ -294,6 +316,57 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
294
316
  }
295
317
  })
296
318
 
319
+ /**
320
+ * Template-side column binding helper (#1255): spread onto a PrimeVue
321
+ * `<Column>` so `field` + `header` come from one source instead of being
322
+ * retyped in every list template:
323
+ *
324
+ * ```html
325
+ * <Column v-bind="list.column('botUuid')" sortable>
326
+ * <template #body="{ data }">...</template>
327
+ * </Column>
328
+ * ```
329
+ *
330
+ * Header resolution: i18n key (`entities.{entity}.fields.{field}`) >
331
+ * `overrides.header` > inline header given to `addColumn` >
332
+ * `manager.fields[name].label` > humanized field name.
333
+ *
334
+ * NOTE the precedence truth (#1255): an inline `header` is the fallback
335
+ * used when no i18n key exists — NOT an absolute override. The catalog
336
+ * wins by design (or a hardcoded header would pin the column to one
337
+ * language); to pin a header, don't create the key. When a key shadows
338
+ * an explicit header with a different value, a one-shot dev warning
339
+ * announces it instead of letting the header change silently.
340
+ *
341
+ * Pure read — it never registers into columnsMap, so calling it during
342
+ * render is safe.
343
+ */
344
+ function column(name: string, overrides: Partial<ColumnConfig> = {}): ColumnConfig {
345
+ // Depend on the locale so headers re-render on locale change.
346
+ void i18nLocale.value
347
+
348
+ let header: string | undefined
349
+ if (entity && kernelI18n) {
350
+ const trace = kernelI18n.resolve(`entities.${entity}.fields.${name}`)
351
+ if (trace.hit) {
352
+ header = trace.result
353
+ // Both explicit sources are checked: per-call override AND the
354
+ // inline header registered via addColumn (values compared — a key
355
+ // that merely copies the header stays silent).
356
+ warnHeaderShadow(name, trace.result, overrides.header ?? columnInlineHeaders.get(name))
357
+ }
358
+ }
359
+ const fieldConfig = manager.getFieldConfig(name) as { label?: string } | null | undefined
360
+ header ??=
361
+ overrides.header ??
362
+ columnInlineHeaders.get(name) ??
363
+ fieldConfig?.label ??
364
+ humanizeFieldName(name)
365
+
366
+ const registered = columnsMap.value.get(name)
367
+ return { ...registered, ...overrides, field: name, header }
368
+ }
369
+
297
370
  function removeColumn(field: string): void {
298
371
  columnsMap.value.delete(field)
299
372
  }
@@ -1111,6 +1184,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1111
1184
 
1112
1185
  // Columns
1113
1186
  columns,
1187
+ column,
1114
1188
  addColumn,
1115
1189
  removeColumn,
1116
1190
  updateColumn,
@@ -61,6 +61,11 @@ export interface ConfirmService {
61
61
  */
62
62
  export interface ColumnConfig {
63
63
  field: string
64
+ /**
65
+ * Column header. When an i18n key `entities.{entity}.fields.{field}`
66
+ * exists, the catalog WINS — an inline header is the no-key fallback,
67
+ * not an absolute override (#1255). To pin a header, don't create the key.
68
+ */
64
69
  header?: string
65
70
  sortable?: boolean
66
71
  style?: string
@@ -347,6 +352,8 @@ export interface UseListPageReturn<T = unknown> {
347
352
 
348
353
  // Columns
349
354
  columns: ComputedRef<ColumnConfig[]>
355
+ /** v-bind column binding helper (#1255): field + header from one source */
356
+ column: (name: string, overrides?: Partial<ColumnConfig>) => ColumnConfig
350
357
  addColumn: (field: string, config?: Partial<ColumnConfig>) => void
351
358
  removeColumn: (field: string) => void
352
359
  updateColumn: (field: string, updates: Partial<ColumnConfig>) => void
@@ -10,3 +10,8 @@
10
10
 
11
11
  export { default as VanillaJsonEditor } from '../components/editors/VanillaJsonEditor.vue'
12
12
  export { default as JsonStructuredField } from '../components/editors/JsonStructuredField.vue'
13
+
14
+ // Re-export the mode enum so consumers who want it don't need to depend on
15
+ // vanilla-jsoneditor directly (#1253). String literals ('tree' | 'text' |
16
+ // 'table') are accepted by VanillaJsonEditor's `mode` prop anyway.
17
+ export { Mode } from 'vanilla-jsoneditor'
@@ -13,6 +13,15 @@
13
13
  *
14
14
  * All interfaces accept an optional generic parameter `T` (default: `unknown`)
15
15
  * that flows through to method signatures for typed entity data.
16
+ *
17
+ * ## Optionality contract (#1253)
18
+ *
19
+ * These views describe the IMPLEMENTER'S MINIMUM, not a mirror of the
20
+ * EntityManager class. A member is REQUIRED iff qdadm's own composables
21
+ * call it unguarded (or capability coherence demands it — e.g. the four
22
+ * can* permission checks travel together). A member stays OPTIONAL only
23
+ * when it is a genuine capability a minimal manager-like need not carry;
24
+ * the reason is documented inline. `?` means "conditional", never "lazy".
16
25
  */
17
26
 
18
27
  import type { EntityBadge } from './EntityManager.types'
@@ -32,6 +41,9 @@ export interface EntityManagerBase<T = unknown> {
32
41
  labelField?: string | ((entity: T) => string)
33
42
  readOnly?: boolean
34
43
  getEntityLabel: (data: T) => string
44
+ // Optional BY DESIGN (#1253): part of the badge/severity presentation
45
+ // capability; call sites guard (e.g. useEntityItemShowPage checks
46
+ // `if (!manager.getEntityBadges)`).
35
47
  getEntityBadges?: (data: T) => Array<{ label: string; severity?: string }>
36
48
  }
37
49
 
@@ -41,7 +53,10 @@ export interface EntityManagerBase<T = unknown> {
41
53
  * Permission checks — used by guards, list pages, forms.
42
54
  */
43
55
  export interface EntityManagerPermissions<T = unknown> {
44
- canRead?: (entity?: T) => boolean
56
+ // The four checks are ONE capability — a manager-like answering canUpdate
57
+ // but not canRead is not a smaller contract, it's an incoherent one. The
58
+ // `?` on canRead was a #1191 leftover (#1253).
59
+ canRead: (entity?: T) => boolean
45
60
  canCreate: () => boolean
46
61
  canUpdate: (entity?: T) => boolean
47
62
  canDelete: (entity?: T) => boolean
@@ -55,11 +70,18 @@ export interface EntityManagerPermissions<T = unknown> {
55
70
  export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, EntityManagerPermissions<T> {
56
71
  get: (id: string | number, context?: unknown) => Promise<T>
57
72
  list: (params?: unknown, context?: unknown) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
58
- query?: (params?: unknown, options?: { routingContext?: unknown }) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
73
+ // query/invalidateCache/getFieldConfig are required since #1253: they are
74
+ // unconditionally implemented on the class (prototype-applied) and called
75
+ // unguarded by list pages — the `?` was a #1191 leftover that forced
76
+ // consumer-side casts on base methods.
77
+ query: (params?: unknown, options?: { routingContext?: unknown }) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
59
78
  delete: (id: string | number, context?: unknown) => Promise<void>
60
79
  request: (method: string, path: string, options?: { data?: unknown }) => Promise<unknown>
61
- invalidateCache?: () => void
62
- localFilterThreshold?: number
80
+ invalidateCache: () => void
81
+ /** Field config lookup — read-side too since list column binding (#1255) */
82
+ getFieldConfig: (name: string) => unknown | null
83
+ /** Config-carrying property: always present on the class, VALUE nullable */
84
+ localFilterThreshold?: number | null
63
85
  }
64
86
 
65
87
  // ─── CRUD ────────────────────────────────────────────────────────────────────
@@ -71,11 +93,14 @@ export interface EntityManagerCrud<T = unknown> extends EntityManagerRead<T> {
71
93
  fields?: Record<string, unknown> | unknown[]
72
94
  getInitialData: () => Record<string, unknown>
73
95
  getFormFields: () => Array<{ name: string; [key: string]: unknown }>
74
- getFieldConfig: (name: string) => unknown | null
96
+ // getFieldConfig inherited (required) from EntityManagerRead since #1253
75
97
  resolveReferenceOptions: (fieldName: string) => Promise<unknown[]>
76
98
  create: (data: unknown, context?: unknown) => Promise<T>
77
99
  update: (id: string | number, data: unknown, context?: unknown) => Promise<T>
78
100
  patch: (id: string | number, data: unknown, context?: unknown) => Promise<T>
101
+ // Optional BY DESIGN (#1253): the severity/badge presentation capability —
102
+ // a minimal manager-like need not carry it; every qdadm call site guards
103
+ // (`?.` or truthiness). The canonical class always provides it.
79
104
  hasSeverityMap?: (field: string) => boolean
80
105
  getSeverity?: (field: string, value: string | number, defaultSeverity?: string) => string
81
106
  getSeverityDescriptor?: (field: string, value: string | number, defaultSeverity?: string) => { severity: string; icon?: string; label?: string }
@@ -34,6 +34,8 @@ import type { NullSortMode } from '../query/clientFilter'
34
34
 
35
35
  // Re-export public types from types file
36
36
  export type {
37
+ StorageResolution,
38
+ ResolvedStorage,
37
39
  RoutingContext,
38
40
  PresaveContext,
39
41
  PostsaveContext,
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { describe, it, expect, vi } from 'vitest'
2
2
  import { OpenAPIConnector } from './OpenAPIConnector'
3
3
  import sampleSpec from './__fixtures__/sample-openapi.json'
4
4
 
@@ -584,6 +584,258 @@ describe('OpenAPIConnector', () => {
584
584
 
585
585
  expect(result.warnings.some(w => w.code === 'UNRESOLVED_REF')).toBe(true)
586
586
  })
587
+
588
+ describe('EMPTY_OBJECT_SCHEMA detection (#1240)', () => {
589
+ // Build a spec with a single tasks entity whose `payload` field uses
590
+ // the given schema node.
591
+ function specWithPayload(payloadSchema) {
592
+ return {
593
+ openapi: '3.0.0',
594
+ paths: {
595
+ '/api/tasks/{id}': {
596
+ get: {
597
+ responses: {
598
+ '200': {
599
+ content: {
600
+ 'application/json': {
601
+ schema: {
602
+ type: 'object',
603
+ properties: {
604
+ data: {
605
+ type: 'object',
606
+ properties: {
607
+ id: { type: 'string' },
608
+ payload: payloadSchema
609
+ }
610
+ }
611
+ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ function warningsFor(payloadSchema) {
624
+ const connector = new OpenAPIConnector()
625
+ const result = connector.parseWithWarnings(specWithPayload(payloadSchema))
626
+ return result.warnings.filter(w => w.code === 'EMPTY_OBJECT_SCHEMA')
627
+ }
628
+
629
+ it('warns on a bare object schema (no properties, no additionalProperties)', () => {
630
+ const warnings = warningsFor({ type: 'object' })
631
+ expect(warnings).toHaveLength(1)
632
+ expect(warnings[0].path).toBe('/api/tasks/{id}#payload')
633
+ expect(warnings[0].message).toContain('additionalProperties: true')
634
+ })
635
+
636
+ it('warns when properties is present but empty', () => {
637
+ expect(warningsFor({ type: 'object', properties: {} })).toHaveLength(1)
638
+ })
639
+
640
+ it('does not warn when additionalProperties is true', () => {
641
+ expect(warningsFor({ type: 'object', additionalProperties: true })).toHaveLength(0)
642
+ })
643
+
644
+ it('does not warn when additionalProperties is a sub-schema', () => {
645
+ expect(
646
+ warningsFor({ type: 'object', additionalProperties: { type: 'string' } })
647
+ ).toHaveLength(0)
648
+ })
649
+
650
+ it('does not warn when the object declares properties', () => {
651
+ expect(
652
+ warningsFor({ type: 'object', properties: { targets: { type: 'array' } } })
653
+ ).toHaveLength(0)
654
+ })
655
+
656
+ it('does not warn on composition schemas (oneOf/anyOf/allOf)', () => {
657
+ expect(
658
+ warningsFor({ type: 'object', oneOf: [{ type: 'object', properties: { a: { type: 'string' } } }] })
659
+ ).toHaveLength(0)
660
+ expect(
661
+ warningsFor({ type: 'object', allOf: [{ type: 'object', properties: { a: { type: 'string' } } }] })
662
+ ).toHaveLength(0)
663
+ })
664
+
665
+ it('does not warn on non-object types', () => {
666
+ expect(warningsFor({ type: 'string' })).toHaveLength(0)
667
+ })
668
+
669
+ it('still generates the field as a usable object type', () => {
670
+ const connector = new OpenAPIConnector()
671
+ const { schemas } = connector.parseWithWarnings(specWithPayload({ type: 'object' }))
672
+ const tasks = schemas.find(e => e.name === 'tasks')
673
+ expect(tasks.fields.payload).toBeDefined()
674
+ expect(tasks.fields.payload.type).toBe('object')
675
+ })
676
+
677
+ it('parse() logs collected warnings to console.warn', () => {
678
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
679
+ try {
680
+ const connector = new OpenAPIConnector()
681
+ connector.parse(specWithPayload({ type: 'object' }))
682
+ const calls = warnSpy.mock.calls.map(args => args.join(' '))
683
+ expect(calls.some(line => line.includes('EMPTY') || line.includes('additionalProperties'))).toBe(true)
684
+ expect(calls.some(line => line.includes('/api/tasks/{id}#payload'))).toBe(true)
685
+ } finally {
686
+ warnSpy.mockRestore()
687
+ }
688
+ })
689
+ })
690
+ })
691
+
692
+ describe('inference options (#1255)', () => {
693
+ // /api/bots: GET item + POST with a partial body; lastSeen/uuid are
694
+ // response-only, name is writable, status declares readOnly in-schema.
695
+ const botsSpec = {
696
+ openapi: '3.0.0',
697
+ paths: {
698
+ '/api/bots/{id}': {
699
+ get: {
700
+ responses: {
701
+ '200': {
702
+ content: {
703
+ 'application/json': {
704
+ schema: {
705
+ type: 'object',
706
+ properties: {
707
+ data: {
708
+ type: 'object',
709
+ properties: {
710
+ uuid: { type: 'string', format: 'uuid' },
711
+ name: { type: 'string' },
712
+ lastSeen: { type: 'string', format: 'date-time' },
713
+ status: { type: 'string', readOnly: true },
714
+ notes: { type: 'string', description: 'Operator notes' }
715
+ }
716
+ }
717
+ }
718
+ }
719
+ }
720
+ }
721
+ }
722
+ }
723
+ }
724
+ },
725
+ '/api/bots': {
726
+ post: {
727
+ requestBody: {
728
+ content: {
729
+ 'application/json': {
730
+ schema: {
731
+ type: 'object',
732
+ properties: {
733
+ name: { type: 'string' },
734
+ notes: { type: 'string', description: 'Operator notes' }
735
+ }
736
+ }
737
+ }
738
+ }
739
+ },
740
+ responses: {
741
+ '200': {
742
+ content: {
743
+ 'application/json': {
744
+ schema: { type: 'object', properties: { data: { type: 'object', properties: { uuid: { type: 'string' } } } } }
745
+ }
746
+ }
747
+ }
748
+ }
749
+ }
750
+ }
751
+ }
752
+ }
753
+
754
+ // Read-only entity: no write operation at all
755
+ const logsSpec = {
756
+ openapi: '3.0.0',
757
+ paths: {
758
+ '/api/logs': {
759
+ get: {
760
+ responses: {
761
+ '200': {
762
+ content: {
763
+ 'application/json': {
764
+ schema: {
765
+ type: 'object',
766
+ properties: {
767
+ data: {
768
+ type: 'array',
769
+ items: {
770
+ type: 'object',
771
+ properties: {
772
+ id: { type: 'string' },
773
+ message: { type: 'string' }
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ }
784
+ }
785
+ }
786
+ }
787
+
788
+ describe('inferLabels', () => {
789
+ it('is off by default (label only from description)', () => {
790
+ const connector = new OpenAPIConnector()
791
+ const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
792
+
793
+ expect(bots.fields.lastSeen.label).toBeUndefined()
794
+ expect(bots.fields.notes.label).toBe('Operator notes')
795
+ })
796
+
797
+ it('humanizes field names when enabled and description is absent', () => {
798
+ const connector = new OpenAPIConnector({ inferLabels: 'humanize' })
799
+ const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
800
+
801
+ expect(bots.fields.lastSeen.label).toBe('Last Seen')
802
+ expect(bots.fields.uuid.label).toBe('Uuid')
803
+ // description still wins
804
+ expect(bots.fields.notes.label).toBe('Operator notes')
805
+ })
806
+ })
807
+
808
+ describe('inferReadOnly', () => {
809
+ it('is off by default (readOnly only from schema)', () => {
810
+ const connector = new OpenAPIConnector()
811
+ const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
812
+
813
+ expect(bots.fields.lastSeen.readOnly).toBe(false)
814
+ expect(bots.fields.status.readOnly).toBe(true)
815
+ })
816
+
817
+ it('marks response-only fields readOnly when enabled', () => {
818
+ const connector = new OpenAPIConnector({ inferReadOnly: true })
819
+ const bots = connector.parse(botsSpec).find(e => e.name === 'bots')
820
+
821
+ // never in a request body → inferred readOnly
822
+ expect(bots.fields.uuid.readOnly).toBe(true)
823
+ expect(bots.fields.lastSeen.readOnly).toBe(true)
824
+ // present in the POST body → stays writable
825
+ expect(bots.fields.name.readOnly).toBe(false)
826
+ expect(bots.fields.notes.readOnly).toBe(false)
827
+ // schema-declared readOnly preserved
828
+ expect(bots.fields.status.readOnly).toBe(true)
829
+ })
830
+
831
+ it('marks every field readOnly for entities without write operations', () => {
832
+ const connector = new OpenAPIConnector({ inferReadOnly: true })
833
+ const logs = connector.parse(logsSpec).find(e => e.name === 'logs')
834
+
835
+ expect(logs.fields.id.readOnly).toBe(true)
836
+ expect(logs.fields.message.readOnly).toBe(true)
837
+ })
838
+ })
587
839
  })
588
840
 
589
841
  describe('extensions option', () => {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { BaseConnector, type ConnectorOptions, type ParseResult, type ParseWarning } from './BaseConnector'
11
11
  import { getDefaultType, type CustomMappings } from '../FieldMapper'
12
+ import { humanizeFieldName } from '../../utils/humanize'
12
13
  import type { UnifiedEntitySchema, UnifiedFieldSchema, UnifiedFieldType } from '../schema'
13
14
 
14
15
  /**
@@ -48,6 +49,19 @@ export interface OpenAPIConnectorOptions extends ConnectorOptions {
48
49
  operationFilter?: OperationFilter | null
49
50
  /** Custom type mappings for FieldMapper */
50
51
  customMappings?: CustomMappings
52
+ /**
53
+ * Infer a label from the field name when the schema has no `description`
54
+ * (#1255): 'humanize' turns `botUuid` into "Bot Uuid". Opt-in — it changes
55
+ * generated output. Default: off (label only from `description`).
56
+ */
57
+ inferLabels?: 'humanize' | false
58
+ /**
59
+ * Infer `readOnly` from operation shape (#1255): a field that appears in
60
+ * responses but in no request-body schema is marked `readOnly: true`
61
+ * (an entity with no write operations gets all fields readOnly). Opt-in —
62
+ * it changes generated output. Schema-declared `readOnly` always wins.
63
+ */
64
+ inferReadOnly?: boolean
51
65
  }
52
66
 
53
67
  /**
@@ -64,6 +78,8 @@ interface EntityWorkingData {
64
78
  rawSchema: OpenAPISchema | null
65
79
  /** Extracted fields */
66
80
  fields: Map<string, UnifiedFieldSchema>
81
+ /** Field names seen in request-body schemas (for readOnly inference, #1255) */
82
+ requestFields: Set<string>
67
83
  }
68
84
 
69
85
  /**
@@ -173,6 +189,12 @@ export class OpenAPIConnector extends BaseConnector {
173
189
  /** Custom type mappings */
174
190
  customMappings: CustomMappings
175
191
 
192
+ /** Label inference mode (#1255) */
193
+ inferLabels: 'humanize' | false
194
+
195
+ /** Whether to infer readOnly from operation shape (#1255) */
196
+ inferReadOnly: boolean
197
+
176
198
  /**
177
199
  * Create a new OpenAPI connector
178
200
  *
@@ -185,6 +207,8 @@ export class OpenAPIConnector extends BaseConnector {
185
207
  this.dataWrapper = options.dataWrapper ?? 'data'
186
208
  this.operationFilter = options.operationFilter || null
187
209
  this.customMappings = options.customMappings || {}
210
+ this.inferLabels = options.inferLabels ?? false
211
+ this.inferReadOnly = options.inferReadOnly ?? false
188
212
  }
189
213
 
190
214
  /**
@@ -201,10 +225,11 @@ export class OpenAPIConnector extends BaseConnector {
201
225
  * })
202
226
  */
203
227
  parse(source: unknown): UnifiedEntitySchema[] {
204
- const spec = source as OpenAPISpec
205
- this._validateSource(spec)
206
- const entities = this._extractEntities(spec)
207
- return Array.from(entities.values())
228
+ const { schemas, warnings } = this.parseWithWarnings(source)
229
+ for (const warning of warnings) {
230
+ console.warn(`⚠ [${this.name}] ${warning.path}: ${warning.message}`)
231
+ }
232
+ return schemas
208
233
  }
209
234
 
210
235
  /**
@@ -267,6 +292,7 @@ export class OpenAPIConnector extends BaseConnector {
267
292
  idField: null,
268
293
  rawSchema: null,
269
294
  fields: new Map(),
295
+ requestFields: new Set(),
270
296
  })
271
297
  }
272
298
 
@@ -371,6 +397,8 @@ export class OpenAPIConnector extends BaseConnector {
371
397
  const requestSchema = this._extractRequestSchema(op, spec)
372
398
  if (requestSchema) {
373
399
  this._extractFields(requestSchema, entity.fields, spec, warnings, path)
400
+ // Track writable field names for readOnly inference (#1255)
401
+ this._collectFieldNames(requestSchema, spec, entity.requestFields)
374
402
  }
375
403
  }
376
404
  }
@@ -565,6 +593,20 @@ export class OpenAPIConnector extends BaseConnector {
565
593
  continue
566
594
  }
567
595
 
596
+ // Bare object schema: no contract at all. fast-json-stringify (Fastify)
597
+ // serializes such a field to `{}` at runtime, stripping every key (#1240).
598
+ if (this._isBareObjectSchema(resolvedSchema)) {
599
+ warnings.push({
600
+ path: `${sourcePath}#${fieldName}`,
601
+ message:
602
+ `object schema has no \`properties\` and \`additionalProperties\` is not set. ` +
603
+ `Under Fastify (fast-json-stringify) this serializes to \`{}\` at runtime ` +
604
+ `and the generated field type is unusable. ` +
605
+ `For a free-form JSON blob, declare \`additionalProperties: true\`.`,
606
+ code: 'EMPTY_OBJECT_SCHEMA',
607
+ })
608
+ }
609
+
568
610
  // Use FieldMapper for type conversion
569
611
  const type = getDefaultType(resolvedSchema, this.customMappings)
570
612
 
@@ -578,6 +620,8 @@ export class OpenAPIConnector extends BaseConnector {
578
620
  // Add optional properties
579
621
  if (resolvedSchema.description) {
580
622
  field.label = resolvedSchema.description
623
+ } else if (this.inferLabels === 'humanize') {
624
+ field.label = humanizeFieldName(fieldName)
581
625
  }
582
626
  if (resolvedSchema.format) {
583
627
  field.format = resolvedSchema.format
@@ -604,6 +648,57 @@ export class OpenAPIConnector extends BaseConnector {
604
648
  }
605
649
  }
606
650
 
651
+ /**
652
+ * Collect field names from a schema the same way _extractFields names them
653
+ * (top-level + one-level-nested dotted keys). Used to track which fields
654
+ * appear in request bodies, for readOnly inference (#1255).
655
+ *
656
+ * @private
657
+ * @param schema - JSON Schema object
658
+ * @param spec - Full spec for ref resolution
659
+ * @param into - Set collecting the names
660
+ * @param prefix - Prefix for nested field names
661
+ */
662
+ private _collectFieldNames(
663
+ schema: OpenAPISchema,
664
+ spec: OpenAPISpec,
665
+ into: Set<string>,
666
+ prefix: string = ''
667
+ ): void {
668
+ if (!schema || !schema.properties) return
669
+
670
+ for (const [name, propSchema] of Object.entries(schema.properties)) {
671
+ const fieldName = prefix ? `${prefix}.${name}` : name
672
+ into.add(fieldName)
673
+
674
+ const resolvedSchema = propSchema.$ref ? this._resolveRef(propSchema.$ref, spec) : propSchema
675
+ if (resolvedSchema?.type === 'object' && resolvedSchema.properties && !prefix) {
676
+ this._collectFieldNames(resolvedSchema, spec, into, fieldName)
677
+ }
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Detect a "bare" object schema: type object with no properties, no
683
+ * additionalProperties contract, and no composition/reference that could
684
+ * supply one. Such a schema is the quasi-always-footgun case (#1240) —
685
+ * objects with `properties`, a sub-schema `additionalProperties`, `$ref`
686
+ * or oneOf/anyOf/allOf are deliberately excluded.
687
+ *
688
+ * @private
689
+ * @param schema - Resolved property schema
690
+ * @returns True if the schema is a contract-less object
691
+ */
692
+ private _isBareObjectSchema(schema: OpenAPISchema): boolean {
693
+ if (schema.type !== 'object') return false
694
+ if (schema.properties && Object.keys(schema.properties).length > 0) return false
695
+ const additionalProperties = schema.additionalProperties
696
+ if (additionalProperties === true) return false
697
+ if (additionalProperties && typeof additionalProperties === 'object') return false
698
+ if (schema.$ref || schema.oneOf || schema.anyOf || schema.allOf) return false
699
+ return true
700
+ }
701
+
607
702
  /**
608
703
  * Merge field info from secondary source into primary
609
704
  *
@@ -667,6 +762,17 @@ export class OpenAPIConnector extends BaseConnector {
667
762
  const schemas: Map<string, UnifiedEntitySchema> = new Map()
668
763
 
669
764
  for (const [name, data] of entityData) {
765
+ // readOnly inference (#1255): a field never seen in a request body is
766
+ // not writable through this API. Entities without write operations end
767
+ // up with every field readOnly. Schema-declared readOnly is preserved.
768
+ if (this.inferReadOnly) {
769
+ for (const field of data.fields.values()) {
770
+ if (!field.readOnly && !data.requestFields.has(field.name)) {
771
+ field.readOnly = true
772
+ }
773
+ }
774
+ }
775
+
670
776
  const schema: UnifiedEntitySchema = {
671
777
  name: data.name,
672
778
  endpoint: data.endpoint,
@@ -436,6 +436,84 @@ describe('generateManagers', () => {
436
436
  })
437
437
  })
438
438
 
439
+ describe('baseClass option (#1253)', () => {
440
+ function entityConfig(extra = {}) {
441
+ return {
442
+ schema: {
443
+ name: 'users',
444
+ endpoint: '/users',
445
+ fields: {
446
+ id: { name: 'id', type: 'number' },
447
+ name: { name: 'name', type: 'text', required: true }
448
+ }
449
+ },
450
+ endpoint: '/users',
451
+ storageImport: '@quazardous/qdadm',
452
+ storageClass: 'ApiStorage',
453
+ ...extra
454
+ }
455
+ }
456
+
457
+ it('class mode extends the global baseClass and imports it', async () => {
458
+ await generateManagers({
459
+ output: testOutputDir,
460
+ classMode: true,
461
+ baseClass: { import: '@/managers/SkyEntityManager', name: 'SkyEntityManager' },
462
+ entities: { users: entityConfig() }
463
+ })
464
+
465
+ const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
466
+ expect(content).toContain("import { SkyEntityManager } from '@/managers/SkyEntityManager'")
467
+ expect(content).toContain('extends SkyEntityManager<UsersEntity>')
468
+ expect(content).not.toContain("import { EntityManager } from '@quazardous/qdadm'")
469
+ // Options/record types still come from qdadm
470
+ expect(content).toContain("import type { EntityRecord, EntityManagerOptions } from '@quazardous/qdadm'")
471
+ })
472
+
473
+ it('instance mode instantiates the baseClass', async () => {
474
+ await generateManagers({
475
+ output: testOutputDir,
476
+ baseClass: { import: './base', name: 'MyBase' },
477
+ entities: { users: entityConfig() }
478
+ })
479
+
480
+ const content = await readFile(join(testOutputDir, 'usersManager.ts'), 'utf-8')
481
+ expect(content).toContain("import { MyBase } from './base'")
482
+ expect(content).toContain('new MyBase<UsersEntity>(')
483
+ expect(content).not.toContain('new EntityManager<UsersEntity>(')
484
+ })
485
+
486
+ it('per-entity baseClass overrides the global one', async () => {
487
+ await generateManagers({
488
+ output: testOutputDir,
489
+ classMode: true,
490
+ baseClass: { import: './globalBase', name: 'GlobalBase' },
491
+ entities: {
492
+ users: entityConfig({
493
+ baseClass: { import: './localBase', name: 'LocalBase' }
494
+ })
495
+ }
496
+ })
497
+
498
+ const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
499
+ expect(content).toContain("import { LocalBase } from './localBase'")
500
+ expect(content).toContain('extends LocalBase<UsersEntity>')
501
+ expect(content).not.toContain('GlobalBase')
502
+ })
503
+
504
+ it('defaults to EntityManager when no baseClass is given', async () => {
505
+ await generateManagers({
506
+ output: testOutputDir,
507
+ classMode: true,
508
+ entities: { users: entityConfig() }
509
+ })
510
+
511
+ const content = await readFile(join(testOutputDir, 'users.ts'), 'utf-8')
512
+ expect(content).toContain("import { EntityManager } from '@quazardous/qdadm'")
513
+ expect(content).toContain('extends EntityManager<UsersEntity>')
514
+ })
515
+ })
516
+
439
517
  describe('default output directory', () => {
440
518
  it('uses default output when not specified', async () => {
441
519
  // This test would write to actual src/generated/managers/
@@ -22,6 +22,21 @@ import type { EntityDecorator } from './decorators'
22
22
  */
23
23
  const DEFAULT_OUTPUT_DIR = 'src/generated/managers/'
24
24
 
25
+ /**
26
+ * Custom base class for generated managers (#1253).
27
+ *
28
+ * The class MUST extend EntityManager, stay generic over the entity type
29
+ * and pass constructor options through — the generated code does
30
+ * `class Generated<X>Manager extends <name><XEntity>` and calls
31
+ * `super({ ...schema options, storage, ...options })`.
32
+ */
33
+ export interface GenerateManagersBaseClass {
34
+ /** Import specifier (e.g. '@/managers/SkyEntityManager') */
35
+ import: string
36
+ /** Exported class name (e.g. 'SkyEntityManager') */
37
+ name: string
38
+ }
39
+
25
40
  /**
26
41
  * Entity configuration for generation
27
42
  */
@@ -40,6 +55,8 @@ export interface GenerateManagersEntityConfig {
40
55
  decorators?: EntityDecorator
41
56
  /** Generate class instead of instance (for extension) */
42
57
  classMode?: boolean
58
+ /** Base class for this entity's generated manager (overrides global) */
59
+ baseClass?: GenerateManagersBaseClass
43
60
  }
44
61
 
45
62
  /**
@@ -50,6 +67,8 @@ export interface GenerateManagersConfig {
50
67
  output?: string
51
68
  /** Generate classes instead of instances (for extension pattern) */
52
69
  classMode?: boolean
70
+ /** Base class for all generated managers (default: EntityManager) */
71
+ baseClass?: GenerateManagersBaseClass
53
72
  /** Entity configurations keyed by entity name */
54
73
  entities: Record<string, GenerateManagersEntityConfig>
55
74
  }
@@ -175,12 +194,19 @@ function serializeValue(value: unknown, indent: number = 0): string {
175
194
  function generateManagerSource(
176
195
  entityName: string,
177
196
  entityConfig: GenerateManagersEntityConfig,
178
- globalOptions: { classMode?: boolean } = {}
197
+ globalOptions: { classMode?: boolean; baseClass?: GenerateManagersBaseClass } = {}
179
198
  ): string {
180
199
  const { schema, endpoint, storageImport, storageClass, storageOptions = {}, decorators } = entityConfig
181
200
  const classMode = entityConfig.classMode ?? globalOptions.classMode ?? false
182
201
  const className = toPascalCase(entityName)
183
202
 
203
+ // Custom base class (#1253): per-entity wins over global, default EntityManager.
204
+ const baseClass = entityConfig.baseClass ?? globalOptions.baseClass
205
+ const baseName = baseClass?.name ?? 'EntityManager'
206
+ const baseImport = baseClass
207
+ ? `import { ${baseClass.name} } from '${baseClass.import}'`
208
+ : `import { EntityManager } from '@quazardous/qdadm'`
209
+
184
210
  // Build storage options object
185
211
  const fullStorageOptions: Record<string, unknown> = {
186
212
  endpoint,
@@ -240,7 +266,7 @@ function generateManagerSource(
240
266
  * Endpoint: ${endpoint}
241
267
  */
242
268
 
243
- import { EntityManager } from '@quazardous/qdadm'
269
+ ${baseImport}
244
270
  import type { EntityRecord, EntityManagerOptions } from '@quazardous/qdadm'
245
271
  import { ${storageClass} } from '${storageImport}'
246
272
 
@@ -249,9 +275,9 @@ ${entityInterface}
249
275
  /**
250
276
  * Generated base manager for ${entityName}
251
277
  *
252
- * @extends EntityManager
278
+ * @extends ${baseName}
253
279
  */
254
- export class Generated${className}Manager extends EntityManager<${className}Entity> {
280
+ export class Generated${className}Manager extends ${baseName}<${className}Entity> {
255
281
  constructor(options: Partial<EntityManagerOptions<${className}Entity>> = {}) {
256
282
  super({
257
283
  ...${serializeValue(managerOptions, 2)},
@@ -274,7 +300,7 @@ export class Generated${className}Manager extends EntityManager<${className}Enti
274
300
  * Endpoint: ${endpoint}
275
301
  */
276
302
 
277
- import { EntityManager } from '@quazardous/qdadm'
303
+ ${baseImport}
278
304
  import type { EntityRecord } from '@quazardous/qdadm'
279
305
  import { ${storageClass} } from '${storageImport}'
280
306
 
@@ -295,7 +321,7 @@ const storageOptions = ${serializeValue(fullStorageOptions, 0)}
295
321
  *
296
322
  * Provides CRUD operations for ${entityName} entity.
297
323
  */
298
- export const ${entityName}Manager = new EntityManager<${className}Entity>({
324
+ export const ${entityName}Manager = new ${baseName}<${className}Entity>({
299
325
  ...${serializeValue(managerOptions, 0)},
300
326
  storage: new ${storageClass}<${className}Entity>(storageOptions)
301
327
  })
@@ -349,7 +375,7 @@ export async function generateManagers(config: GenerateManagersConfig): Promise<
349
375
  }
350
376
 
351
377
  const outputDir = config.output || DEFAULT_OUTPUT_DIR
352
- const globalOptions = { classMode: config.classMode ?? false }
378
+ const globalOptions = { classMode: config.classMode ?? false, baseClass: config.baseClass }
353
379
  const generatedFiles: string[] = []
354
380
 
355
381
  // Validate each entity config
package/src/gen/index.ts CHANGED
@@ -63,4 +63,5 @@ export {
63
63
  generateEntityInterface,
64
64
  type GenerateManagersConfig,
65
65
  type GenerateManagersEntityConfig,
66
+ type GenerateManagersBaseClass,
66
67
  } from './generateManagers'
package/src/index.ts CHANGED
@@ -96,6 +96,8 @@ export {
96
96
  type SeverityMapValue,
97
97
  type SeverityMap,
98
98
  type RoutingContext,
99
+ type StorageResolution,
100
+ type ResolvedStorage,
99
101
  type PresaveContext,
100
102
  type PostsaveContext,
101
103
  type PredeleteContext,
@@ -104,6 +106,17 @@ export {
104
106
  type CacheInfo,
105
107
  } from './entity/EntityManager'
106
108
 
109
+ // Structural manager/orchestrator views (#1253) — the implementer's minimum
110
+ // that composables consume; import these to type manager-likes and test doubles.
111
+ export type {
112
+ EntityManagerBase,
113
+ EntityManagerPermissions,
114
+ EntityManagerRead,
115
+ EntityManagerCrud,
116
+ EntityManagerLike,
117
+ OrchestratorLike,
118
+ } from './entity/EntityManager.interface'
119
+
107
120
  // Re-export core entity types
108
121
  export type {
109
122
  EntityRecord,
@@ -150,6 +163,30 @@ export type {
150
163
  // ════════════════════════════════════════════════════════════════════════════
151
164
  export * from './orchestrator/index'
152
165
 
166
+ /**
167
+ * Typed manager registry (#1253) — augment it from your app to make
168
+ * `getManager()` / `useEntity()` return your concrete EntityManager
169
+ * subclasses instead of `EntityManager<EntityRecord>`:
170
+ *
171
+ * ```ts
172
+ * declare module '@quazardous/qdadm' {
173
+ * interface QdadmManagerRegistry {
174
+ * bots: BotsManager
175
+ * botPools: BotPoolsManager
176
+ * }
177
+ * }
178
+ * ```
179
+ *
180
+ * Entity names not declared here keep the current fallback behavior.
181
+ *
182
+ * NOTE: deliberately declared in this file (not re-exported from a
183
+ * sub-module) — TypeScript module augmentation does not merge through
184
+ * re-export aliases, and consumers augment '@quazardous/qdadm', which
185
+ * resolves here.
186
+ */
187
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
188
+ export interface QdadmManagerRegistry {}
189
+
153
190
  // ════════════════════════════════════════════════════════════════════════════
154
191
  // COMPOSABLES (excluding types that conflict with other modules)
155
192
  // ════════════════════════════════════════════════════════════════════════════
@@ -16,13 +16,24 @@ import { inject } from 'vue'
16
16
  import type { Orchestrator } from './Orchestrator'
17
17
  import type { EntityManager } from '../entity/EntityManager'
18
18
  import type { EntityRecord } from '../types'
19
+ // Type-only circular import: QdadmManagerRegistry must live in the package
20
+ // entry (module augmentation does not merge through re-export aliases).
21
+ import type { QdadmManagerRegistry } from '../index'
19
22
 
20
23
  /**
21
24
  * Return type for useOrchestrator
25
+ *
26
+ * `getManager` resolves through the consumer-augmentable
27
+ * QdadmManagerRegistry first (#1253): a declared entity name returns its
28
+ * concrete manager subclass; anything else falls back to the historical
29
+ * `EntityManager<T>` signature.
22
30
  */
23
31
  export interface UseOrchestratorReturn {
24
32
  orchestrator: Orchestrator
25
- getManager: <T extends EntityRecord = EntityRecord>(name: string) => EntityManager<T>
33
+ getManager: {
34
+ <K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
35
+ <T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
36
+ }
26
37
  hasManager: (name: string) => boolean
27
38
  }
28
39
 
@@ -46,8 +57,10 @@ export function useOrchestrator(): UseOrchestratorReturn {
46
57
  * Get an EntityManager by name
47
58
  * @param name - Entity name
48
59
  */
49
- function getManager<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T> {
50
- return orchestrator.get(name) as EntityManager<T>
60
+ function getManager<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
61
+ function getManager<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
62
+ function getManager(name: string): unknown {
63
+ return orchestrator.get(name)
51
64
  }
52
65
 
53
66
  /**
@@ -74,7 +87,9 @@ export function useOrchestrator(): UseOrchestratorReturn {
74
87
  * const { items } = await users.list()
75
88
  * ```
76
89
  */
77
- export function useEntity<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T> {
90
+ export function useEntity<K extends keyof QdadmManagerRegistry>(name: K): QdadmManagerRegistry[K]
91
+ export function useEntity<T extends EntityRecord = EntityRecord>(name: string): EntityManager<T>
92
+ export function useEntity(name: string): unknown {
78
93
  const { getManager } = useOrchestrator()
79
- return getManager<T>(name)
94
+ return getManager(name)
80
95
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Field-name humanization (#1255)
3
+ *
4
+ * Turns machine field names into presentable labels:
5
+ * 'botUuid' → 'Bot Uuid'
6
+ * 'created_at' → 'Created At'
7
+ * 'filter.botId' → 'Filter Bot Id'
8
+ *
9
+ * Shared by the list column binding helper (useListPage.column()) and the
10
+ * OpenAPI connector's opt-in label inference — dependency-free so the gen
11
+ * side (node) can import it too.
12
+ */
13
+
14
+ /**
15
+ * Humanize a field name: split camelCase, snake_case, kebab-case and dotted
16
+ * segments into words, then capitalize each word.
17
+ */
18
+ export function humanizeFieldName(field: string): string {
19
+ return field
20
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
21
+ .replace(/[_\-.]+/g, ' ')
22
+ .trim()
23
+ .split(/\s+/)
24
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
25
+ .join(' ')
26
+ }
@@ -21,6 +21,9 @@ export {
21
21
  // Error formatting
22
22
  export { formatFetchError, type FetchErrorLike } from './errors'
23
23
 
24
+ // Field-name humanization
25
+ export { humanizeFieldName } from './humanize'
26
+
24
27
  // Transformers
25
28
  export {
26
29
  toKeyValueArray,