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 +1 -1
- package/src/entity/EntityManager.js +111 -43
- package/src/entity/storage/ApiStorage.js +29 -15
package/package.json
CHANGED
|
@@ -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
|
|
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') //
|
|
339
|
+
* manager.resolveStorage('list') // undefined = use default storage
|
|
332
340
|
*
|
|
333
341
|
* @example
|
|
334
|
-
* //
|
|
335
|
-
*
|
|
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
|
-
* //
|
|
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
|
-
*
|
|
344
|
-
*
|
|
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
|
-
* //
|
|
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
|
-
*
|
|
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.
|
|
357
|
-
*
|
|
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
|
|
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 =
|
|
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,
|
|
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 (
|
|
970
|
-
// Use request() with
|
|
971
|
-
// Build query params for GET request
|
|
972
|
-
const apiResponse = await storage.request('GET',
|
|
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(
|
|
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,
|
|
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
|
|
1047
|
-
if (
|
|
1048
|
-
const response = await storage.request('GET', `${
|
|
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,
|
|
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
|
|
1181
|
+
// Use request() with endpoint for multi-storage routing, otherwise use create()
|
|
1114
1182
|
let result
|
|
1115
|
-
if (
|
|
1116
|
-
const response = await storage.request('POST',
|
|
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,
|
|
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
|
|
1226
|
+
// Use request() with endpoint for multi-storage routing, otherwise use update()
|
|
1159
1227
|
let result
|
|
1160
|
-
if (
|
|
1161
|
-
const response = await storage.request('PUT', `${
|
|
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,
|
|
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
|
|
1271
|
+
// Use request() with endpoint for multi-storage routing, otherwise use patch()
|
|
1204
1272
|
let result
|
|
1205
|
-
if (
|
|
1206
|
-
const response = await storage.request('PATCH', `${
|
|
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,
|
|
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
|
|
1314
|
+
// Use request() with endpoint for multi-storage routing, otherwise use delete()
|
|
1247
1315
|
let result
|
|
1248
|
-
if (
|
|
1249
|
-
result = await storage.request('DELETE', `${
|
|
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}
|
|
234
|
-
*
|
|
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,
|
|
238
|
-
const
|
|
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:
|
|
243
|
-
params:
|
|
244
|
-
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
|
|