@quazardous/qdadm 2.18.0 → 2.20.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.
Files changed (38) hide show
  1. package/package.json +5 -7
  2. package/src/components/editors/VanillaJsonEditor.vue +14 -4
  3. package/src/components/lists/ListPage.vue +7 -0
  4. package/src/composables/index.ts +3 -0
  5. package/src/composables/useLayoutResolver.ts +10 -0
  6. package/src/composables/useListPage.filters.ts +137 -67
  7. package/src/composables/useListPage.ts +148 -26
  8. package/src/composables/useListPage.types.ts +74 -5
  9. package/src/composables/useListPage.utils.ts +58 -0
  10. package/src/entity/EntityManager.interface.ts +11 -0
  11. package/src/entity/EntityManager.ts +5 -0
  12. package/src/entity/EntityManager.types.ts +11 -0
  13. package/src/entity/storage/ApiStorage.ts +16 -2
  14. package/src/entity/storage/LocalStorage.ts +1 -1
  15. package/src/entity/storage/MemoryStorage.ts +7 -2
  16. package/src/entity/storage/MockApiStorage.ts +1 -1
  17. package/src/entity/storage/SdkStorage.ts +12 -3
  18. package/src/gen/createManagers.ts +15 -1
  19. package/src/gen/index.ts +7 -1
  20. package/src/index.ts +19 -0
  21. package/src/kernel/Kernel.registries.ts +202 -46
  22. package/src/kernel/Kernel.ts +34 -9
  23. package/src/kernel/Kernel.types.ts +19 -0
  24. package/src/kernel/Kernel.vue.ts +7 -0
  25. package/src/kernel/KernelContext.routing.ts +18 -7
  26. package/src/plugin.ts +1 -1
  27. package/src/query/clientFilter.ts +14 -3
  28. package/src/routeState/CompositePersister.ts +144 -0
  29. package/src/routeState/CookiePersister.ts +109 -0
  30. package/src/routeState/MemoryPersister.ts +60 -0
  31. package/src/routeState/NullPersister.ts +33 -0
  32. package/src/routeState/RouteStatePersister.ts +86 -0
  33. package/src/routeState/UrlPersister.ts +207 -0
  34. package/src/routeState/WebStoragePersister.ts +149 -0
  35. package/src/routeState/defaultPersister.ts +66 -0
  36. package/src/routeState/factory.ts +86 -0
  37. package/src/routeState/index.ts +32 -0
  38. package/src/routeState/keyLocator.ts +84 -0
@@ -1,6 +1,6 @@
1
1
  import { IStorage } from './IStorage'
2
2
  import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
3
- import { sortItems, filterItems, paginate } from '../../query/clientFilter'
3
+ import { sortItems, filterItems, paginate, searchItems } from '../../query/clientFilter'
4
4
 
