qdadm 0.52.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.52.2",
3
+ "version": "0.53.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -324,46 +324,110 @@ export class EntityManager {
324
324
  * @param {string} method - Operation: 'list', 'get', 'create', 'update', 'delete'
325
325
  * @param {object} [context] - Routing context
326
326
  * @param {Array<{entity: string, id: string|number}>} [context.parentChain] - Parent chain from root to direct parent
327
- * @returns {{ storage: IStorage, path?: string, mappingKey?: string }}
327
+ * @returns {string | function | { storage?: IStorage, endpoint?: string | function, params?: object }}
328
+ * - `string`: Full endpoint URL (uses primary storage)
329
+ * - `function`: Dynamic endpoint builder `(context) => string` (marks as dynamic)
330
+ * - `{ endpoint }`: Full endpoint URL (uses primary storage)
331
+ * - `{ endpoint: function }`: Dynamic endpoint builder (marks as dynamic)
332
+ * - `{ endpoint, params }`: Endpoint with default query params (merged with request params)
333
+ * - `{ storage, endpoint }`: Custom storage with full endpoint
334
+ * - `{ storage }`: Custom storage with its default endpoint
335
+ * - `undefined/null`: Use primary storage with its default endpoint
328
336
  *
329
337
  * @example
330
338
  * // Single storage (default, no change needed)
331
- * manager.resolveStorage('list') // { storage: this.storage }
339
+ * manager.resolveStorage('list') // undefined = use default storage
332
340
  *
333
341
  * @example
334
- * // Access direct parent (last in chain)
335
- * const parent = context?.parentChain?.at(-1)
342
+ * // Endpoint override (simplest, recommended)
343
+ * resolveStorage(method, context) {
344
+ * const parent = context?.parentChain?.at(-1)
345
+ * if (parent?.entity === 'bots') {
346
+ * return `/api/admin/bots/${parent.id}/commands`
347
+ * }
348
+ * }
336
349
  *
337
350
  * @example
338
- * // Multi-storage routing in subclass
351
+ * // Endpoint with default query params
339
352
  * resolveStorage(method, context) {
340
353
  * const parent = context?.parentChain?.at(-1)
341
354
  * if (parent?.entity === 'bots') {
342
355
  * return {
343
- * storage: this.botStorage,
344
- * path: `/${parent.id}/commands`
356
+ * endpoint: `/api/admin/bots/${parent.id}/commands`,
357
+ * params: { include: 'bot', status: 'pending' }
345
358
  * }
346
359
  * }
347
- * return { storage: this.storage }
348
360
  * }
349
361
  *
350
362
  * @example
