@quazardous/qdadm 2.1.0 → 2.2.1

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.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -90,7 +90,9 @@ export function applyCacheMethods(EntityManagerClass: { prototype: any }): void
90
90
  Object.defineProperty(proto, 'overflow', {
91
91
  get(this: Self): boolean {
92
92
  const cache = this._cache
93
- return cache.valid && cache.total > cache.items.length
93
+ // overflowed: total exceeded effectiveThreshold, cache was never
94
+ // filled (#1204) — the cache is unusable either way.
95
+ return cache.overflowed === true || (cache.valid && cache.total > cache.items.length)
94
96
  },
95
97
  configurable: true,
96
98
  })
@@ -398,6 +400,7 @@ export function applyCacheMethods(EntityManagerClass: { prototype: any }): void
398
400
  cache.valid = false
399
401
  cache.items = []
400
402
  cache.total = 0
403
+ cache.overflowed = false
401
404
  cache.loadedAt = null
402
405
  this._cacheLoading = null
403
406
  // Also clear detail cache
@@ -182,6 +182,7 @@ export function applyCrudMethods(EntityManagerClass: { prototype: any }): void {
182
182
  cache.items = items
183
183
  cache.total = total
184
184
  cache.valid = true
185
+ cache.overflowed = false
185
186
  cache.loadedAt = Date.now()
186
187
  // Resolve parent fields for search (book.title, user.username, etc.)
187
188
  await this._resolveSearchFields(items)
@@ -191,6 +192,11 @@ export function applyCrudMethods(EntityManagerClass: { prototype: any }): void {
191
192
  // Fire-and-forget: load full cache in background
192
193
  this._loadCacheInBackground()
193
194
  }
195
+ } else if (canUseCache && this.isCacheEnabled && total > this.effectiveThreshold) {
196
+ // Entity outgrew the threshold: the cache can never hold it. Mark it
197
+ // so query() stops filtering an empty cache and goes to the API
198
+ // instead of returning an empty list (#1204).
199
+ cache.overflowed = true
194
200
  }
195
201
 
196
202
  return { items, total, fromCache: false }
@@ -31,7 +31,7 @@ export function applyQueryMethods(EntityManagerClass: { prototype: any }): void
31
31
  // Ensure cache is filled (via list) — skip for dynamic endpoints
32
32
  // since list() disables caching when resolveStorage provides an endpoint
33
33
  const cache = this._cache
