@quazardous/qdadm 2.18.0 → 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 +4 -6
- package/src/components/editors/VanillaJsonEditor.vue +14 -4
- package/src/composables/useLayoutResolver.ts +10 -0
- package/src/composables/useListPage.ts +42 -8
- package/src/gen/createManagers.ts +15 -1
- package/src/gen/index.ts +7 -1
- package/src/kernel/Kernel.registries.ts +202 -46
- package/src/kernel/Kernel.ts +34 -9
- package/src/kernel/Kernel.types.ts +3 -0
- package/src/kernel/Kernel.vue.ts +4 -0
- package/src/kernel/KernelContext.routing.ts +18 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
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,7 +54,7 @@
|
|
|
54
54
|
"dist/node"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@quazardous/qdcore": "^1.
|
|
57
|
+
"@quazardous/qdcore": "^1.2.0",
|
|
58
58
|
"@quazardous/qddebug": "^1.2.0",
|
|
59
59
|
"@quazardous/quarkernel": "^2.1.0",
|
|
60
60
|
"yaml": "^2.8.4"
|
|
@@ -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":
|
|
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
|
-
|
|
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<
|
|
78
|
-
let editor:
|
|
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 =
|
|
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 },
|
|
@@ -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'
|
|
@@ -855,21 +855,55 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
855
855
|
return { name: `${routePrefix}-create` }
|
|
856
856
|
}
|
|
857
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
|
+
|
|
858
893
|
function goToCreate(): void {
|
|
859
|
-
|
|
894
|
+
const target = findCreateRoute() as { name: string; params?: Record<string, unknown> }
|
|
895
|
+
pushNamedRoute(target.name, target.params)
|
|
860
896
|
}
|
|
861
897
|
|
|
862
898
|
function goToEdit(item: unknown): void {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
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,
|
|
866
901
|
})
|
|
867
902
|
}
|
|
868
903
|
|
|
869
904
|
function goToShow(item: unknown): void {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
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,
|
|
873
907
|
})
|
|
874
908
|
}
|
|
875
909
|
|
|
@@ -196,7 +196,7 @@ function findSchemaForEntity(
|
|
|
196
196
|
* const users = await usersManager.list()
|
|
197
197
|
* ```
|
|
198
198
|
*/
|
|
199
|
-
export function
|
|
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 {
|
|
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
|
|
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
|
|
113
|
+
this.permissionRegistry.register('auth', {
|
|
106
114
|
impersonate: 'Impersonate other users',
|
|
107
115
|
manage: 'Manage authentication settings',
|
|
108
116
|
})
|
|
109
117
|
|
|
110
|
-
this.permissionRegistry
|
|
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
|
|
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
|
-
*
|
|
243
|
+
* The known key a typo most likely meant, or undefined.
|
|
229
244
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 =
|
|
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]
|
|
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
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -94,18 +94,18 @@ export class Kernel {
|
|
|
94
94
|
* window existed for no reason. Removing it beats warning about it
|
|
95
95
|
* (ADR 0011).
|
|
96
96
|
*/
|
|
97
|
-
signals
|
|
97
|
+
signals!: SignalBus
|
|
98
98
|
orchestrator: Orchestrator | null = null
|
|
99
|
-
zoneRegistry
|
|
100
|
-
hookRegistry
|
|
101
|
-
deferred
|
|
99
|
+
zoneRegistry!: ZoneRegistry
|
|
100
|
+
hookRegistry!: HookRegistry
|
|
101
|
+
deferred!: DeferredRegistry
|
|
102
102
|
eventRouter: EventRouter | null = null
|
|
103
103
|
sseBridge: SSEBridge | null = null
|
|
104
104
|
/** Routes external entity changes to cache invalidation (#1888). */
|
|
105
105
|
liveEntityRouter: LiveEntityRouter | null = null
|
|
106
106
|
layoutComponents: InternalLayoutComponents | null = null
|
|
107
107
|
securityChecker: SecurityChecker | null = null
|
|
108
|
-
permissionRegistry
|
|
108
|
+
permissionRegistry!: PermissionRegistry
|
|
109
109
|
moduleLoader: ModuleLoader | null = null
|
|
110
110
|
activeStack: ActiveStack | null = null
|
|
111
111
|
stackHydrator: StackHydrator | null = null
|
|
@@ -181,11 +181,28 @@ export class Kernel {
|
|
|
181
181
|
|
|
182
182
|
this.options = options
|
|
183
183
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
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
|
+
|
|
188
201
|
this._createSignalBus()
|
|
202
|
+
this._createHookRegistry()
|
|
203
|
+
this._createZoneRegistry()
|
|
204
|
+
this._createDeferredRegistry()
|
|
205
|
+
this._createPermissionRegistry()
|
|
189
206
|
}
|
|
190
207
|
|
|
191
208
|
/**
|
|
@@ -479,6 +496,14 @@ export interface Kernel {
|
|
|
479
496
|
_createDeferredRegistry(): void
|
|
480
497
|
_createEventRouter(): void
|
|
481
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
|
|
482
507
|
_createSSEBridge(): void
|
|
483
508
|
|
|
484
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
|
}
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -115,6 +115,10 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
115
115
|
this.layoutComponents = {
|
|
116
116
|
list: layouts.list || layouts.ListLayout || null,
|
|
117
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,
|
|
118
122
|
dashboard: layouts.dashboard || layouts.DashboardLayout || null,
|
|
119
123
|
base: layouts.base || layouts.BaseLayout || null,
|
|
120
124
|
}
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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') */
|