5
5
  /**
6
6
  * SDK method result with data property
@@ -275,9 +275,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
275
275
  }
276
276
 
277
277
  async list(params: ListParams = {}): Promise<ListResult<T>> {
278
- const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
278
+ const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
279
279
 
280
- const queryParams = { query: { page, page_size, sort_by, sort_order, ...filters } }
280
+ // `search` goes to the SDK with everything else (#2147). It was dropped
281
+ // here, so a search box over an SDK-backed entity typed into the void:
282
+ // the call went out without the term and the rows came back unfiltered,
283
+ // with nothing anywhere saying why.
284
+ const queryParams = { query: { page, page_size, sort_by, sort_order, search, ...filters } }
281
285
  const data = (await this._execute('list', queryParams)) as ListResult<T>
282
286
 
283
287
  let { items, total } = data
@@ -292,8 +296,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
292
296
 
293
297
  // Shared pipeline (#1192) — original order preserved: sort, then
294
298
  // filter (substring semantics), then paginate.
299
+ //
300
+ // The search is applied here TOO, not only sent (#2147). An SDK that
301
+ // ignores the term would otherwise hand back the whole collection and
302
+ // this branch would paginate it as if it had been searched.
295
303
  items = sortItems([...items], sort_by, sort_order)
296
304
  items = filterItems(items, filters, { stringMatch: 'includes' })
305
+ items = searchItems(items, search)
297
306
  total = items.length
298
307
  items = paginate(items, page, page_size)
299
308
  }
@@ -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'
package/src/index.ts CHANGED
@@ -237,10 +237,13 @@ export * from './composables/useJsonSyntax'
237
237
  export {
238
238
  useListPage,
239
239
  PAGE_SIZE_OPTIONS,
240
+ FILTER_TYPES,
240
241
  type UseListPageOptions,
241
242
  type UseListPageReturn,
242
243
  type ColumnConfig,
243
244
  type FilterConfig,
245
+ type FilterOptions,
246
+ type FilterType,
244
247
  type ActionConfig as ListActionConfig,
245
248
  type ResolvedAction,
246
249
  type HeaderActionConfig,
@@ -322,6 +325,22 @@ export {
322
325
  type LayoutMeta,
323
326
  } from './composables/useLayoutResolver'
324
327
  export { useSSEBridge, type UseSSEBridgeReturn } from './composables/useSSEBridge'
328
+
329
+ // Route-state persistence (#2146) — where a route remembers what it shows.
330
+ export {
331
+ UrlPersister,
332
+ isRouteStatePersister,
333
+ resolveRouteStatePersister,
334
+ createRouteStatePersisterFactory,
335
+ type RouteStatePersister,
336
+ type RouteStateScope,
337
+ type RouteStatePersisterSpec,
338
+ type RouteStatePersisterFactory,
339
+ type UrlPersisterOptions,
340
+ dottedKeyLocator,
341
+ namespacedKeyLocator,
342
+ type RouteStateKeyLocator,
343
+ } from './routeState'
325
344
  export {
326
345
  useDeferred,
327
346
  useDeferredValue,
@@ -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', 'routeState', 'sectionOrder',
365
+ 'security', '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
@@ -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: SignalBus | null = null
97
+ signals!: SignalBus
98
98
  orchestrator: Orchestrator | null = null
99
- zoneRegistry: ZoneRegistry | null = null
100
- hookRegistry: HookRegistry | null = null
101
- deferred: DeferredRegistry | null = null
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: PermissionRegistry | null = null
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
- // The bus exists from here on (#1905 lot B). It has no dependencies, and
185
- // the natural place to wire it is right after `new Kernel()` — so it must
186
- // be usable there. _createSignalBus() is idempotent, so the later call in
187
- // 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
+
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)
@@ -12,6 +12,7 @@ import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
12
12
  import type { RoleProvider } from '../security/RolesProvider'
13
13
  import type { I18nOptions } from '../i18n/types'
14
14
  import type { ApiClientSource } from '../api/apiClient'
15
+ import type { RouteStatePersister } from '../routeState'
15
16
 
16
17
  /**
17
18
  * Auth adapter interface (app-level authentication)
@@ -70,10 +71,12 @@ export interface Pages {
70
71
  export interface LayoutComponents {
71
72
  list?: Component | null
72
73
  form?: Component | null
74
+ show?: Component | null
73
75
  dashboard?: Component | null
74
76
  base?: Component | null
75
77
  ListLayout?: Component
76
78
  FormLayout?: Component
79
+ ShowLayout?: Component
77
80
  DashboardLayout?: Component
78
81
  BaseLayout?: Component
79
82
  }
@@ -216,6 +219,21 @@ export interface KernelOptions {
216
219
  defaultEntityCacheTtlMs?: number
217
220
  eventRouter?: RoutesConfig
218
221
  sse?: SSEConfig
222
+ /**
223
+ * Where screens remember their route state by default (#2146): a slug the
224
+ * framework knows (`'url'`, `'local_storage'`, `'cookie'`, `'memory'`,
225
+ * `'none'`), or a persister instance.
226
+ *
227
+ * The app-wide floor. An entity overrides it for every screen showing that
228
+ * entity; a single list overrides it for itself. Unset means `'url'`, which
229
+ * is the only medium where what you are looking at is also what you can
230
+ * send someone.
231
+ *
232
+ * An unknown slug throws at list construction rather than falling back —
233
+ * a persistence choice that quietly does something else is the failure
234
+ * ADR 0011 forbids.
235
+ */
236
+ routeState?: string | RouteStatePersister
219
237
  debugBar?: DebugBarConfig
220
238
  notifications?: NotificationsConfig
221
239
  toast?: Record<string, unknown>
@@ -293,6 +311,7 @@ export interface KernelOptions {
293
311
  export interface InternalLayoutComponents {
294
312
  list: Component | null
295
313
  form: Component | null
314
+ show: Component | null
296
315
  dashboard: Component | null
297
316
  base: Component | null
298
317
  }
@@ -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
  }
@@ -319,6 +323,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
319
323
  }
320
324
 
321
325
  app.provide('qdadmHooks', this.hookRegistry)
326
+ // The app-wide route-state default (#2146). Provided even when unset, so
327
+ // a list can tell "nobody chose" from "somebody chose and it was null".
328
+ app.provide('qdadmRouteState', this.options.routeState ?? null)
322
329
  app.provide('qdadmDeferred', this.deferred)
323
330
  app.provide('qdadmLayoutComponents', this.layoutComponents)
324
331
  // Surface PrimeVue presence so host-rendered <QdadmRoot /> can
@@ -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') */
package/src/plugin.ts CHANGED
@@ -107,7 +107,7 @@ export interface QdadmPlugin {
107
107
  export function createQdadm(options: QdadmOptions): QdadmPlugin {
108
108
  return {
109
109
  install(app: App) {
110
- // Validation des contrats requis
110
+ // The contracts this plugin cannot run without.
111
111
  if (!options.entityFactory && !options.orchestrator && !options.managers) {
112
112
  throw new Error('[qdadm] entityFactory, orchestrator, or managers required')
113
113
  }