@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 +1 -1
- package/src/entity/EntityManager.cache.ts +4 -1
- package/src/entity/EntityManager.crud.ts +6 -0
- package/src/entity/EntityManager.query.ts +6 -3
- package/src/entity/EntityManager.ts +1 -0
- package/src/entity/EntityManager.types.ts +2 -0
- package/src/generated/managers/testManager.ts +0 -46
- package/src/kernel/Kernel.routing.ts +12 -3
- package/src/kernel/Kernel.types.ts +20 -0
- package/src/kernel/KernelContext.routing.ts +110 -2
- package/src/kernel/KernelContext.types.ts +8 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
44
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 */
|