34
- if (!isDynamicEndpoint && !cache.valid && this.isCacheEnabled) {
34
+ if (!isDynamicEndpoint && !cache.valid && !cache.overflowed && this.isCacheEnabled) {
35
35
  await this.list(
36
36
  { page_size: this.effectiveThreshold },
37
37
  routingContext ?? undefined
@@ -40,8 +40,11 @@ export function applyQueryMethods(EntityManagerClass: { prototype: any }): void
40
40
 
41
41
  let result: ListResult<any>
42
42
 
43
- // Dynamic endpoint, overflow, or cache disabled → use API directly
44
- if (isDynamicEndpoint || this.overflow || !this.isCacheEnabled) {
43
+ // Dynamic endpoint, overflow, cache disabled, or cache still not
44
+ // valid (fill failed / threshold exceeded) → use API directly. The
45
+ // cache is an optional layer: it must never turn into an empty
46
+ // result when it can't hold the data (#1204).
47
+ if (isDynamicEndpoint || this.overflow || !this.isCacheEnabled || !cache.valid) {
45
48
  result = await this.list(params, routingContext ?? undefined)
46
49
  } else {
47
50
  // Full cache available - filter locally
@@ -114,6 +114,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
114
114
  total: 0,
115
115
  loadedAt: null,
116
116
  valid: false,
117
+ overflowed: false,
117
118
  }
118
119
  protected _cacheLoading: Promise<boolean> | null = null
119
120
 
@@ -26,6 +26,8 @@ export interface CacheState<T> {
26
26
  total: number
27
27
  loadedAt: number | null
28
28
  valid: boolean
29
+ /** Entity total exceeded effectiveThreshold — cache can't hold it (#1204). */
30
+ overflowed?: boolean
29
31
  }
30
32
 
31
33
  /**
@@ -1,46 +0,0 @@
1
- /**
2
- * TestManager - Auto-generated EntityManager
3
- *
4
- * Generated by qdadm/gen/generateManagers
5
- * DO NOT EDIT MANUALLY - Changes will be overwritten
6
- *
7
- * Entity: test
8
- * Endpoint: /test
9
- */
10
-
11
- import { EntityManager } from '@quazardous/qdadm'
12
- import type { EntityRecord } from '@quazardous/qdadm'
13
- import { ApiStorage } from '@quazardous/qdadm'
14
-
15
- export interface TestEntity extends EntityRecord {
16
- }
17
-
18
- /**
19
- * Schema definition for test
20
- */
21
- export const testSchema = {
22
- name: "test",
23
- endpoint: "/test",
24
- fields: {}
25
- } as const
26
-
27
- /**
28
- * Storage options for test
29
- */
30
- const storageOptions = {
31
- endpoint: "/test"
32
- }
33
-
34
- /**
35
- * TestManager instance
36
- *
37
- * Provides CRUD operations for test entity.
38
- */
39
- export const testManager = new EntityManager<TestEntity>({
40
- ...{
41
- name: "test",
42
- idField: "id",
43
- fields: {}
44
- },
45
- storage: new ApiStorage<TestEntity>(storageOptions)
46
- })
@@ -212,7 +212,11 @@ export function applyRoutingMethods(KernelClass: { prototype: any }): void {
212
212
  }
213
213
 
214
214
  const entity = to.meta?.entity as string | undefined
215
- if (entity && this.orchestrator) {
215
+ // Entity not registered → nothing to check yet (e.g. child route
216
+ // declared before its manager loads) — allow. This is the ONLY
217
+ // allowed pass-through; a failing access check must fail CLOSED,
218
+ // not silently allow (#1190).
219
+ if (entity && this.orchestrator && this.orchestrator.isRegistered(entity)) {
216
220
  try {
217
221
  const manager = this.orchestrator.get(entity)
218
222
  if (manager && !manager.canRead()) {
@@ -232,8 +236,13 @@ export function applyRoutingMethods(KernelClass: { prototype: any }): void {
232
236
  next({ path: '/' })
233
237
  return
234
238
  }
235
- } catch {
236
- // Entity not registered - allow navigation
239
+ } catch (err) {
240
+ console.error(
241
+ `[qdadm] Access check failed for ${to.path} (entity: ${entity}) — denying navigation:`,
242
+ err
243
+ )
244
+ next({ path: '/' })
245
+ return
237
246
  }
238
247
  }
239
248
 
@@ -192,6 +192,26 @@ export interface KernelOptions {
192
192
  * wiring. Typically the same client your ApiStorages use.
193
193
  */
194
194
  apiClient?: ApiClientSource
195
+ /**
196
+ * Naming policy for the URL param carrying the parent id in child route
197
+ * families (#1201). `auto` (default): list-only families keep the bare
198
+ * `idField` param; families with show/edit routes namespace the parent
199
+ * (`jobs/:jobId/tasks/:id`) so the principal (child) id stays `:id`.
200
+ * `always`: namespaced everywhere. `bare`: legacy `idField` param, with
201
+ * an explicit registration error on collision.
202
+ */
203
+ parentParamMode?: 'auto' | 'always' | 'bare'
204
+ /**
205
+ * Global mapping service for parent URL param names (#1201). Consulted
206
+ * before `parentParamMode`; a per-call `parentParam` still wins. Return
207
+ * undefined to fall through to the mode-based default.
208
+ */
209
+ routeParamResolver?: (ctx: {
210
+ parentEntity: string
211
+ parentRouteName: string
212
+ childEntity: string | null
213
+ parentIdField: string
214
+ }) => string | undefined
195
215
  /**
196
216
  * Existing Vue app to install qdadm onto. When provided, the Kernel
197
217
  * skips its own `createApp(WrappedRoot)` and reuses the supplied
@@ -45,6 +45,89 @@ function capitalize(str: string): string {
45
45
  return str.charAt(0).toUpperCase() + str.slice(1)
46
46
  }
47
47
 
48
+ /** Context handed to a kernel-level routeParamResolver (#1201). */
49
+ export interface RouteParamResolverContext {
50
+ parentEntity: string
51
+ parentRouteName: string
52
+ /** Child entity name, or null for a non-entity childPage. */
53
+ childEntity: string | null
54
+ parentIdField: string
55
+ }
56
+
57
+ /**
58
+ * Resolve the URL param name carrying the parent id in a child route
59
+ * family (#1201). Precedence: per-call `parentParam` > kernel
60
+ * `routeParamResolver` > `parentParamMode`:
61
+ *
62
+ * - `auto` (default): families whose routes never carry the child's own
63
+ * id (list-only, childPage) keep the bare parent `idField` param —
64
+ * unchanged URLs. Families with show/edit routes namespace the parent
65
+ * (`:jobId`) so the principal (child) id stays `:id`.
66
+ * - `always`: namespaced parent param everywhere.
67
+ * - `bare`: legacy `idField` param everywhere; a would-be collision
68
+ * throws at registration instead of silently shadowing the parent.
69
+ */
70
+ function resolveParentParam(opts: {
71
+ kernelOptions?: {
72
+ parentParamMode?: 'auto' | 'always' | 'bare'
73
+ routeParamResolver?: (ctx: RouteParamResolverContext) => string | undefined
74
+ }
75
+ explicit?: string
76
+ parentEntity: string
77
+ parentRouteName: string
78
+ childEntity: string | null
79
+ parentIdField: string
80
+ childIdParam: string
81
+ hasOwnIdParam: boolean
82
+ }): string {
83
+ const {
84
+ kernelOptions,
85
+ explicit,
86
+ parentEntity,
87
+ parentRouteName,
88
+ childEntity,
89
+ parentIdField,
90
+ childIdParam,
91
+ hasOwnIdParam,
92
+ } = opts
93
+
94
+ if (explicit) return explicit
95
+
96
+ const resolved = kernelOptions?.routeParamResolver?.({
97
+ parentEntity,
98
+ parentRouteName,
99
+ childEntity,
100
+ parentIdField,
101
+ })
102
+ if (resolved) return resolved
103
+
104
+ const mode = kernelOptions?.parentParamMode || 'auto'
105
+ const namespaced = `${singularize(parentEntity)}Id`
106
+
107
+ if (mode === 'always') return namespaced
108
+
109
+ if (mode === 'bare') {
110
+ if (hasOwnIdParam && parentIdField === childIdParam) {
111
+ throw new Error(
112
+ `[qdadm] crud('${childEntity}', { parentRoute: '${parentRouteName}' }): parent and child ` +
113
+ `both use the URL param ':${childIdParam}' — the child id would shadow the parent id. ` +
114
+ `Switch parentParamMode to 'auto', or set an explicit parentParam.`
115
+ )
116
+ }
117
+ return parentIdField
118
+ }
119
+
120
+ // mode === 'auto'
121
+ if (!hasOwnIdParam) return parentIdField
122
+ if (namespaced === childIdParam) {
123
+ throw new Error(
124
+ `[qdadm] crud('${childEntity}', { parentRoute: '${parentRouteName}' }): the namespaced ` +
125
+ `parent param ':${namespaced}' collides with the child idField — set an explicit parentParam.`
126
+ )
127
+ }
128
+ return namespaced
129
+ }
130
+
48
131
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
132
  export function applyRoutingMethods(KernelContextClass: { prototype: any }): void {
50
133
  const proto = KernelContextClass.prototype as Self
@@ -103,7 +186,20 @@ export function applyRoutingMethods(KernelContextClass: { prototype: any }): voi
103
186
  const parentManager = parentEntityName
104
187
  ? this._kernel.orchestrator?.get(parentEntityName)
105
188
  : null
106
- const parentIdParam = parentManager?.idField || 'id'
189
+ const parentIdField = parentManager?.idField || 'id'
190
+ // Families with show/edit routes carry the child's own :id — the
191
+ // parent param must not collide with it (#1201).
192
+ const hasOwnIdParam = !!(pages.show || pages.form || pages.edit)
193
+ const parentIdParam = resolveParentParam({
194
+ kernelOptions: this._kernel.options,
195
+ explicit: options.parentParam,
196
+ parentEntity: parentEntityName || parentRouteName,
197
+ parentRouteName,
198
+ childEntity: entity,
199
+ parentIdField,
200
+ childIdParam: idParam,
201
+ hasOwnIdParam,
202
+ })
107
203
 
108
204
  // Build base path: parentPath/:parentId/entity (e.g., books/:bookId/loans)
109
205
  const parentBasePath =
@@ -238,7 +334,19 @@ export function applyRoutingMethods(KernelContextClass: { prototype: any }): voi
238
334
  const parentManager = parentEntityName
239
335
  ? this._kernel.orchestrator?.get(parentEntityName)
240
336
  : null
241
- const parentIdParam = parentManager?.idField || 'id'
337
+ const parentIdField = parentManager?.idField || 'id'
338
+ // childPage has no own :id (single-id family) — auto mode keeps the
339
+ // bare param; explicit parentParam / resolver / 'always' still apply.
340
+ const parentIdParam = resolveParentParam({
341
+ kernelOptions: this._kernel.options,
342
+ explicit: options.parentParam,
343
+ parentEntity: parentEntityName || parentRouteName,
344
+ parentRouteName,
345
+ childEntity: null,
346
+ parentIdField,
347
+ childIdParam: 'id',
348
+ hasOwnIdParam: false,
349
+ })
242
350
 
243
351
  const parentBasePath =
244
352
  parentRoute.path.replace(/\/(create|:.*)?$/, '') || parentEntityName
@@ -148,6 +148,12 @@ export interface CrudOptions {
148
148
  parentRoute?: string
149
149
  foreignKey?: string
150
150
  label?: string
151
+ /**
152
+ * Explicit URL param name carrying the parent id in child routes
153
+ * (e.g. 'jobUuid' → `jobs/:jobUuid/tasks/:id`). Overrides the kernel
154
+ * `routeParamResolver` and `parentParamMode` (#1201).
155
+ */
156
+ parentParam?: string
151
157
  }
152
158
 
153
159
  /** Child page options for non-entity child routes */
@@ -156,6 +162,8 @@ export interface ChildPageOptions {
156
162
  label?: string
157
163
  icon?: string
158
164
  meta?: Record<string, unknown>
165
+ /** Explicit URL param name for the parent id (see CrudOptions.parentParam). */
166
+ parentParam?: string
159
167
  }
160
168
 
161
169
  /** User entity options */