@quazardous/qdadm 2.17.4 → 2.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.17.4",
3
+ "version": "2.19.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -54,8 +54,8 @@
54
54
  "dist/node"
55
55
  ],
56
56
  "dependencies": {
57
- "@quazardous/qdcore": "^1.1.2",
58
- "@quazardous/qddebug": "^1.1.0",
57
+ "@quazardous/qdcore": "^1.2.0",
58
+ "@quazardous/qddebug": "^1.2.0",
59
59
  "@quazardous/quarkernel": "^2.1.0",
60
60
  "yaml": "^2.8.4"
61
61
  },
@@ -63,6 +63,7 @@
63
63
  "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
64
64
  "primevue": "^4.0.0",
65
65
  "sass-embedded": "^1.0.0",
66
+ "vanilla-jsoneditor": "^3.13.0",
66
67
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
67
68
  "vue": "^3.3.0",
68
69
  "vue-router": "^4.0.0 || ^5.0.0"
@@ -78,12 +79,9 @@
78
79
  "optional": true
79
80
  },
80
81
  "sass-embedded": {
81
- "optional": "true"
82
+ "optional": true
82
83
  }
83
84
  },
84
- "optionalDependencies": {
85
- "vanilla-jsoneditor": "^0.23.0"
86
- },
87
85
  "keywords": [
88
86
  "vue",
89
87
  "vue3",
@@ -15,7 +15,17 @@
15
15
  */
16
16
 
17
17
  import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
18
- import { JSONEditor, type Content, type ContentErrors, type OnChangeStatus, type Mode } from 'vanilla-jsoneditor'
18
+ // vanilla-jsoneditor 3.x (#1901): the `new JSONEditor(...)` constructor is
19
+ // deprecated — `JSONEditor` is now the Svelte component itself, and the
20
+ // instance type is exported as `JsonEditor`.
21
+ import {
22
+ createJSONEditor,
23
+ type JsonEditor,
24
+ type Content,
25
+ type ContentErrors,
26
+ type OnChangeStatus,
27
+ type Mode,
28
+ } from 'vanilla-jsoneditor'
19
29
  import { buildJsonValidator, type Validator, type JSONSchema, type JsonValidatorOptions } from './jsonValidator'
20
30
 
21
31
  type JsonValue = Record<string, unknown> | unknown[] | string | null
@@ -74,8 +84,8 @@ const emit = defineEmits<{
74
84
  'error': [errors: ContentErrors]
75
85
  }>()
76
86
 
77
- const containerRef = ref<HTMLElement | null>(null)
78
- let editor: JSONEditor | null = null
87
+ const containerRef = ref<HTMLDivElement | null>(null)
88
+ let editor: JsonEditor | null = null
79
89
  // Flag to prevent onChange from firing during programmatic updates (instance-specific)
80
90
  const updatingFromProp = ref<boolean>(false)
81
91
 
@@ -102,7 +112,7 @@ onMounted(() => {
102
112
  json: parseValue(props.modelValue)
103
113
  }
104
114
 
105
- editor = new JSONEditor({
115
+ editor = createJSONEditor({
106
116
  target: containerRef.value,
107
117
  props: {
108
118
  content,
@@ -34,6 +34,10 @@ import { useRoute } from 'vue-router'
34
34
  export const LAYOUT_TYPES = {
35
35
  LIST: 'list',
36
36
  FORM: 'form',
37
+ // `show` was emitted by crud({ show }) long before it existed here (#1922).
38
+ // The resolver returned it, found no component under that key, and fell
39
+ // back to `base` — gracefully, which is exactly what made it invisible.
40
+ SHOW: 'show',
37
41
  DASHBOARD: 'dashboard',
38
42
  BASE: 'base',
39
43
  } as const
@@ -46,6 +50,7 @@ export type LayoutType = (typeof LAYOUT_TYPES)[keyof typeof LAYOUT_TYPES]
46
50
  export interface LayoutComponentsMap {
47
51
  list?: Component | null
48
52
  form?: Component | null
53
+ show?: Component | null
49
54
  dashboard?: Component | null
50
55
  base?: Component | null
51
56
  [key: string]: Component | null | undefined
@@ -63,6 +68,7 @@ interface PageNamePattern {
63
68
  const PAGE_NAME_PATTERNS: PageNamePattern[] = [
64
69
  { pattern: /List(Page)?$/i, layout: LAYOUT_TYPES.LIST },
65
70
  { pattern: /(Edit|Create|Form)(Page)?$/i, layout: LAYOUT_TYPES.FORM },
71
+ { pattern: /Show(Page)?$/i, layout: LAYOUT_TYPES.SHOW },
66
72
  { pattern: /Dashboard(Page)?$/i, layout: LAYOUT_TYPES.DASHBOARD },
67
73
  ]
68
74
 
@@ -70,6 +76,10 @@ const PAGE_NAME_PATTERNS: PageNamePattern[] = [
70
76
  const ROUTE_NAME_PATTERNS: PageNamePattern[] = [
71
77
  { pattern: /-list$/i, layout: LAYOUT_TYPES.LIST },
72
78
  { pattern: /-(edit|create)$/i, layout: LAYOUT_TYPES.FORM },
79
+ // Load-bearing, not decoration: an app that declares its detail route by
80
+ // hand rather than through crud({ show }) has no meta.layout to go on, and
81
+ // this is the only path that can reach `show` for it (#1922).
82
+ { pattern: /-show$/i, layout: LAYOUT_TYPES.SHOW },
73
83
  { pattern: /-dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
74
84
  // Common patterns without suffix
75
85
  { pattern: /^dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
@@ -44,7 +44,6 @@ export interface UseListFiltersDeps {
44
44
  /** Thunks — resolved lazily, the targets are declared later in useListPage. */
45
45
  loadItems: () => void
46
46
  setSearch: (searchCfg: Partial<SearchConfig>) => void
47
- invokeFilterAlterHook: () => Promise<void>
48
47
  }
49
48
 
50
49
  export interface UseListFiltersReturn {
@@ -63,6 +62,8 @@ export interface UseListFiltersReturn {
63
62
  loadFilterOptions: () => Promise<void>
64
63
  updateCacheBasedFilters: () => Promise<void>
65
64
  restoreFilters: () => void
65
+ /** Push filters, search and the page number into the URL (#2113). */
66
+ writeStateToUrl: () => void
66
67
  }
67
68
 
68
69
  export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
@@ -83,13 +84,42 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
83
84
  entityFilters,
84
85
  loadItems,
85
86
  setSearch,
86
- invokeFilterAlterHook,
87
87
  } = deps
88
88
 
89
89
  const filtersMap = ref<Map<string, FilterConfig>>(new Map())
90
90
  const filterValues = ref<Record<string, unknown>>(savedFilters || {})
91
91
 
92
+ /** Names the URL sync already owns — a filter cannot have them (#2113). */
93
+ const RESERVED_QUERY_KEYS = new Set(['page', 'search'])
94
+ const warnedReserved = new Set<string>()
95
+
96
+ /**
97
+ * A filter named `page` or `search` silently overwrites the query key of
98
+ * the same name, and is overwritten back on restore. `search` has behaved
99
+ * this way since the URL sync existed and nobody ever reported it, which
100
+ * says how quiet the failure is; `page` joins the reserved set now that the
101
+ * page number lives in the URL too.
102
+ *
103
+ * We name what happens INSTEAD of what was asked, rather than dropping the
104
+ * filter — renaming someone's filter behind their back would be a worse
105
+ * surprise than the collision (ADR 0011).
106
+ */
107
+ function warnIfReservedName(name: string): void {
108
+ if (!RESERVED_QUERY_KEYS.has(name)) return
109
+ if (!syncUrlParams) return
110
+ if (warnedReserved.has(name)) return
111
+ warnedReserved.add(name)
112
+ console.warn(
113
+ `[qdadm] Filter "${name}" on "${entityName}" collides with the URL ` +
114
+ `parameter of the same name. With syncUrlParams on, the list's own ` +
115
+ `${name === 'page' ? 'page number' : 'search query'} wins and this ` +
116
+ `filter will not survive a reload. Rename the filter, or set ` +
117
+ `syncUrlParams: false on this list.`
118
+ )
119
+ }
120
+
92
121
  function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
122
+ warnIfReservedName(name)
93
123
  filtersMap.value.set(name, {
94
124
  name,
95
125
  type: 'select',
@@ -115,6 +145,46 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
115
145
  onFiltersChanged()
116
146
  }
117
147
 
148
+ /**
149
+ * Push the list's own state into the URL (#2113).
150
+ *
151
+ * Filters, search AND the page number, each written only when it differs
152
+ * from the default and removed otherwise, so a pristine list leaves a clean
153
+ * URL. `router.replace` rather than `push`: paging is not a history step of
154
+ * its own, but the URL must carry the page at the moment the user leaves
155
+ * for a detail view, so that coming back restores it.
156
+ */
157
+ function writeStateToUrl(): void {
158
+ if (!syncUrlParams) return
159
+
160
+ const query = { ...route.query } as Record<string, string>
161
+
162
+ for (const [name, value] of Object.entries(filterValues.value)) {
163
+ if (value !== null && value !== undefined && value !== '') {
164
+ query[name] = String(value)
165
+ } else {
166
+ delete query[name]
167
+ }
168
+ }
169
+
170
+ if (searchQuery.value) {
171
+ query.search = searchQuery.value
172
+ } else {
173
+ delete query.search
174
+ }
175
+
176
+ // Page 1 is the default and stays implicit. Reserved keys are written
177
+ // last so the list's own state wins a collision — the warning in
178
+ // addFilter() names that consequence at declaration time.
179
+ if (page.value > 1) {
180
+ query.page = String(page.value)
181
+ } else {
182
+ delete query.page
183
+ }
184
+
185
+ router.replace({ query })
186
+ }
187
+
118
188
  function onFiltersChanged(): void {
119
189
  page.value = 1
120
190
  loadItems()
@@ -136,22 +206,7 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
136
206
  }
137
207
  setSessionFilters(filterSessionKey, toPersist)
138
208
  }
139
- if (syncUrlParams) {
140
- const query = { ...route.query } as Record<string, string>
141
- for (const [name, value] of Object.entries(filterValues.value)) {
142
- if (value !== null && value !== undefined && value !== '') {
143
- query[name] = String(value)
144
- } else {
145
- delete query[name]
146
- }
147
- }
148
- if (searchQuery.value) {
149
- query.search = searchQuery.value
150
- } else {
151
- delete query.search
152
- }
153
- router.replace({ query })
154
- }
209
+ writeStateToUrl()
155
210
  }
156
211
 
157
212
  function clearFilters(): void {
@@ -164,15 +219,16 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
164
219
  if (persistFilters) {
165
220
  clearSessionFilters(filterSessionKey)
166
221
  }
222
+ page.value = 1
167
223
  if (syncUrlParams) {
168
224
  const query = { ...route.query } as Record<string, string>
169
225
  for (const key of filtersMap.value.keys()) {
170
226
  delete query[key]
171
227
  }
172
228
  delete query.search
229
+ delete query.page
173
230
  router.replace({ query })
174
231
  }
175
- page.value = 1
176
232
  loadItems()
177
233
  }
178
234
 
@@ -221,12 +277,49 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
221
277
  }
222
278
  }
223
279
 
280
+ /**
281
+ * Fetch the remote options that populate filter dropdowns (#1934 lot 1).
282
+ *
283
+ * The loop used to be sequential: three filters with remote options cost
284
+ * three round trips in a queue, and the rows waited behind the sum. They
285
+ * are independent, so they now go together and cost the slowest.
286
+ *
287
+ * Deduplicated by `optionsEntity` on purpose. Sequential execution had one
288
+ * accidental virtue — two filters on the same entity meant one fetch and
289
+ * one cache hit — and firing them in parallel would turn that into two
290
+ * concurrent fetches. Trading latency for duplicate requests is not a win,
291
+ * so filters sharing an entity are still chained behind one another while
292
+ * different entities run side by side.
293
+ *
294
+ * This no longer invokes `filter:alter`: that hook establishes what the
295
+ * query will ask for, so it belongs with the other query-settling work in
296
+ * `onMounted`, not behind the network (#1934 lot 2).
297
+ */
224
298
  async function loadFilterOptions(): Promise<void> {
225
- // Process filters configured directly via addFilter() (smart filter modes)
226
- for (const [filterName, filterDef] of filtersMap.value) {
227
- if (filterDef.options && filterDef.options.length > 1) continue
228
- if (filterDef.optionsFromCache) continue
299
+ const pending = Array.from(filtersMap.value).filter(([, filterDef]) => {
300
+ if (filterDef.options && filterDef.options.length > 1) return false
301
+ if (filterDef.optionsFromCache) return false
302
+ return true
303
+ })
304
+
305
+ // One chain per source entity; everything else is a chain of its own.
306
+ const chains = new Map<string, Promise<void>>()
229
307
 
308
+ await Promise.all(
309
+ pending.map(([filterName, filterDef]) => {
310
+ const key = filterDef.optionsEntity ? `entity:${filterDef.optionsEntity}` : `own:${filterName}`
311
+ const previous = chains.get(key) ?? Promise.resolve()
312
+ const next = previous.then(() => loadOneFilterOptions(filterName, filterDef))
313
+ chains.set(key, next)
314
+ return next
315
+ })
316
+ )
317
+
318
+ // Trigger Vue reactivity
319
+ filtersMap.value = new Map(filtersMap.value)
320
+ }
321
+
322
+ async function loadOneFilterOptions(filterName: string, filterDef: FilterConfig): Promise<void> {
230
323
  try {
231
324
  let rawOptions: Array<{ label: string; value: unknown }> | null = null
232
325
 
@@ -299,13 +392,6 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
299
392
  } catch (error) {
300
393
  console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
301
394
  }
302
- }
303
-
304
- // Invoke filter:alter hooks after all options are loaded
305
- await invokeFilterAlterHook()
306
-
307
- // Trigger Vue reactivity
308
- filtersMap.value = new Map(filtersMap.value)
309
395
  }
310
396
 
311
397
  async function updateCacheBasedFilters(): Promise<void> {
@@ -399,6 +485,13 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
399
485
  if (route.query.search) {
400
486
  searchQuery.value = route.query.search as string
401
487
  }
488
+ // The page must be restored HERE, before the first loadItems(): restoring
489
+ // it later would mean a first request for page 1 and a second for the
490
+ // real one (#2113).
491
+ const restoredPage = Number(route.query.page)
492
+ if (Number.isInteger(restoredPage) && restoredPage > 0) {
493
+ page.value = restoredPage
494
+ }
402
495
  }
403
496
 
404
497
  return {
@@ -417,5 +510,6 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
417
510
  loadFilterOptions,
418
511
  updateCacheBasedFilters,
419
512
  restoreFilters,
513
+ writeStateToUrl,
420
514
  }
421
515
  }
@@ -28,7 +28,7 @@ import {
28
28
  provide,
29
29
  type Ref,
30
30
  } from 'vue'
31
- import { useRouter, useRoute } from 'vue-router'
31
+ import { useRouter, useRoute, type RouteLocationRaw } from 'vue-router'
32
32
  import { useConfirm } from 'primevue/useconfirm'
33
33
  import { useHooks } from './useHooks.js'
34
34
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
@@ -586,8 +586,8 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
586
586
  const cards = computed(() => Array.from(cardsMap.value.values()))
587
587
 
588
588
  // ============ FILTERS (#1195 — extracted subsystem) ============
589
- // invokeFilterAlterHook is a thunk: alterHooks is declared after the
590
- // actions section (it needs the action registry) and only runs post-setup.
589
+ // loadItems and setSearch are passed as thunks: both are declared further
590
+ // down and only ever run post-setup.
591
591
  const listFilters = useListFilters({
592
592
  entityName,
593
593
  manager,
@@ -605,7 +605,6 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
605
605
  entityFilters,
606
606
  loadItems: () => loadItems(),
607
607
  setSearch: (searchCfg) => setSearch(searchCfg),
608
- invokeFilterAlterHook: () => alterHooks.invokeFilterAlterHook(),
609
608
  })
610
609
  const {
611
610
  filtersMap,
@@ -623,6 +622,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
623
622
  loadFilterOptions,
624
623
  updateCacheBasedFilters,
625
624
  restoreFilters,
625
+ writeStateToUrl,
626
626
  } = listFilters
627
627
 
628
628
  // ============ ACTIONS ============
@@ -799,6 +799,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
799
799
  page.value = event.page + 1
800
800
  pageSize.value = event.rows
801
801
  persistPageSize(event.rows)
802
+ // The page joins the URL (#2113) — it was the only piece of list state
803
+ // that survived nothing, so leaving a list for a detail view and coming
804
+ // back dropped the user on page 1.
805
+ writeStateToUrl()
802
806
  loadItems()
803
807
  }
804
808
 
@@ -851,21 +855,55 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
851
855
  return { name: `${routePrefix}-create` }
852
856
  }
853
857
 
858
+ /** Route names already reported as missing — one warning each. */
859
+ const warnedMissingRoutes = new Set<string>()
860
+
861
+ /**
862
+ * Navigate by route name, and say something when that name does not exist
863
+ * (#1923 lot 2).
864
+ *
865
+ * The failure this catches is quiet by nature: the route prefix is derived
866
+ * from the entity name, so a mismatch between the side that NAMES routes
867
+ * and the side that LOOKS THEM UP produces a push to a name nobody
868
+ * registered. Nothing happens, and nothing says why — the divergence that
869
+ * caused #1923 behaved exactly like this, invisible until someone clicked.
870
+ *
871
+ * We warn at the moment of use rather than auditing the prefix upfront: an
872
+ * entity that only ever appears as a child of another has no routes under
873
+ * its own prefix and is perfectly healthy, so an upfront check would cry
874
+ * wolf. A warning nobody trusts is worse than none (ADR 0011).
875
+ */
876
+ function pushNamedRoute(name: string, params?: Record<string, unknown>): void {
877
+ if (!router.hasRoute(name)) {
878
+ if (!warnedMissingRoutes.has(name)) {
879
+ warnedMissingRoutes.add(name)
880
+ console.warn(
881
+ `[qdadm] No route named "${name}" — this navigation does nothing. ` +
882
+ `The list for "${entity}" derives it from the route prefix ` +
883
+ `"${routePrefix}". Either that page is not declared, or the ` +
884
+ `prefix disagrees with the one its routes were registered under ` +
885
+ `(pass routePrefix on the entity, or on useListPage, to pin it).`
886
+ )
887
+ }
888
+ return
889
+ }
890
+ router.push({ name, ...(params ? { params } : {}) } as RouteLocationRaw)
891
+ }
892
+
854
893
  function goToCreate(): void {
855
- router.push(findCreateRoute() as { name: string })
894
+ const target = findCreateRoute() as { name: string; params?: Record<string, unknown> }
895
+ pushNamedRoute(target.name, target.params)
856
896
  }
857
897
 
858
898
  function goToEdit(item: unknown): void {
859
- router.push({
860
- name: `${routePrefix}-edit`,
861
- params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
899
+ pushNamedRoute(`${routePrefix}-edit`, {
900
+ [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
862
901
  })
863
902
  }
864
903
 
865
904
  function goToShow(item: unknown): void {
866
- router.push({
867
- name: `${routePrefix}-show`,
868
- params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
905
+ pushNamedRoute(`${routePrefix}-show`, {
906
+ [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
869
907
  })
870
908
  }
871
909
 
@@ -1018,7 +1056,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1018
1056
  headerActionsMap,
1019
1057
  actionRegistry,
1020
1058
  })
1021
- const { invokeListAlterHook } = alterHooks
1059
+ const { invokeListAlterHook, invokeFilterAlterHook } = alterHooks
1022
1060
 
1023
1061
  // ============ LIFECYCLE ============
1024
1062
  initFromRegistry()
@@ -1030,18 +1068,35 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1030
1068
  onMounted(async () => {
1031
1069
  window.addEventListener('resize', handleResize)
1032
1070
 
1071
+ // Two kinds of work, and they used to be interleaved (#1934).
1072
+ //
1073
+ // SETTLING THE QUERY comes first, and only this blocks the rows.
1074
+ // `restoreFilters()` reads the filters, the search and the page out of
1075
+ // the URL; the alter hooks may add filters and their default values, and
1076
+ // may replace a filter's `toQuery`/`local_filter` — all of which
1077
+ // `loadItems()` reads. Skipping ahead of them sends the wrong request,
1078
+ // and nothing retries it.
1079
+ //
1080
+ // FETCHING comes second. Filter options populate dropdowns and have no
1081
+ // bearing on what the rows request, so the rows no longer wait behind
1082
+ // them: a page whose filters pull options from related entities used to
1083
+ // show nothing until every one of those round trips had returned.
1033
1084
  restoreFilters()
1034
1085
 
1035
- if (!filterOptionsLoaded) {
1036
- filterOptionsLoaded = true
1037
- await loadFilterOptions()
1038
- }
1039
-
1086
+ await invokeFilterAlterHook()
1040
1087
  await invokeListAlterHook()
1041
1088
 
1042
1089
  if (loadOnMount && manager) {
1043
1090
  loadItems()
1044
1091
  }
1092
+
1093
+ if (!filterOptionsLoaded) {
1094
+ filterOptionsLoaded = true
1095
+ // Deliberately not awaited: the dropdowns fill in when they fill in.
1096
+ void loadFilterOptions().catch((error) => {
1097
+ console.warn('[qdadm] Failed to load filter options:', error)
1098
+ })
1099
+ }
1045
1100
  })
1046
1101
 
1047
1102
  onUnmounted(() => {
@@ -36,6 +36,13 @@ export interface ApiStorageOptions<T extends EntityRecord = EntityRecord> {
36
36
  getClient?: (() => HttpClient) | null
37
37
  responseItemsKey?: string
38
38
  responseTotalKey?: string
39
+ /**
40
+ * Response HEADER carrying the total, for APIs that report it there rather
41
+ * than in the body (`X-Total-Count` is the json-server convention). Read
42
+ * before `responseTotalKey`. The header must be CORS-exposed to be
43
+ * readable from a browser.
44
+ */
45
+ responseTotalHeader?: string | null
39
46
  paramMapping?: Record<string, string>
40
47
  normalize?: ((data: T, context?: RoutingContext | null) => T) | null
41
48
  denormalize?: ((data: Partial<T>) => Partial<T>) | null
@@ -60,6 +67,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
60
67
  readonly endpoint: string
61
68
  readonly responseItemsKey: string
62
69
  readonly responseTotalKey: string
70
+ readonly responseTotalHeader: string | null
63
71
  readonly paramMapping: Record<string, string>
64
72
 
65
73
  protected _client: HttpClient | null
@@ -75,6 +83,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
75
83
  getClient = null,
76
84
  responseItemsKey = 'items',
77
85
  responseTotalKey = 'total',
86
+ responseTotalHeader = null,
78
87
  paramMapping = {},
79
88
  normalize = null,
80
89
  denormalize = null,
@@ -85,13 +94,16 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
85
94
  this._getClient = getClient
86
95
  this.responseItemsKey = responseItemsKey
87
96
  this.responseTotalKey = responseTotalKey
97
+ this.responseTotalHeader = responseTotalHeader
88
98
  this.paramMapping = paramMapping
89
99
  this._normalize = normalize
90
100
  this._denormalize = denormalize
91
101
  }
92
102
 
93
103
  /**
94
- * Apply parameter mapping to transform filter names
104
+ * Rename outgoing query parameters to the backend's vocabulary.
105
+ *
106
+ * Applies to filters AND to the pagination/sort keys (#2113) — see `list()`.
95
107
  */
96
108
  protected _applyParamMapping(params: Record<string, unknown>): Record<string, unknown> {
97
109
  if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
@@ -141,10 +153,19 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
141
153
  async list(params: ListParams = {}, context: RoutingContext | null = null): Promise<ListResult<T>> {
142
154
  const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
143
155
 
144
- const mappedFilters = this._applyParamMapping(filters)
156
+ // `paramMapping` covers the WHOLE outgoing query, pagination included
157
+ // (#2113). It used to be applied to the filters only, so `status` could
158
+ // be renamed to `state` but `page_size` could not be renamed to `limit` —
159
+ // and an API speaking any other pagination dialect had to subclass the
160
+ // storage for that alone. The names qdadm uses internally are its own
161
+ // business; what goes on the wire is the backend's.
162
+ //
163
+ // Filters keep their precedence over the pagination keys, exactly as
164
+ // before: a filter named `page` still wins, for better or worse.
165
+ const outgoing = { page, page_size, sort_by, sort_order, ...filters }
145
166
 
146
167
  const response = await this.client.get<Record<string, unknown>>(this.endpoint, {
147
- params: { page, page_size, sort_by, sort_order, ...mappedFilters },
168
+ params: this._applyParamMapping(outgoing),
148
169
  })
149
170
 
150
171
  const data = response.data
@@ -154,12 +175,39 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
154
175
  return {
155
176
  items,
156
177
  total:
157
- (data[this.responseTotalKey] as number) ||
158
- (data.total as number) ||
159
- (Array.isArray(data) ? data.length : 0),
178
+ this._totalFromHeader(response) ??
179
+ ((data[this.responseTotalKey] as number) ||
180
+ (data.total as number) ||
181
+ (Array.isArray(data) ? data.length : 0)),
160
182
  }
161
183
  }
162
184
 
185
+ /**
186
+ * Total announced in a response header, when `responseTotalHeader` says so.
187
+ *
188
+ * Returns null — not 0 — when there is nothing to read, so the body-based
189
+ * fallbacks still get their turn. A 0 from the server is a real answer and
190
+ * is preserved.
191
+ */
192
+ protected _totalFromHeader(response: unknown): number | null {
193
+ if (!this.responseTotalHeader) return null
194
+
195
+ const headers = (response as { headers?: unknown })?.headers as
196
+ | { get?: (name: string) => unknown }
197
+ | Record<string, unknown>
198
+ | undefined
199
+ if (!headers) return null
200
+
201
+ const raw =
202
+ typeof (headers as { get?: unknown }).get === 'function'
203
+ ? (headers as { get: (name: string) => unknown }).get(this.responseTotalHeader)
204
+ : (headers as Record<string, unknown>)[this.responseTotalHeader.toLowerCase()]
205
+
206
+ if (raw === null || raw === undefined || raw === '') return null
207
+ const total = Number(raw)
208
+ return Number.isFinite(total) ? total : null
209
+ }
210
+
163
211
  async get(id: string | number, context: RoutingContext | null = null): Promise<T> {
164
212
  const response = await this.client.get<T>(`${this.endpoint}/${id}`)
165
213
  return this._normalizeData(response.data, context) as T
@@ -196,7 +196,7 @@ function findSchemaForEntity(
196
196
  * const users = await usersManager.list()
197
197
  * ```
198
198
  */
199
- export function createManagers(config: CreateManagersConfig): Map<string, EntityManager> {
199
+ export function createGeneratedManagers(config: CreateManagersConfig): Map<string, EntityManager> {
200
200
  // Validate config structure early
201
201
  validateConfig(config)
202
202
 
@@ -243,3 +243,17 @@ export function createManagers(config: CreateManagersConfig): Map<string, Entity
243
243
 
244
244
  return managers
245
245
  }
246
+
247
+ /**
248
+ * @deprecated Renamed to `createGeneratedManagers` (#1902).
249
+ *
250
+ * The root exports a DIFFERENT `createManagers` — `entity/factory.ts`, which
251
+ * takes `(config, context)` and returns a `Record`, where this one takes a
252
+ * single generated config and returns a `Map`. Same name, two public entry
253
+ * points, different signatures and different return types, with nothing to
254
+ * warn whoever picked the wrong import: the mistake surfaces at the first
255
+ * `.get()`.
256
+ *
257
+ * This alias keeps existing imports working. Use `createGeneratedManagers`.
258
+ */
259
+ export const createManagers = createGeneratedManagers
package/src/gen/index.ts CHANGED
@@ -51,7 +51,13 @@ export { OpenAPIConnector, type OpenAPIConnectorOptions, type OperationFilter }
51
51
  export { type StorageProfileFactory, type StorageProfileOptions } from './StorageProfileFactory'
52
52
 
53
53
  // T00317: createManagers - Runtime factory for EntityManager creation
54
- export { createManagers, type CreateManagersConfig, type EntityConfig } from './createManagers'
54
+ export {
55
+ createGeneratedManagers,
56
+ /** @deprecated Renamed to `createGeneratedManagers` (#1902) — it collided with the root's own `createManagers`. */
57
+ createManagers,
58
+ type CreateManagersConfig,
59
+ type EntityConfig,
60
+ } from './createManagers'
55
61
 
56
62
  // T00318: Decorators - Per-entity field customization layer
57
63
  export { applyDecorators, type FieldDecorator, type EntityDecorator } from './decorators'
@@ -52,9 +52,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
52
52
  * Create hook registry for Drupal-inspired extensibility
53
53
  */
54
54
  proto._createHookRegistry = function (this: Self): void {
55
+ // Idempotent, and built in the constructor (#1906 lot B1): replacing it in
56
+ // createApp() would orphan every hook registered before then — silently,
57
+ // which is the failure this lot exists to remove.
58
+ if (this.hookRegistry) return
59
+
55
60
  const debug = this.options.debug ?? false
56
61
  this.hookRegistry = createHookRegistry({
57
- kernel: this.signals!.getKernel(),
62
+ kernel: this.signals.getKernel(),
58
63
  debug,
59
64
  })
60
65
  }
@@ -94,6 +99,9 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
94
99
  * Create PermissionRegistry early so modules can register permissions
95
100
  */
96
101
  proto._createPermissionRegistry = function (this: Self): void {
102
+ // Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
103
+ if (this.permissionRegistry) return
104
+
97
105
  this.permissionRegistry = new PermissionRegistry()
98
106
  this._registerCorePermissions()
99
107
  }
@@ -102,12 +110,12 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
102
110
  * Register core system permissions provided by the framework
103
111
  */
104
112
  proto._registerCorePermissions = function (this: Self): void {
105
- this.permissionRegistry!.register('auth', {
113
+ this.permissionRegistry.register('auth', {
106
114
  impersonate: 'Impersonate other users',
107
115
  manage: 'Manage authentication settings',
108
116
  })
109
117
 
110
- this.permissionRegistry!.register('admin', {
118
+ this.permissionRegistry.register('admin', {
111
119
  access: 'Access admin panel',
112
120
  config: 'Edit system configuration',
113
121
  })
@@ -119,12 +127,10 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
119
127
  proto._setupSecurity = function (this: Self): void {
120
128
  const { security, entityAuthAdapter } = this.options
121
129
 
122
- if (!this.permissionRegistry) {
123
- this.permissionRegistry = new PermissionRegistry()
124
- }
125
-
126
130
  if (!security) return
127
131
 
132
+ this._validateSecurityConfig(security as unknown as Record<string, unknown>)
133
+
128
134
  // security configured but no user source: every isGranted() falls back
129
135
  // to permissive — a newcomer believes gating is on when it isn't (#1388)
130
136
  if (!entityAuthAdapter) {
@@ -173,6 +179,9 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
173
179
  * Create zone registry for extensible UI composition
174
180
  */
175
181
  proto._createZoneRegistry = function (this: Self): void {
182
+ // Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
183
+ if (this.zoneRegistry) return
184
+
176
185
  const debug = this.options.debug ?? false
177
186
  this.zoneRegistry = createZoneRegistry({ debug })
178
187
  }
@@ -199,9 +208,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
199
208
  * Create deferred registry for async service loading
200
209
  */
201
210
  proto._createDeferredRegistry = function (this: Self): void {
211
+ // Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
212
+ if (this.deferred) return
213
+
202
214
  const debug = this.options.debug ?? false
215
+ // `this.signals` is never null now, so the defensive `?.` this line used
216
+ // to carry is gone. Less guarding, not more: that is the point of closing
217
+ // the window rather than warning about it.
203
218
  this.deferred = createDeferredRegistry({
204
- kernel: this.signals?.getKernel() || null,
219
+ kernel: this.signals.getKernel(),
205
220
  debug,
206
221
  })
207
222
  }
@@ -225,49 +240,93 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
225
240
  * Create SSEBridge for Server-Sent Events to SignalBus integration
226
241
  */
227
242
  /**
228
- * Warn about unrecognised `sse` keys (#1898 lot A).
243
+ * The known key a typo most likely meant, or undefined.
229
244
  *
230
- * A consumer configured `sse.getToken` against a version that predated it.
231
- * It was not rejected — it was ignored, and the bridge fell back to the
232
- * session's DURABLE token, which then travelled in the query string of every
233
- * `/events` request and into logs that outlive the session by months. No
234
- * error, no warning, and a stream that worked perfectly.
235
- *
236
- * The warning therefore names what happens INSTEAD, not merely that the key
237
- * was ignored: "ignored" reads as "no effect", not as "falls back to a more
238
- * sensitive secret". That reading is what made the failure invisible.
245
+ * Case differences and prefixes were all the original matcher caught
246
+ * (#1898), which misses the commonest typo of all: one missing or
247
+ * transposed letter. `securty` suggested nothing, so the reader got
248
+ * "IGNORED" with no hint and no consequence — the two things that make the
249
+ * warning worth printing. An edit distance of one covers an omission, an
250
+ * insertion, a substitution or a transposition; two is allowed only for
251
+ * longer keys, where it stays specific enough not to guess wildly.
239
252
  */
240
- proto._validateSseConfig = function (this: Self, sse: SSEConfig): void {
241
- const known = new Set([
242
- 'url',
243
- 'reconnectDelay',
244
- 'signalPrefix',
245
- 'autoConnect',
246
- 'withCredentials',
247
- 'tokenParam',
248
- 'events',
249
- 'entities',
250
- 'getToken',
251
- 'connectOnSignal',
252
- 'disconnectOnSignal',
253
- ])
254
-
255
- const unknown = Object.keys(sse).filter((key) => !known.has(key))
256
- if (!unknown.length) return
253
+ function nearestKnownKey(key: string, known: Set<string>): string | undefined {
254
+ const exact = [...known].find(
255
+ (k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
256
+ )
257
+ if (exact) return exact
258
+
259
+ const budget = key.length >= 8 ? 2 : 1
260
+ let best: string | undefined
261
+ let bestDistance = budget + 1
262
+ for (const candidate of known) {
263
+ const distance = editDistance(key.toLowerCase(), candidate.toLowerCase(), bestDistance)
264
+ if (distance < bestDistance) {
265
+ bestDistance = distance
266
+ best = candidate
267
+ }
268
+ }
269
+ return bestDistance <= budget ? best : undefined
270
+ }
257
271
 
258
- // What the app loses by having the key ignored, wherever we can say it.
259
- const consequences: Record<string, string> = {
260
- getToken:
261
- 'the session auth token will be sent in the stream URL instead — ' +
262
- 'and query strings reach access logs',
263
- entities: 'no entity cache will be invalidated from the stream',
264
- tokenParam: 'the token will be sent under the default name "token"',
272
+ /** Damerau-Levenshtein, abandoned as soon as it exceeds `cap`. */
273
+ function editDistance(a: string, b: string, cap: number): number {
274
+ if (Math.abs(a.length - b.length) > cap) return cap + 1
275
+
276
+ let previous: number[] = Array.from({ length: b.length + 1 }, (_, i) => i)
277
+ let beforePrevious: number[] = []
278
+
279
+ for (let i = 1; i <= a.length; i++) {
280
+ const current: number[] = [i]
281
+ let rowBest = i
282
+ for (let j = 1; j <= b.length; j++) {
283
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
284
+ let value = Math.min(
285
+ (current[j - 1] as number) + 1,
286
+ (previous[j] as number) + 1,
287
+ (previous[j - 1] as number) + cost
288
+ )
289
+ // Transposition: `raods` for `roads`.
290
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
291
+ value = Math.min(value, (beforePrevious[j - 2] as number) + 1)
292
+ }
293
+ current[j] = value
294
+ rowBest = Math.min(rowBest, value)
295
+ }
296
+ if (rowBest > cap) return cap + 1
297
+ beforePrevious = previous
298
+ previous = current
265
299
  }
300
+ return previous[b.length] as number
301
+ }
302
+
303
+ /**
304
+ * Warn about keys a config object does not recognise (#1906 lot B2).
305
+ *
306
+ * Extracted from the `sse` validator of #1898, which was the only one of its
307
+ * kind: every other qdadm config accepted anything in silence. The shape of
308
+ * the message is the lesson from that incident and is kept intact — it names
309
+ * what happens INSTEAD of what was asked, because "ignored" reads as "no
310
+ * effect" rather than "falls back to something else", and that reading is
311
+ * what cost a durable auth token in access logs.
312
+ *
313
+ * Only for CLOSED shapes. A config that deliberately forwards unknown keys
314
+ * elsewhere — `debugBar`, whose extra options reach the DebugModule — must
315
+ * not be passed through here: a warning that cries wolf is worse than none
316
+ * (ADR 0011).
317
+ */
318
+ proto._warnUnknownKeys = function (
319
+ this: Self,
320
+ scope: string,
321
+ config: Record<string, unknown>,
322
+ known: Set<string>,
323
+ consequences: Record<string, string> = {}
324
+ ): void {
325
+ const unknown = Object.keys(config).filter((key) => !known.has(key))
326
+ if (!unknown.length) return
266
327
 
267
328
  for (const key of unknown) {
268
- const near = [...known].find(
269
- (k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
270
- )
329
+ const near = nearestKnownKey(key, known)
271
330
  // Look the consequence up under the NEAREST KNOWN key: a typo like
272
331
  // `getTokens` means `getToken` is absent, and it is that absence whose
273
332
  // effect the reader needs to hear.
@@ -275,13 +334,110 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
275
334
  const because = consequence ? ` — ${consequence}` : ''
276
335
  const suggestion = near ? ` Did you mean "${near}"?` : ''
277
336
  console.warn(
278
- `[Kernel] sse.${key} is not a recognised option and is IGNORED${because}.` +
337
+ `[Kernel] ${scope}${key} is not a recognised option and is IGNORED${because}.` +
279
338
  `${suggestion} This usually means the installed qdadm predates the option ` +
280
339
  `— check the version before assuming the key has no effect.`
281
340
  )
282
341
  }
283
342
  }
284
343
 
344
+ /**
345
+ * Every option `KernelOptions` accepts (#1906 lot B2).
346
+ *
347
+ * A misspelled TOP-LEVEL key is the worst silence of the family: it does not
348
+ * degrade a feature, it removes a whole section of configuration. `securty:`
349
+ * means no security config at all, and nothing says so. TypeScript catches
350
+ * it; the many consumers whose module files are plain JavaScript get
351
+ * nothing.
352
+ *
353
+ * Kept in sync with the interface by a test that reads `Kernel.types.ts` and
354
+ * compares — a hand-maintained list that drifts would start crying wolf,
355
+ * which is the failure mode this whole ticket exists to avoid.
356
+ */
357
+ const KNOWN_KERNEL_OPTIONS = new Set([
358
+ 'apiClient', 'app', 'authAdapter', 'authTypes', 'basePath', 'coreRoutes',
359
+ 'debug', 'debugBar', 'defaultEntityCacheTtlMs', 'entityAuthAdapter',
360
+ 'eventRouter', 'existingApp', 'existingRouter', 'existingSignals',
361
+ 'features', 'hashMode', 'homeRoute', 'i18n', 'layouts', 'managerRegistry',
362
+ 'managerResolver', 'managers', 'moduleDefs', 'modules', 'modulesOptions',
363
+ 'notifications', 'onAuthExpired', 'pages', 'parentParamMode', 'primevue',
364
+ 'root', 'routeParamResolver', 'routePrefix', 'sectionOrder', 'security',
365
+ 'sse', 'storageResolver', 'toast', 'warmup',
366
+ ])
367
+
368
+ proto._validateKernelOptions = function (this: Self): void {
369
+ this._warnUnknownKeys('', this.options as Record<string, unknown>, KNOWN_KERNEL_OPTIONS, {
370
+ security: 'no role hierarchy or permissions will be applied — every check falls back to the default',
371
+ sse: 'no server-sent events will be connected',
372
+ debugBar: 'no debug bar will be mounted',
373
+ layouts: 'pages will fall back to the base layout',
374
+ i18n: 'translations will not be configured',
375
+ authAdapter: 'the app will run unauthenticated',
376
+ })
377
+ }
378
+
379
+ /**
380
+ * Warn about unrecognised `security` keys (#1906 lot B2).
381
+ *
382
+ * Chosen first, and by damage: a misspelled key here means a role hierarchy
383
+ * or a permission map is simply absent, and every check then falls through
384
+ * to whatever the default is. Nothing fails, nothing warns, and the app is
385
+ * open where its author believed it closed.
386
+ */
387
+ proto._validateSecurityConfig = function (this: Self, security: Record<string, unknown>): void {
388
+ this._warnUnknownKeys(
389
+ 'security.',
390
+ security,
391
+ new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
392
+ {
393
+ role_hierarchy: 'roles will not inherit from one another',
394
+ role_permissions: 'no role will carry any permission',
395
+ entity_permissions: 'per-entity permissions will not be generated',
396
+ rolesProvider: "the current user's roles will not be resolved",
397
+ }
398
+ )
399
+ }
400
+
401
+ /**
402
+ * Warn about unrecognised `sse` keys (#1898 lot A).
403
+ *
404
+ * A consumer configured `sse.getToken` against a version that predated it.
405
+ * It was not rejected — it was ignored, and the bridge fell back to the
406
+ * session's DURABLE token, which then travelled in the query string of every
407
+ * `/events` request and into logs that outlive the session by months. No
408
+ * error, no warning, and a stream that worked perfectly.
409
+ *
410
+ * The warning therefore names what happens INSTEAD, not merely that the key
411
+ * was ignored: "ignored" reads as "no effect", not as "falls back to a more
412
+ * sensitive secret". That reading is what made the failure invisible.
413
+ */
414
+ proto._validateSseConfig = function (this: Self, sse: SSEConfig): void {
415
+ this._warnUnknownKeys(
416
+ 'sse.',
417
+ sse as unknown as Record<string, unknown>,
418
+ new Set([
419
+ 'url',
420
+ 'reconnectDelay',
421
+ 'signalPrefix',
422
+ 'autoConnect',
423
+ 'withCredentials',
424
+ 'tokenParam',
425
+ 'events',
426
+ 'entities',
427
+ 'getToken',
428
+ 'connectOnSignal',
429
+ 'disconnectOnSignal',
430
+ ]),
431
+ {
432
+ getToken:
433
+ 'the session auth token will be sent in the stream URL instead — ' +
434
+ 'and query strings reach access logs',
435
+ entities: 'no entity cache will be invalidated from the stream',
436
+ tokenParam: 'the token will be sent under the default name "token"',
437
+ }
438
+ )
439
+ }
440
+
285
441
  proto._createSSEBridge = function (this: Self): void {
286
442
  const { sse, authAdapter } = this.options
287
443
  if (!sse?.url) return
@@ -76,6 +76,7 @@ import { applyRoutingMethods } from './Kernel.routing'
76
76
  import { applyModuleMethods } from './Kernel.modules'
77
77
  import { applyRegistryMethods } from './Kernel.registries'
78
78
  import { setQdadmDebugBar, applyVueMethods } from './Kernel.vue'
79
+ import { isDebugBarKilled } from './debugBarKillSwitch'
79
80
  import { applyApiMethods } from './Kernel.api'
80
81
  import { applyI18nMethods } from './Kernel.i18n'
81
82
  import type { I18n } from '../i18n/I18n'
@@ -93,18 +94,18 @@ export class Kernel {
93
94
  * window existed for no reason. Removing it beats warning about it
94
95
  * (ADR 0011).
95
96
  */
96
- signals: SignalBus | null = null
97
+ signals!: SignalBus
97
98
  orchestrator: Orchestrator | null = null
98
- zoneRegistry: ZoneRegistry | null = null
99
- hookRegistry: HookRegistry | null = null
100
- deferred: DeferredRegistry | null = null
99
+ zoneRegistry!: ZoneRegistry
100
+ hookRegistry!: HookRegistry
101
+ deferred!: DeferredRegistry
101
102
  eventRouter: EventRouter | null = null
102
103
  sseBridge: SSEBridge | null = null
103
104
  /** Routes external entity changes to cache invalidation (#1888). */
104
105
  liveEntityRouter: LiveEntityRouter | null = null
105
106
  layoutComponents: InternalLayoutComponents | null = null
106
107
  securityChecker: SecurityChecker | null = null
107
- permissionRegistry: PermissionRegistry | null = null
108
+ permissionRegistry!: PermissionRegistry
108
109
  moduleLoader: ModuleLoader | null = null
109
110
  activeStack: ActiveStack | null = null
110
111
  stackHydrator: StackHydrator | null = null
@@ -157,7 +158,10 @@ export class Kernel {
157
158
  // the key inert — including to keep the bar ON, "since it changes
158
159
  // nothing" — will now see it disappear. Both readings were possible
159
160
  // precisely because the flag did nothing.
160
- const debugBarEnabled = debugModuleOptions.enabled !== false
161
+ // The emergency switch wins over everything (#1900 lot C.3): a bar that
162
+ // is taking the app down has to be stoppable without a redeploy, so
163
+ // `?qddebug=off` overrides even an explicit `enabled: true`.
164
+ const debugBarEnabled = debugModuleOptions.enabled !== false && !isDebugBarKilled()
161
165
 
162
166
  // Store component for root wrapper
163
167
  setQdadmDebugBar(debugBarEnabled ? options.debugBar.component || null : null)
@@ -177,11 +181,28 @@ export class Kernel {
177
181
 
178
182
  this.options = options
179
183
 
180
- // The bus exists from here on (#1905 lot B). It has no dependencies, and
181
- // the natural place to wire it is right after `new Kernel()` — so it must
182
- // be usable there. _createSignalBus() is idempotent, so the later call in
183
- // createApp() keeps this instance rather than orphaning its listeners.
184
+ // Everything that CAN exist from here on, does (#1905 lot B, #1906 lot B1).
185
+ //
186
+ // These five have no dependency beyond `options` and each other, and the
187
+ // natural place to wire a kernel is right after `new Kernel()` — so they
188
+ // have to be usable there. Someone reached for `kernel.signals` before
189
+ // createApp(), got `null`, and `null?.emit?.()` swallowed it without a
190
+ // word; there were seventeen properties shaped like that, and these are
191
+ // the ones that need not have been.
192
+ //
193
+ // Every creator is idempotent, so the later calls in createApp() keep
194
+ // these instances rather than orphaning whatever was registered on them
195
+ // in between — which would be the same silent failure wearing a new hat.
196
+ // Before anything reads them (#1906 lot B2): a misspelled top-level key
197
+ // removes a whole section of configuration, and TypeScript only catches
198
+ // it for consumers whose module files are TypeScript.
199
+ this._validateKernelOptions()
200
+
184
201
  this._createSignalBus()
202
+ this._createHookRegistry()
203
+ this._createZoneRegistry()
204
+ this._createDeferredRegistry()
205
+ this._createPermissionRegistry()
185
206
  }
186
207
 
187
208
  /**
@@ -475,6 +496,14 @@ export interface Kernel {
475
496
  _createDeferredRegistry(): void
476
497
  _createEventRouter(): void
477
498
  _validateSseConfig(sse: SSEConfigType): void
499
+ _validateSecurityConfig(security: Record<string, unknown>): void
500
+ _validateKernelOptions(): void
501
+ _warnUnknownKeys(
502
+ scope: string,
503
+ config: Record<string, unknown>,
504
+ known: Set<string>,
505
+ consequences?: Record<string, string>
506
+ ): void
478
507
  _createSSEBridge(): void
479
508
 
480
509
  // Vue (Kernel.vue.ts)
@@ -70,10 +70,12 @@ export interface Pages {
70
70
  export interface LayoutComponents {
71
71
  list?: Component | null
72
72
  form?: Component | null
73
+ show?: Component | null
73
74
  dashboard?: Component | null
74
75
  base?: Component | null
75
76
  ListLayout?: Component
76
77
  FormLayout?: Component
78
+ ShowLayout?: Component
77
79
  DashboardLayout?: Component
78
80
  BaseLayout?: Component
79
81
  }
@@ -293,6 +295,7 @@ export interface KernelOptions {
293
295
  export interface InternalLayoutComponents {
294
296
  list: Component | null
295
297
  form: Component | null
298
+ show: Component | null
296
299
  dashboard: Component | null
297
300
  base: Component | null
298
301
  }
@@ -3,6 +3,7 @@ import {
3
3
  h,
4
4
  defineComponent,
5
5
  ref,
6
+ onErrorCaptured,
6
7
  type App,
7
8
  type Component,
8
9
  type Ref,
@@ -58,6 +59,51 @@ export function setQdadmDebugBar(component: Component | null): void {
58
59
  /**
59
60
  * Patch Kernel prototype with Vue app creation methods.
60
61
  */
62
+ /**
63
+ * The debug bar must not be able to take down the app it observes
64
+ * (#1900 lot C.1).
65
+ *
66
+ * Before this, a bar that threw during render propagated the error to the root
67
+ * and killed the application — a diagnostic tool failing precisely when
68
+ * someone needed it, and the consumer's only way out was to rebuild without
69
+ * it.
70
+ *
71
+ * `onErrorCaptured` returning false stops the error here: the bar is dropped,
72
+ * the app keeps rendering. The failure is loud in the console rather than on
73
+ * screen, because a crashed debug tool leaving debris in the corner of a
74
+ * working app helps nobody.
75
+ *
76
+ * This catches THROWN errors only. A runaway render loop throws nothing, so it
77
+ * needs its own guard — that is the bar's circuit breaker in `@quazardous/qddebug`,
78
+ * and the two are deliberately separate.
79
+ *
80
+ * Exported so the guard can be tested as a unit; the Kernel is its only caller.
81
+ */
82
+ export function createDebugBarBoundary(BarComponent: Component): Component {
83
+ return defineComponent({
84
+ name: 'QdadmDebugBarBoundary',
85
+ setup() {
86
+ const failed = ref(false)
87
+
88
+ onErrorCaptured((err: unknown) => {
89
+ if (!failed.value) {
90
+ failed.value = true
91
+ console.error(
92
+ '[qdadm] The debug bar threw and has been removed for this ' +
93
+ 'session. The application is unaffected. Use ?qddebug=off to ' +
94
+ 'keep it off across reloads.',
95
+ err
96
+ )
97
+ }
98
+ // Stop propagation: the app must not die with its instrument.
99
+ return false
100
+ })
101
+
102
+ return () => (failed.value ? null : h(BarComponent))
103
+ },
104
+ })
105
+ }
106
+
61
107
  export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
62
108
  const proto = KernelClass.prototype
63
109
 
@@ -69,6 +115,10 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
69
115
  this.layoutComponents = {
70
116
  list: layouts.list || layouts.ListLayout || null,
71
117
  form: layouts.form || layouts.FormLayout || null,
118
+ // Until #1922 this literal had no `show` key, so an app that passed one
119
+ // had it dropped without a word while crud({ show }) kept emitting
120
+ // `layout: 'show'` for a layout nothing could honour.
121
+ show: layouts.show || layouts.ShowLayout || null,
72
122
  dashboard: layouts.dashboard || layouts.DashboardLayout || null,
73
123
  base: layouts.base || layouts.BaseLayout || null,
74
124
  }
@@ -102,6 +152,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
102
152
  this.options.debugBar?.component && debugBarRef.value
103
153
  ? debugBarRef.value
104
154
  : null
155
+ const DebugBarBoundary = DebugBarComponent
156
+ ? createDebugBarBoundary(DebugBarComponent as Component)
157
+ : null
105
158
  const hasPrimeVue = !!this.options.primevue?.plugin
106
159
  const appKey = this._appKey
107
160
 
@@ -116,8 +169,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
116
169
  children.push(h(ToastListener))
117
170
  }
118
171
 
119
- if (DebugBarComponent) {
120
- children.push(h(DebugBarComponent))
172
+ if (DebugBarBoundary) {
173
+ children.push(h(DebugBarBoundary))
121
174
  }
122
175
 
123
176
  return h(
@@ -9,9 +9,13 @@
9
9
  * The string helpers (`singularize`, `toKebab`, `capitalize`) are kept
10
10
  * module-local — they're pure, stateless, and were only used by `crud()` and
11
11
  * `childPage()`. Keeping them off the prototype keeps the public shape clean.
12
+ * `singularize` is a thin call into the vendored `pluralize` since #1923: the
13
+ * hand-rolled version disagreed with the one `EntityManager` uses, and a
14
+ * framework cannot afford two answers to the same question.
12
15
  */
13
16
 
14
17
  import type { RouteRecordRaw } from 'vue-router'
18
+ import pluralize from '../utils/pluralize'
15
19
  import { registry, getRoutes } from '../module/moduleRegistry'
16
20
  import type {
17
21
  ChildPageOptions,
@@ -26,14 +30,21 @@ import type { KernelContext } from './KernelContext'
26
30
  // #1196 Phase B — this-typing against the real KernelContext shape (was Self = any)
27
31
  type Self = KernelContext
28
32
 
29
- /** Singularize a plural word (simple English rules) */
33
+ /**
34
+ * Singularize an entity name (#1923).
35
+ *
36
+ * This used to be six hand-rolled lines, while `EntityManager.routePrefix`
37
+ * used the vendored `pluralize`. Two engines, and they disagreed: `crud()`
38
+ * named a route `people-show` while `useListPage` looked for `person-show`,
39
+ * so every navigation helper missed silently — no error at boot, nothing until
40
+ * someone clicked. They agreed on every regular plural, which is exactly why
41
+ * it went unnoticed.
42
+ *
43
+ * One engine now, the real one. `pluralize` is vendored (#1454) and imports
44
+ * nothing, so there is no cycle to fear from calling it here.
45
+ */
30
46
  function singularize(plural: string): string {
31
- if (plural.endsWith('ies')) return plural.slice(0, -3) + 'y'
32
- if (plural.endsWith('ses') || plural.endsWith('xes') || plural.endsWith('zes')) {
33
- return plural.slice(0, -2)
34
- }
35
- if (plural.endsWith('s') && !plural.endsWith('ss')) return plural.slice(0, -1)
36
- return plural
47
+ return pluralize.singular(plural)
37
48
  }
38
49
 
39
50
  /** Convert camelCase to kebab-case (e.g. 'botTasks' → 'bot-tasks') */
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The emergency switch for the debug bar (#1900 lot C.3).
3
+ *
4
+ * A consumer whose debug bar took down the application it was observing had
5
+ * no way to turn it off: they had to rebuild and redeploy. A diagnostic tool
6
+ * that can only be disabled by a deploy fails exactly when it is needed.
7
+ *
8
+ * So the bar answers to a switch that needs no rebuild and no code change:
9
+ *
10
+ * ?qddebug=off turn it off, and REMEMBER that across navigations
11
+ * ?qddebug=on turn it back on (clears the memory)
12
+ *
13
+ * The query parameter is the way in — it is the only thing reachable when the
14
+ * app is unusable — and localStorage is what makes it stick, so the next page
15
+ * load is survivable too. Without persistence the switch would be lost by the
16
+ * first redirect, which is the moment a broken app is most likely to hit.
17
+ *
18
+ * This is a KILL switch, deliberately one-directional in effect: it can only
19
+ * take the bar away, never conjure one that `enabled: false` refused. See
20
+ * [[docs/adr/0011]] — the point of the switch is that it does something
21
+ * observable, and `?qddebug=on` on an app with no bar configured stays a
22
+ * no-op because there is nothing to turn on.
23
+ */
24
+
25
+ /** localStorage key, under qddebug's existing `qddebug:` namespace (ADR 0008). */
26
+ const KILL_KEY = 'qddebug:off'
27
+
28
+ /** Read a key without letting a hostile storage throw into the boot path. */
29
+ function readFlag(): boolean {
30
+ try {
31
+ return localStorage.getItem(KILL_KEY) === '1'
32
+ } catch {
33
+ // Private mode, disabled cookies, quota. Absent, not fatal.
34
+ return false
35
+ }
36
+ }
37
+
38
+ function writeFlag(on: boolean): void {
39
+ try {
40
+ if (on) localStorage.setItem(KILL_KEY, '1')
41
+ else localStorage.removeItem(KILL_KEY)
42
+ } catch {
43
+ // The switch still holds for THIS page load — the caller uses our return
44
+ // value, not the storage. It just will not survive a reload.
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Whether the debug bar has been killed for this browser.
50
+ *
51
+ * Reads the query parameter first (it is an explicit act, and it updates the
52
+ * remembered state), then falls back to what was remembered.
53
+ *
54
+ * Safe to call before anything is mounted, and outside a browser.
55
+ */
56
+ export function isDebugBarKilled(): boolean {
57
+ if (typeof window === 'undefined') return false
58
+
59
+ let param: string | null = null
60
+ try {
61
+ param = new URLSearchParams(window.location.search).get('qddebug')
62
+ } catch {
63
+ // Nothing readable in the URL; fall through to the remembered flag.
64
+ }
65
+
66
+ if (param === 'off') {
67
+ writeFlag(true)
68
+ return true
69
+ }
70
+ if (param === 'on') {
71
+ writeFlag(false)
72
+ return false
73
+ }
74
+
75
+ return readFlag()
76
+ }
77
+
78
+ /** Exported for tests and for an app that wants to offer its own switch. */
79
+ export const DEBUG_BAR_KILL_KEY = KILL_KEY