351
- * // Nested parents: /users/:userId/posts/:postId/comments
363
+ * // Dynamic endpoint builder (marks endpoint as context-dependent for debug panel)
364
+ * resolveStorage(method, context) {
365
+ * const parent = context?.parentChain?.at(-1)
366
+ * if (parent?.entity === 'bots') {
367
+ * return (ctx) => `/api/admin/bots/${ctx.parentChain.at(-1).id}/commands`
368
+ * }
369
+ * }
370
+ *
371
+ * @example
372
+ * // Different storage with endpoint
352
373
  * resolveStorage(method, context) {
353
- * const chain = context?.parentChain || []
354
- * if (chain.length === 2 && chain[0].entity === 'users' && chain[1].entity === 'posts') {
374
+ * if (context?.parentChain?.at(-1)?.entity === 'projects') {
355
375
  * return {
356
- * storage: this.nestedStorage,
357
- * path: `/${chain[0].id}/posts/${chain[1].id}/comments`
376
+ * storage: this.projectStorage,
377
+ * endpoint: `/api/projects/${parent.id}/tasks`
358
378
  * }
359
379
  * }
360
- * return { storage: this.storage }
361
380
  * }
362
381
  */
363
382
  resolveStorage(method, context) {
364
383
  return { storage: this.storage }
365
384
  }
366
385
 
386
+ /**
387
+ * Normalize resolveStorage() return value to standard format
388
+ * @param {string | function | object} result - Result from resolveStorage()
389
+ * @param {object} [context] - Context to pass to endpoint builder function
390
+ * @returns {{ storage: IStorage, endpoint?: string, params?: object, isDynamic?: boolean }}
391
+ * @private
392
+ */
393
+ _normalizeResolveResult(result, context) {
394
+ // Function = dynamic endpoint builder
395
+ if (typeof result === 'function') {
396
+ return {
397
+ storage: this.storage,
398
+ endpoint: result(context),
399
+ isDynamic: true
400
+ }
401
+ }
402
+
403
+ // String = endpoint with primary storage
404
+ if (typeof result === 'string') {
405
+ return { storage: this.storage, endpoint: result }
406
+ }
407
+
408
+ // Null/undefined = primary storage
409
+ if (!result) {
410
+ return { storage: this.storage }
411
+ }
412
+
413
+ // Object with endpoint function = dynamic endpoint builder
414
+ if (typeof result.endpoint === 'function') {
415
+ return {
416
+ storage: result.storage || this.storage,
417
+ endpoint: result.endpoint(context),
418
+ params: result.params,
419
+ isDynamic: true
420
+ }
421
+ }
422
+
423
+ // Object without storage = use primary storage
424
+ if (!result.storage) {
425
+ return { storage: this.storage, ...result }
426
+ }
427
+
428
+ return result
429
+ }
430
+
367
431
  // ============ METADATA ACCESSORS ============
368
432
 
369
433
  /**
@@ -931,7 +995,8 @@ export class EntityManager {
931
995
  * @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
932
996
  */
933
997
  async list(params = {}, context) {
934
- const { storage, path } = this.resolveStorage('list', context)
998
+ const resolved = this._normalizeResolveResult(this.resolveStorage('list', context), context)
999
+ const { storage, endpoint, params: resolvedParams } = resolved
935
1000
  if (!storage) {
936
1001
  throw new Error(`[EntityManager:${this.name}] list() not implemented`)
937
1002
  }
@@ -939,19 +1004,22 @@ export class EntityManager {
939
1004
  // Extract internal flag and cacheSafe flag
940
1005
  const { _internal = false, cacheSafe = false, ...queryParams } = params
941
1006
 
1007
+ // Merge resolved params (defaults) with query params (overrides)
1008
+ const mergedParams = resolvedParams ? { ...resolvedParams, ...queryParams } : queryParams
1009
+
942
1010
  // Only count stats for non-internal operations
943
1011
  if (!_internal) {
944
1012
  this._stats.list++
945
1013
  }
946
1014
 
947
- const hasFilters = queryParams.search || Object.keys(queryParams.filters || {}).length > 0
1015
+ const hasFilters = mergedParams.search || Object.keys(mergedParams.filters || {}).length > 0
948
1016
  const canUseCache = !hasFilters || cacheSafe
949
1017
 
950
1018
  // 1. Cache valid + cacheable → use cache with local filtering
951
1019
  if (this._cache.valid && canUseCache) {
952
1020
  if (!_internal) this._stats.cacheHits++
953
1021
  console.log('[cache] Using local cache for entity:', this.name)
954
- const filtered = this._filterLocally(this._cache.items, queryParams)
1022
+ const filtered = this._filterLocally(this._cache.items, mergedParams)
955
1023
  // Update max stats
956
1024
  if (filtered.items.length > this._stats.maxItemsSeen) {
957
1025
  this._stats.maxItemsSeen = filtered.items.length
@@ -966,10 +1034,10 @@ export class EntityManager {
966
1034
 
967
1035
  // 2. Fetch from API
968
1036
  let response
969
- if (path && storage.request) {
970
- // Use request() with path for multi-storage routing
971
- // Build query params for GET request
972
- const apiResponse = await storage.request('GET', path, { params: queryParams })
1037
+ if (endpoint && storage.request) {
1038
+ // Use request() with endpoint for multi-storage routing
1039
+ // Build query params for GET request, pass context for normalize()
1040
+ const apiResponse = await storage.request('GET', endpoint, { params: mergedParams, context })
973
1041
  // Normalize response: handle both { data: [...], pagination: {...} } and { items, total }
974
1042
  const data = apiResponse.data ?? apiResponse
975
1043
  response = {
@@ -978,7 +1046,7 @@ export class EntityManager {
978
1046
  }
979
1047
  } else {
980
1048
  // Standard storage.list() (normalizes response to { items, total })
981
- response = await storage.list(queryParams)
1049
+ response = await storage.list(mergedParams, context)
982
1050
  }
983
1051
  const items = response.items || []
984
1052
  const total = response.total ?? items.length
@@ -1027,7 +1095,7 @@ export class EntityManager {
1027
1095
  * @returns {Promise<object>}
1028
1096
  */
1029
1097
  async get(id, context) {
1030
- const { storage, path } = this.resolveStorage('get', context)
1098
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('get', context), context)
1031
1099
  this._stats.get++
1032
1100
 
1033
1101
  // Try cache first if valid and complete
@@ -1043,12 +1111,12 @@ export class EntityManager {
1043
1111
  // Fallback to storage
1044
1112
  this._stats.cacheMisses++
1045
1113
  if (storage) {
1046
- // Use request() with path for multi-storage routing, otherwise use get()
1047
- if (path && storage.request) {
1048
- const response = await storage.request('GET', `${path}/${id}`)
1114
+ // Use request() with endpoint for multi-storage routing, otherwise use get()
1115
+ if (endpoint && storage.request) {
1116
+ const response = await storage.request('GET', `${endpoint}/${id}`, { context })
1049
1117
  return response.data ?? response
1050
1118
  }
1051
- return storage.get(id)
1119
+ return storage.get(id, context)
1052
1120
  }
1053
1121
  throw new Error(`[EntityManager:${this.name}] get() not implemented`)
1054
1122
  }
@@ -1103,17 +1171,17 @@ export class EntityManager {
1103
1171
  * @returns {Promise<object>} - The created entity
1104
1172
  */
1105
1173
  async create(data, context) {
1106
- const { storage, path } = this.resolveStorage('create', context)
1174
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('create', context), context)
1107
1175
  this._stats.create++
1108
1176
  if (storage) {
1109
1177
  // Invoke presave hooks (can modify data or throw to abort)
1110
1178
  const presaveContext = this._buildPresaveContext(data, true)
1111
1179
  await this._invokeHook('presave', presaveContext)
1112
1180
 
1113
- // Use request() with path for multi-storage routing, otherwise use create()
1181
+ // Use request() with endpoint for multi-storage routing, otherwise use create()
1114
1182
  let result
1115
- if (path && storage.request) {
1116
- const response = await storage.request('POST', path, { data: presaveContext.record })
1183
+ if (endpoint && storage.request) {
1184
+ const response = await storage.request('POST', endpoint, { data: presaveContext.record, context })
1117
1185
  result = response.data ?? response
1118
1186
  } else {
1119
1187
  result = await storage.create(presaveContext.record)
@@ -1148,17 +1216,17 @@ export class EntityManager {
1148
1216
  * @returns {Promise<object>}
1149
1217
  */
1150
1218
  async update(id, data, context) {
1151
- const { storage, path } = this.resolveStorage('update', context)
1219
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('update', context), context)
1152
1220
  this._stats.update++
1153
1221
  if (storage) {
1154
1222
  // Invoke presave hooks (can modify data or throw to abort)
1155
1223
  const presaveContext = this._buildPresaveContext(data, false, id)
1156
1224
  await this._invokeHook('presave', presaveContext)
1157
1225
 
1158
- // Use request() with path for multi-storage routing, otherwise use update()
1226
+ // Use request() with endpoint for multi-storage routing, otherwise use update()
1159
1227
  let result
1160
- if (path && storage.request) {
1161
- const response = await storage.request('PUT', `${path}/${id}`, { data: presaveContext.record })
1228
+ if (endpoint && storage.request) {
1229
+ const response = await storage.request('PUT', `${endpoint}/${id}`, { data: presaveContext.record, context })
1162
1230
  result = response.data ?? response
1163
1231
  } else {
1164
1232
  result = await storage.update(id, presaveContext.record)
@@ -1193,17 +1261,17 @@ export class EntityManager {
1193
1261
  * @returns {Promise<object>}
1194
1262
  */
1195
1263
  async patch(id, data, context) {
1196
- const { storage, path } = this.resolveStorage('patch', context)
1264
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('patch', context), context)
1197
1265
  this._stats.update++ // patch counts as update
1198
1266
  if (storage) {
1199
1267
  // Invoke presave hooks (can modify data or throw to abort)
1200
1268
  const presaveContext = this._buildPresaveContext(data, false, id)
1201
1269
  await this._invokeHook('presave', presaveContext)
1202
1270
 
1203
- // Use request() with path for multi-storage routing, otherwise use patch()
1271
+ // Use request() with endpoint for multi-storage routing, otherwise use patch()
1204
1272
  let result
1205
- if (path && storage.request) {
1206
- const response = await storage.request('PATCH', `${path}/${id}`, { data: presaveContext.record })
1273
+ if (endpoint && storage.request) {
1274
+ const response = await storage.request('PATCH', `${endpoint}/${id}`, { data: presaveContext.record, context })
1207
1275
  result = response.data ?? response
1208
1276
  } else {
1209
1277
  result = await storage.patch(id, presaveContext.record)
@@ -1236,17 +1304,17 @@ export class EntityManager {
1236
1304
  * @returns {Promise<void>}
1237
1305
  */
1238
1306
  async delete(id, context) {
1239
- const { storage, path } = this.resolveStorage('delete', context)
1307
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('delete', context), context)
1240
1308
  this._stats.delete++
1241
1309
  if (storage) {
1242
1310
  // Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
1243
1311
  const predeleteContext = this._buildPredeleteContext(id)
1244
1312
  await this._invokeHook('predelete', predeleteContext)
1245
1313
 
1246
- // Use request() with path for multi-storage routing, otherwise use delete()
1314
+ // Use request() with endpoint for multi-storage routing, otherwise use delete()
1247
1315
  let result
1248
- if (path && storage.request) {
1249
- result = await storage.request('DELETE', `${path}/${id}`)
1316
+ if (endpoint && storage.request) {
1317
+ result = await storage.request('DELETE', `${endpoint}/${id}`, { context })
1250
1318
  } else {
1251
1319
  result = await storage.delete(id)
1252
1320
  }
@@ -114,14 +114,17 @@ export class ApiStorage extends IStorage {
114
114
  /**
115
115
  * Normalize API response data to internal format
116
116
  * @param {object|Array} data - API response data
117
+ * @param {object} [context] - Routing context from EntityManager
118
+ * @param {Array<{entity: string, id: string}>} [context.parentChain] - Parent hierarchy
119
+ * @param {string} [context.path] - API path used for the request
117
120
  * @returns {object|Array} - Normalized data
118
121
  */
119
- _normalizeData(data) {
122
+ _normalizeData(data, context = null) {
120
123
  if (!this._normalize) return data
121
124
  if (Array.isArray(data)) {
122
- return data.map(item => this._normalize(item))
125
+ return data.map(item => this._normalize(item, context))
123
126
  }
124
- return this._normalize(data)
127
+ return this._normalize(data, context)
125
128
  }
126
129
 
127
130
  /**
@@ -153,9 +156,11 @@ export class ApiStorage extends IStorage {
153
156
  * @param {string} [params.sort_by] - Field to sort by
154
157
  * @param {string} [params.sort_order='asc'] - Sort order ('asc' or 'desc')
155
158
  * @param {object} [params.filters] - Field filters { field: value }
159
+ * @param {object} [context] - Routing context for normalize()
160
+ * @param {Array<{entity: string, id: string}>} [context.parentChain] - Parent hierarchy
156
161
  * @returns {Promise<{ items: Array, total: number }>}
157
162
  */
158
- async list(params = {}) {
163
+ async list(params = {}, context = null) {
159
164
  const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
160
165
 
161
166
  // WIP: Apply param mapping to filters
@@ -168,7 +173,7 @@ export class ApiStorage extends IStorage {
168
173
  const data = response.data
169
174
  const items = data[this.responseItemsKey] || data.items || data
170
175
  return {
171
- items: this._normalizeData(items),
176
+ items: this._normalizeData(items, context),
172
177
  total: data[this.responseTotalKey] || data.total || (Array.isArray(data) ? data.length : 0)
173
178
  }
174
179
  }
@@ -176,11 +181,12 @@ export class ApiStorage extends IStorage {
176
181
  /**
177
182
  * Get a single entity by ID
178
183
  * @param {string|number} id
184
+ * @param {object} [context] - Routing context for normalize()
179
185
  * @returns {Promise<object>}
180
186
  */
181
- async get(id) {
187
+ async get(id, context = null) {
182
188
  const response = await this.client.get(`${this.endpoint}/${id}`)
183
- return this._normalizeData(response.data)
189
+ return this._normalizeData(response.data, context)
184
190
  }
185
191
 
186
192
  /**
@@ -230,20 +236,28 @@ export class ApiStorage extends IStorage {
230
236
  /**
231
237
  * Generic request for special operations
232
238
  * @param {string} method - 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'
233
- * @param {string} path - Relative path (appended to endpoint)
234
- * @param {object} options - { data, params, headers }
239
+ * @param {string} endpoint - Full endpoint URL (starts with /) or relative path
240
+ * - Full endpoint (/api/...): used as-is
241
+ * - Relative path (no leading /): appended to this.endpoint
242
+ * @param {object} options - Request options
243
+ * @param {object} [options.data] - Request body
244
+ * @param {object} [options.params] - Query parameters
245
+ * @param {object} [options.headers] - Additional headers
246
+ * @param {object} [options.context] - Routing context for normalize()
247
+ * @param {Array<{entity: string, id: string}>} [options.context.parentChain] - Parent hierarchy
235
248
  * @returns {Promise<any>}
236
249
  */
237
- async request(method, path, options = {}) {
238
- const url = path.startsWith('/') ? path : `${this.endpoint}/${path}`
250
+ async request(method, endpoint, options = {}) {
251
+ const { context, ...requestOptions } = options
252
+ const url = endpoint.startsWith('/') ? endpoint : `${this.endpoint}/${endpoint}`
239
253
  const response = await this.client.request({
240
254
  method,
241
255
  url,
242
- data: options.data,
243
- params: options.params,
244
- headers: options.headers
256
+ data: requestOptions.data,
257
+ params: requestOptions.params,
258
+ headers: requestOptions.headers
245
259
  })
246
- return response.data
260
+ return this._normalizeData(response.data, context)
247
261
  }
248
262
  }
249
263