@quazardous/qdadm 2.4.5 → 2.5.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.5.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 {
@@ -294,6 +295,42 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
294
295
  }
295
296
  })
296
297
 
298
+ /**
299
+ * Template-side column binding helper (#1255): spread onto a PrimeVue
300
+ * `<Column>` so `field` + `header` come from one source instead of being
301
+ * retyped in every list template:
302
+ *
303
+ * ```html
304
+ * <Column v-bind="list.column('botUuid')" sortable>
305
+ * <template #body="{ data }">...</template>
306
+ * </Column>
307
+ * ```
308
+ *
309
+ * Header resolution: i18n key (`entities.{entity}.fields.{field}`) >
310
+ * `overrides.header` > inline header given to `addColumn` >
311
+ * `manager.fields[name].label` > humanized field name. Pure read — it
312
+ * never registers into columnsMap, so calling it during render is safe.
313
+ */
314
+ function column(name: string, overrides: Partial<ColumnConfig> = {}): ColumnConfig {
315
+ // Depend on the locale so headers re-render on locale change.
316
+ void i18nLocale.value
317
+
318
+ let header: string | undefined
319
+ if (entity && kernelI18n) {
320
+ const trace = kernelI18n.resolve(`entities.${entity}.fields.${name}`)
321
+ if (trace.hit) header = trace.result
322
+ }
323
+ const fieldConfig = manager.getFieldConfig?.(name) as { label?: string } | null | undefined
324
+ header ??=
325
+ overrides.header ??
326
+ columnInlineHeaders.get(name) ??
327
+ fieldConfig?.label ??
328
+ humanizeFieldName(name)
329
+
330
+ const registered = columnsMap.value.get(name)
331
+ return { ...registered, ...overrides, field: name, header }
332
+ }
333
+
297
334
  function removeColumn(field: string): void {
298
335
  columnsMap.value.delete(field)
299
336
  }
@@ -1111,6 +1148,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1111
1148
 
1112
1149
  // Columns
1113
1150
  columns,
1151
+ column,
1114
1152
  addColumn,
1115
1153
  removeColumn,
1116
1154
  updateColumn,
@@ -347,6 +347,8 @@ export interface UseListPageReturn<T = unknown> {
347
347
 
348
348
  // Columns
349
349
  columns: ComputedRef<ColumnConfig[]>
350
+ /** v-bind column binding helper (#1255): field + header from one source */
351
+ column: (name: string, overrides?: Partial<ColumnConfig>) => ColumnConfig
350
352
  addColumn: (field: string, config?: Partial<ColumnConfig>) => void
351
353
  removeColumn: (field: string) => void
352
354
  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'
@@ -60,6 +60,8 @@ export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, En
60
60
  request: (method: string, path: string, options?: { data?: unknown }) => Promise<unknown>
61
61
  invalidateCache?: () => void
62
62
  localFilterThreshold?: number
63
+ /** Field config lookup — read-side too since list column binding (#1255) */
64
+ getFieldConfig?: (name: string) => unknown | null
63
65
  }
64
66
 
65
67
  // ─── CRUD ────────────────────────────────────────────────────────────────────
@@ -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,
@@ -150,6 +152,30 @@ export type {
150
152
  // ════════════════════════════════════════════════════════════════════════════
151
153
  export * from './orchestrator/index'
152
154
 
155
+ /**
156
+ * Typed manager registry (#1253) — augment it from your app to make
157
+ * `getManager()` / `useEntity()` return your concrete EntityManager
158
+ * subclasses instead of `EntityManager<EntityRecord>`:
159
+ *
160
+ * ```ts
161
+ * declare module '@quazardous/qdadm' {
162
+ * interface QdadmManagerRegistry {
163
+ * bots: BotsManager
164
+ * botPools: BotPoolsManager
165
+ * }
166
+ * }
167
+ * ```
168
+ *
169
+ * Entity names not declared here keep the current fallback behavior.
170
+ *
171
+ * NOTE: deliberately declared in this file (not re-exported from a
172
+ * sub-module) — TypeScript module augmentation does not merge through
173
+ * re-export aliases, and consumers augment '@quazardous/qdadm', which
174
+ * resolves here.
175
+ */
176
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
177
+ export interface QdadmManagerRegistry {}
178
+
153
179
  // ════════════════════════════════════════════════════════════════════════════
154
180
  // COMPOSABLES (excluding types that conflict with other modules)
155
181
  // ════════════════════════════════════════════════════════════════════════════
@@ -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,