qdadm 0.52.2 → 0.53.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": "qdadm",
3
- "version": "0.52.2",
3
+ "version": "0.53.1",
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)
352
364
  * resolveStorage(method, context) {
353
- * const chain = context?.parentChain || []
354
- * if (chain.length === 2 && chain[0].entity === 'users' && chain[1].entity === 'posts') {
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
373
+ * resolveStorage(method, context) {
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
  /**
@@ -712,14 +776,15 @@ export class EntityManager {
712
776
 
713
777
  /**
714
778
  * Get initial data for a new entity based on field defaults
779
+ * @param {object} [context] - Routing context passed to default functions
715
780
  * @returns {object}
716
781
  */
717
- getInitialData() {
782
+ getInitialData(context = null) {
718
783
  const data = {}
719
784
  for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
720
785
  if (fieldConfig.default !== undefined) {
721
786
  data[fieldName] = typeof fieldConfig.default === 'function'
722
- ? fieldConfig.default()
787
+ ? fieldConfig.default(context)
723
788
  : fieldConfig.default
724
789
  } else {
725
790
  // Type-based defaults
@@ -744,6 +809,26 @@ export class EntityManager {
744
809
  return data
745
810
  }
746
811
 
812
+ /**
813
+ * Apply field defaults to data (for create operations)
814
+ * Only applies defaults for fields that are undefined in data.
815
+ * @param {object} data - Provided data
816
+ * @param {object} [context] - Routing context passed to default functions
817
+ * @returns {object} - Data with defaults applied
818
+ */
819
+ applyDefaults(data, context = null) {
820
+ const result = { ...data }
821
+ for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
822
+ // Only apply default if field is undefined in data
823
+ if (result[fieldName] === undefined && fieldConfig.default !== undefined) {
824
+ result[fieldName] = typeof fieldConfig.default === 'function'
825
+ ? fieldConfig.default(context)
826
+ : fieldConfig.default
827
+ }
828
+ }
829
+ return result
830
+ }
831
+
747
832
  /**
748
833
  * Get field names that are required
749
834
  * @returns {string[]}
@@ -931,7 +1016,8 @@ export class EntityManager {
931
1016
  * @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
932
1017
  */
933
1018
  async list(params = {}, context) {
934
- const { storage, path } = this.resolveStorage('list', context)
1019
+ const resolved = this._normalizeResolveResult(this.resolveStorage('list', context), context)
1020
+ const { storage, endpoint, params: resolvedParams } = resolved
935
1021
  if (!storage) {
936
1022
  throw new Error(`[EntityManager:${this.name}] list() not implemented`)
937
1023
  }
@@ -939,19 +1025,22 @@ export class EntityManager {
939
1025
  // Extract internal flag and cacheSafe flag
940
1026
  const { _internal = false, cacheSafe = false, ...queryParams } = params
941
1027
 
1028
+ // Merge resolved params (defaults) with query params (overrides)
1029
+ const mergedParams = resolvedParams ? { ...resolvedParams, ...queryParams } : queryParams
1030
+
942
1031
  // Only count stats for non-internal operations
943
1032
  if (!_internal) {
944
1033
  this._stats.list++
945
1034
  }
946
1035
 
947
- const hasFilters = queryParams.search || Object.keys(queryParams.filters || {}).length > 0
1036
+ const hasFilters = mergedParams.search || Object.keys(mergedParams.filters || {}).length > 0
948
1037
  const canUseCache = !hasFilters || cacheSafe
949
1038
 
950
1039
  // 1. Cache valid + cacheable → use cache with local filtering
951
1040
  if (this._cache.valid && canUseCache) {
952
1041
  if (!_internal) this._stats.cacheHits++
953
1042
  console.log('[cache] Using local cache for entity:', this.name)
954
- const filtered = this._filterLocally(this._cache.items, queryParams)
1043
+ const filtered = this._filterLocally(this._cache.items, mergedParams)
955
1044
  // Update max stats
956
1045
  if (filtered.items.length > this._stats.maxItemsSeen) {
957
1046
  this._stats.maxItemsSeen = filtered.items.length
@@ -966,10 +1055,10 @@ export class EntityManager {
966
1055
 
967
1056
  // 2. Fetch from API
968
1057
  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 })
1058
+ if (endpoint && storage.request) {
1059
+ // Use request() with endpoint for multi-storage routing
1060
+ // Build query params for GET request, pass context for normalize()
1061
+ const apiResponse = await storage.request('GET', endpoint, { params: mergedParams, context })
973
1062
  // Normalize response: handle both { data: [...], pagination: {...} } and { items, total }
974
1063
  const data = apiResponse.data ?? apiResponse
975
1064
  response = {
@@ -978,7 +1067,7 @@ export class EntityManager {
978
1067
  }
979
1068
  } else {
980
1069
  // Standard storage.list() (normalizes response to { items, total })
981
- response = await storage.list(queryParams)
1070
+ response = await storage.list(mergedParams, context)
982
1071
  }
983
1072
  const items = response.items || []
984
1073
  const total = response.total ?? items.length
@@ -1027,7 +1116,7 @@ export class EntityManager {
1027
1116
  * @returns {Promise<object>}
1028
1117
  */
1029
1118
  async get(id, context) {
1030
- const { storage, path } = this.resolveStorage('get', context)
1119
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('get', context), context)
1031
1120
  this._stats.get++
1032
1121
 
1033
1122
  // Try cache first if valid and complete
@@ -1043,12 +1132,12 @@ export class EntityManager {
1043
1132
  // Fallback to storage
1044
1133
  this._stats.cacheMisses++
1045
1134
  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}`)
1135
+ // Use request() with endpoint for multi-storage routing, otherwise use get()
1136
+ if (endpoint && storage.request) {
1137
+ const response = await storage.request('GET', `${endpoint}/${id}`, { context })
1049
1138
  return response.data ?? response
1050
1139
  }
1051
- return storage.get(id)
1140
+ return storage.get(id, context)
1052
1141
  }
1053
1142
  throw new Error(`[EntityManager:${this.name}] get() not implemented`)
1054
1143
  }
@@ -1060,9 +1149,10 @@ export class EntityManager {
1060
1149
  * Otherwise fetches from storage (or parallel get() calls).
1061
1150
  *
1062
1151
  * @param {Array<string|number>} ids
1152
+ * @param {object} [context] - Routing context for multi-storage
1063
1153
  * @returns {Promise<Array<object>>}
1064
1154
  */
1065
- async getMany(ids) {
1155
+ async getMany(ids, context) {
1066
1156
  if (!ids || ids.length === 0) return []
1067
1157
 
1068
1158
  // Try cache first if valid and complete
@@ -1082,12 +1172,13 @@ export class EntityManager {
1082
1172
 
1083
1173
  this._stats.cacheMisses += ids.length
1084
1174
 
1085
- if (this.storage?.getMany) {
1086
- return this.storage.getMany(ids)
1175
+ const { storage } = this._normalizeResolveResult(this.resolveStorage('getMany', context), context)
1176
+ if (storage?.getMany) {
1177
+ return storage.getMany(ids, context)
1087
1178
  }
1088
1179
  // Fallback: parallel get calls (get() handles its own stats)
1089
1180
  this._stats.cacheMisses -= ids.length // Avoid double counting
1090
- return Promise.all(ids.map(id => this.get(id).catch(() => null)))
1181
+ return Promise.all(ids.map(id => this.get(id, context).catch(() => null)))
1091
1182
  .then(results => results.filter(Boolean))
1092
1183
  }
1093
1184
 
@@ -1103,17 +1194,20 @@ export class EntityManager {
1103
1194
  * @returns {Promise<object>} - The created entity
1104
1195
  */
1105
1196
  async create(data, context) {
1106
- const { storage, path } = this.resolveStorage('create', context)
1197
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('create', context), context)
1107
1198
  this._stats.create++
1108
1199
  if (storage) {
1200
+ // Apply field defaults before presave hooks
1201
+ const dataWithDefaults = this.applyDefaults(data, context)
1202
+
1109
1203
  // Invoke presave hooks (can modify data or throw to abort)
1110
- const presaveContext = this._buildPresaveContext(data, true)
1204
+ const presaveContext = this._buildPresaveContext(dataWithDefaults, true)
1111
1205
  await this._invokeHook('presave', presaveContext)
1112
1206
 
1113
- // Use request() with path for multi-storage routing, otherwise use create()
1207
+ // Use request() with endpoint for multi-storage routing, otherwise use create()
1114
1208
  let result
1115
- if (path && storage.request) {
1116
- const response = await storage.request('POST', path, { data: presaveContext.record })
1209
+ if (endpoint && storage.request) {
1210
+ const response = await storage.request('POST', endpoint, { data: presaveContext.record, context })
1117
1211
  result = response.data ?? response
1118
1212
  } else {
1119
1213
  result = await storage.create(presaveContext.record)
@@ -1148,17 +1242,17 @@ export class EntityManager {
1148
1242
  * @returns {Promise<object>}
1149
1243
  */
1150
1244
  async update(id, data, context) {
1151
- const { storage, path } = this.resolveStorage('update', context)
1245
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('update', context), context)
1152
1246
  this._stats.update++
1153
1247
  if (storage) {
1154
1248
  // Invoke presave hooks (can modify data or throw to abort)
1155
1249
  const presaveContext = this._buildPresaveContext(data, false, id)
1156
1250
  await this._invokeHook('presave', presaveContext)
1157
1251
 
1158
- // Use request() with path for multi-storage routing, otherwise use update()
1252
+ // Use request() with endpoint for multi-storage routing, otherwise use update()
1159
1253
  let result
1160
- if (path && storage.request) {
1161
- const response = await storage.request('PUT', `${path}/${id}`, { data: presaveContext.record })
1254
+ if (endpoint && storage.request) {
1255
+ const response = await storage.request('PUT', `${endpoint}/${id}`, { data: presaveContext.record, context })
1162
1256
  result = response.data ?? response
1163
1257
  } else {
1164
1258
  result = await storage.update(id, presaveContext.record)
@@ -1193,17 +1287,17 @@ export class EntityManager {
1193
1287
  * @returns {Promise<object>}
1194
1288
  */
1195
1289
  async patch(id, data, context) {
1196
- const { storage, path } = this.resolveStorage('patch', context)
1290
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('patch', context), context)
1197
1291
  this._stats.update++ // patch counts as update
1198
1292
  if (storage) {
1199
1293
  // Invoke presave hooks (can modify data or throw to abort)
1200
1294
  const presaveContext = this._buildPresaveContext(data, false, id)
1201
1295
  await this._invokeHook('presave', presaveContext)
1202
1296
 
1203
- // Use request() with path for multi-storage routing, otherwise use patch()
1297
+ // Use request() with endpoint for multi-storage routing, otherwise use patch()
1204
1298
  let result
1205
- if (path && storage.request) {
1206
- const response = await storage.request('PATCH', `${path}/${id}`, { data: presaveContext.record })
1299
+ if (endpoint && storage.request) {
1300
+ const response = await storage.request('PATCH', `${endpoint}/${id}`, { data: presaveContext.record, context })
1207
1301
  result = response.data ?? response
1208
1302
  } else {
1209
1303
  result = await storage.patch(id, presaveContext.record)
@@ -1236,17 +1330,17 @@ export class EntityManager {
1236
1330
  * @returns {Promise<void>}
1237
1331
  */
1238
1332
  async delete(id, context) {
1239
- const { storage, path } = this.resolveStorage('delete', context)
1333
+ const { storage, endpoint } = this._normalizeResolveResult(this.resolveStorage('delete', context), context)
1240
1334
  this._stats.delete++
1241
1335
  if (storage) {
1242
1336
  // Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
1243
1337
  const predeleteContext = this._buildPredeleteContext(id)
1244
1338
  await this._invokeHook('predelete', predeleteContext)
1245
1339
 
1246
- // Use request() with path for multi-storage routing, otherwise use delete()
1340
+ // Use request() with endpoint for multi-storage routing, otherwise use delete()
1247
1341
  let result
1248
- if (path && storage.request) {
1249
- result = await storage.request('DELETE', `${path}/${id}`)
1342
+ if (endpoint && storage.request) {
1343
+ result = await storage.request('DELETE', `${endpoint}/${id}`, { context })
1250
1344
  } else {
1251
1345
  result = await storage.delete(id)
1252
1346
  }
@@ -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