qdadm 0.18.0 → 0.26.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.
@@ -1,5 +1,6 @@
1
1
  import { PermissiveAuthAdapter } from './auth/PermissiveAdapter.js'
2
2
  import { AuthActions } from './auth/AuthAdapter.js'
3
+ import { QueryExecutor } from '../query/QueryExecutor.js'
3
4
 
4
5
  /**
5
6
  * EntityManager - Base class for entity CRUD operations
@@ -62,6 +63,7 @@ export class EntityManager {
62
63
  // Relations
63
64
  children = {}, // { roles: { entity: 'roles', endpoint?: ':id/roles' } }
64
65
  parent = null, // { entity: 'users', foreignKey: 'user_id' }
66
+ parents = {}, // { book: { entity: 'books', foreignKey: 'book_id' } } - multi-parent support
65
67
  relations = {}, // { groups: { entity: 'groups', through: 'user_groups' } }
66
68
  // Auth adapter (for permission checks)
67
69
  authAdapter = null // AuthAdapter instance or null (uses PermissiveAuthAdapter)
@@ -88,6 +90,7 @@ export class EntityManager {
88
90
  // Relations
89
91
  this._children = children
90
92
  this._parent = parent
93
+ this._parents = parents
91
94
  this._relations = relations
92
95
  this._orchestrator = null // Set when registered
93
96
 
@@ -111,6 +114,9 @@ export class EntityManager {
111
114
 
112
115
  // SignalBus reference for event emission (set by Orchestrator)
113
116
  this._signals = null
117
+
118
+ // Cleanup function for signal listeners
119
+ this._signalCleanup = null
114
120
  }
115
121
 
116
122
  // ============ SIGNALS ============
@@ -121,6 +127,7 @@ export class EntityManager {
121
127
  */
122
128
  setSignals(signals) {
123
129
  this._signals = signals
130
+ this._setupCacheListeners()
124
131
  }
125
132
 
126
133
  /**
@@ -687,6 +694,7 @@ export class EntityManager {
687
694
 
688
695
  // 1. Cache valid + cacheable → use cache with local filtering
689
696
  if (this._cache.valid && canUseCache) {
697
+ console.log('[cache] Using local cache for entity:', this.name)
690
698
  const filtered = this._filterLocally(this._cache.items, queryParams)
691
699
  return { ...filtered, fromCache: true }
692
700
  }
@@ -696,12 +704,17 @@ export class EntityManager {
696
704
  const items = response.items || []
697
705
  const total = response.total ?? items.length
698
706
 
699
- // 3. Fill cache opportunistically if cacheable and cache enabled
700
- if (canUseCache && this.isCacheEnabled) {
701
- this._cache.items = items.slice(0, this.effectiveThreshold)
702
- this._cache.total = total // Always keep real total from API
707
+ // 3. Fill cache opportunistically if:
708
+ // - canUseCache (no filters or cacheSafe filters)
709
+ // - isCacheEnabled (threshold > 0 and storage supports total)
710
+ // - total <= threshold (all items fit in cache for complete local filtering)
711
+ if (canUseCache && this.isCacheEnabled && total <= this.effectiveThreshold) {
712
+ this._cache.items = items // Store all items (no slicing - total fits threshold)
713
+ this._cache.total = total
703
714
  this._cache.valid = true
704
715
  this._cache.loadedAt = Date.now()
716
+ // Resolve parent fields for search (book.title, user.username, etc.)
717
+ await this._resolveSearchFields(items)
705
718
  }
706
719
 
707
720
  return { items, total, fromCache: false }
@@ -896,16 +909,196 @@ export class EntityManager {
896
909
  return this.localFilterThreshold ?? 100
897
910
  }
898
911
 
912
+ /**
913
+ * Check if storage adapter supports returning total count
914
+ *
915
+ * Auto-caching only makes sense when storage can report total items
916
+ * (to compare against threshold). Reads from storage.constructor.capabilities.supportsTotal.
917
+ *
918
+ * @returns {boolean} - true if storage supports total, false otherwise
919
+ */
920
+ get storageSupportsTotal() {
921
+ const caps = this.storage?.constructor?.capabilities || {}
922
+ return caps.supportsTotal ?? false
923
+ }
924
+
925
+ /**
926
+ * Get searchable fields declared by storage adapter
927
+ *
928
+ * Returns the list of fields that should be searched in _filterLocally().
929
+ * Supports both own fields ('title') and parent entity fields ('book.title').
930
+ * When undefined (not declared), all string fields are searched.
931
+ *
932
+ * @returns {string[]|undefined} - Array of field names or undefined
933
+ */
934
+ get storageSearchFields() {
935
+ const caps = this.storage?.constructor?.capabilities || {}
936
+ return caps.searchFields
937
+ }
938
+
939
+ /**
940
+ * Parse searchFields into own fields and parent fields
941
+ *
942
+ * Separates fields without dots (own fields like 'title') from fields with
943
+ * dots (parent fields like 'book.title'). Groups parent fields by parentKey.
944
+ *
945
+ * @returns {{ ownFields: string[], parentFields: Object<string, string[]> }}
946
+ */
947
+ _parseSearchFields(overrideSearchFields = null) {
948
+ const searchFields = overrideSearchFields ?? this.storageSearchFields ?? []
949
+ const ownFields = []
950
+ const parentFields = {}
951
+
952
+ for (const field of searchFields) {
953
+ if (!field.includes('.')) {
954
+ ownFields.push(field)
955
+ continue
956
+ }
957
+
958
+ // Split on first dot only
959
+ const [parentKey, fieldName] = field.split('.', 2)
960
+
961
+ // Validate parentKey exists in parents config
962
+ if (!this._parents[parentKey]) {
963
+ console.warn(`[EntityManager:${this.name}] Unknown parent '${parentKey}' in searchFields '${field}', skipping`)
964
+ continue
965
+ }
966
+
967
+ // Group by parentKey
968
+ if (!parentFields[parentKey]) {
969
+ parentFields[parentKey] = []
970
+ }
971
+ parentFields[parentKey].push(fieldName)
972
+ }
973
+
974
+ return { ownFields, parentFields }
975
+ }
976
+
977
+ /**
978
+ * Resolve parent entity fields for searchable caching
979
+ *
980
+ * Batch-fetches parent entities and caches their field values on each item
981
+ * in a non-enumerable `_search` property to avoid JSON serialization issues.
982
+ *
983
+ * @param {Array} items - Items to resolve parent fields for
984
+ * @returns {Promise<void>}
985
+ */
986
+ async _resolveSearchFields(items) {
987
+ const { parentFields } = this._parseSearchFields()
988
+
989
+ // No parent fields to resolve
990
+ if (Object.keys(parentFields).length === 0) return
991
+
992
+ // Need orchestrator to access other managers
993
+ if (!this._orchestrator) {
994
+ console.warn(`[EntityManager:${this.name}] No orchestrator, cannot resolve parent fields`)
995
+ return
996
+ }
997
+
998
+ // Process each parent type
999
+ for (const [parentKey, fields] of Object.entries(parentFields)) {
1000
+ const config = this._parents[parentKey]
1001
+
1002
+ if (!config) {
1003
+ console.warn(`[EntityManager:${this.name}] Missing parent config for '${parentKey}'`)
1004
+ continue
1005
+ }
1006
+
1007
+ // Extract unique parent IDs (deduplicated)
1008
+ const ids = [...new Set(items.map(i => i[config.foreignKey]).filter(Boolean))]
1009
+ if (ids.length === 0) continue
1010
+
1011
+ // Batch fetch parent entities
1012
+ const manager = this._orchestrator.get(config.entity)
1013
+ if (!manager) {
1014
+ console.warn(`[EntityManager:${this.name}] Manager not found for '${config.entity}'`)
1015
+ continue
1016
+ }
1017
+
1018
+ const parents = await manager.getMany(ids)
1019
+ const parentMap = new Map(parents.map(p => [p[manager.idField], p]))
1020
+
1021
+ // Cache resolved values in _search (non-enumerable)
1022
+ for (const item of items) {
1023
+ // Initialize _search as non-enumerable if needed
1024
+ if (!item._search) {
1025
+ Object.defineProperty(item, '_search', {
1026
+ value: {},
1027
+ enumerable: false,
1028
+ writable: true,
1029
+ configurable: true
1030
+ })
1031
+ }
1032
+
1033
+ const parent = parentMap.get(item[config.foreignKey])
1034
+ for (const field of fields) {
1035
+ item._search[`${parentKey}.${field}`] = parent?.[field] ?? ''
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Set up signal listeners for parent entity cache invalidation
1043
+ *
1044
+ * When a parent entity is modified, clears the _search cache on cached items
1045
+ * so that next list() will re-resolve with fresh parent data.
1046
+ */
1047
+ _setupCacheListeners() {
1048
+ // Nothing to do if no parents or no signals
1049
+ if (!this._parents || Object.keys(this._parents).length === 0) return
1050
+ if (!this._signals) return
1051
+
1052
+ // Clean up existing listener if any
1053
+ if (this._signalCleanup) {
1054
+ this._signalCleanup()
1055
+ this._signalCleanup = null
1056
+ }
1057
+
1058
+ // Get parent entity names
1059
+ const parentEntities = Object.values(this._parents).map(p => p.entity)
1060
+
1061
+ // Listen for parent cache invalidation
1062
+ this._signalCleanup = this._signals.on('cache:entity:invalidated', ({ entity }) => {
1063
+ if (parentEntities.includes(entity)) {
1064
+ this._clearSearchCache()
1065
+ }
1066
+ })
1067
+ }
1068
+
1069
+ /**
1070
+ * Clear the _search cache from all cached items
1071
+ *
1072
+ * Called when a parent entity is invalidated. Forces refetch and
1073
+ * re-resolution of parent fields on next list() call.
1074
+ */
1075
+ _clearSearchCache() {
1076
+ if (!this._cache.valid) return
1077
+
1078
+ for (const item of this._cache.items) {
1079
+ if (item._search) {
1080
+ item._search = {}
1081
+ }
1082
+ }
1083
+
1084
+ // Invalidate cache so next list() refetches
1085
+ this.invalidateCache()
1086
+ }
1087
+
899
1088
  /**
900
1089
  * Check if caching is enabled for this entity
1090
+ *
901
1091
  * Caching is disabled if:
902
1092
  * - threshold is 0 (explicit disable)
903
1093
  * - storage declares supportsCaching = false (e.g., LocalStorage)
1094
+ * - storage does not support total count (cannot determine if items fit threshold)
1095
+ *
904
1096
  * @returns {boolean}
905
1097
  */
906
1098
  get isCacheEnabled() {
907
1099
  if (this.effectiveThreshold <= 0) return false
908
1100
  if (this.storage?.supportsCaching === false) return false
1101
+ if (!this.storageSupportsTotal) return false
909
1102
  return true
910
1103
  }
911
1104
 
@@ -920,13 +1113,24 @@ export class EntityManager {
920
1113
 
921
1114
  /**
922
1115
  * Invalidate the cache (call after create/update/delete)
1116
+ *
1117
+ * Emits cache:entity:invalidated signal only when cache was previously valid
1118
+ * to avoid duplicate signals on repeated invalidation calls.
923
1119
  */
924
1120
  invalidateCache() {
1121
+ const wasValid = this._cache.valid
1122
+
925
1123
  this._cache.valid = false
926
1124
  this._cache.items = []
927
1125
  this._cache.total = 0
928
1126
  this._cache.loadedAt = null
929
1127
  this._cacheLoading = null
1128
+
1129
+ // Emit cache invalidation signal only when cache was actually valid
1130
+ // This prevents noise on repeated invalidation calls
1131
+ if (wasValid && this._signals) {
1132
+ this._signals.emit('cache:entity:invalidated', { entity: this.name })
1133
+ }
930
1134
  }
931
1135
 
932
1136
  /**
@@ -969,6 +1173,8 @@ export class EntityManager {
969
1173
  this._cache.total = result.total
970
1174
  this._cache.loadedAt = Date.now()
971
1175
  this._cache.valid = true
1176
+ // Resolve parent fields for search (book.title, user.username, etc.)
1177
+ await this._resolveSearchFields(this._cache.items)
972
1178
  return true
973
1179
  }
974
1180
 
@@ -996,9 +1202,11 @@ export class EntityManager {
996
1202
 
997
1203
  // If overflow or cache disabled, use API for accurate filtered results
998
1204
  if (this.overflow || !this.isCacheEnabled) {
1205
+ console.log('[cache] API call for entity:', this.name, '(total > threshold)', 'isCacheEnabled:', this.isCacheEnabled, 'overflow:', this.overflow)
999
1206
  result = await this.list(params)
1000
1207
  } else {
1001
1208
  // Full cache available - filter locally
1209
+ console.log('[cache] Using local cache for entity:', this.name)
1002
1210
  const filtered = this._filterLocally(this._cache.items, params)
1003
1211
  result = { ...filtered, fromCache: true }
1004
1212
  }
@@ -1011,8 +1219,35 @@ export class EntityManager {
1011
1219
  return result
1012
1220
  }
1013
1221
 
1222
+ /**
1223
+ * Build MongoDB-like query from filters object
1224
+ *
1225
+ * Converts EntityManager filter params to QueryExecutor format:
1226
+ * - Skips null/undefined/empty string values
1227
+ * - Arrays become implicit $in (handled by QueryExecutor)
1228
+ * - Single values stay as implicit $eq
1229
+ *
1230
+ * @param {object} filters - Field filters { field: value }
1231
+ * @returns {object} - Query object for QueryExecutor
1232
+ * @private
1233
+ */
1234
+ _buildQuery(filters) {
1235
+ const query = {}
1236
+ for (const [field, value] of Object.entries(filters)) {
1237
+ if (value === null || value === undefined || value === '') continue
1238
+ // Arrays and single values are passed through as-is
1239
+ // QueryExecutor handles implicit $in for arrays and $eq for primitives
1240
+ query[field] = value
1241
+ }
1242
+ return query
1243
+ }
1244
+
1014
1245
  /**
1015
1246
  * Apply filters, search, sort and pagination locally
1247
+ *
1248
+ * Uses QueryExecutor for filtering (MongoDB-like operators supported).
1249
+ * Sort and pagination are applied after filtering.
1250
+ *
1016
1251
  * @param {Array} items - All items
1017
1252
  * @param {object} params - Query params
1018
1253
  * @returns {{ items: Array, total: number }}
@@ -1020,6 +1255,7 @@ export class EntityManager {
1020
1255
  _filterLocally(items, params = {}) {
1021
1256
  const {
1022
1257
  search = '',
1258
+ searchFields: overrideSearchFields = null, // Override storage's searchFields
1023
1259
  filters = {},
1024
1260
  sort_by = null,
1025
1261
  sort_order = 'asc',
@@ -1029,40 +1265,72 @@ export class EntityManager {
1029
1265
 
1030
1266
  let result = [...items]
1031
1267
 
1032
- // Apply search (searches in all string fields by default)
1268
+ // Apply search
1269
+ // If searchFields is declared, search in own fields + parent fields (from _search cache)
1270
+ // If not declared, search all string/number fields (backward compatible)
1271
+ // Override searchFields takes priority over storage's searchFields
1033
1272
  if (search) {
1034
1273
  const searchLower = search.toLowerCase()
1274
+ const searchFields = overrideSearchFields ?? this.storageSearchFields
1275
+
1035
1276
  result = result.filter(item => {
1036
- return Object.values(item).some(value => {
1037
- if (typeof value === 'string') {
1038
- return value.toLowerCase().includes(searchLower)
1277
+ if (searchFields) {
1278
+ const { ownFields, parentFields } = this._parseSearchFields(searchFields)
1279
+
1280
+ // Search own fields
1281
+ for (const field of ownFields) {
1282
+ const value = item[field]
1283
+ if (typeof value === 'string' && value.toLowerCase().includes(searchLower)) {
1284
+ return true
1285
+ }
1286
+ if (typeof value === 'number' && value.toString().includes(search)) {
1287
+ return true
1288
+ }
1039
1289
  }
1040
- if (typeof value === 'number') {
1041
- return value.toString().includes(search)
1290
+
1291
+ // Search cached parent fields (in item._search)
1292
+ if (item._search) {
1293
+ for (const [parentKey, fields] of Object.entries(parentFields)) {
1294
+ for (const field of fields) {
1295
+ const key = `${parentKey}.${field}`
1296
+ const value = item._search[key]
1297
+ if (typeof value === 'string' && value.toLowerCase().includes(searchLower)) {
1298
+ return true
1299
+ }
1300
+ if (typeof value === 'number' && value.toString().includes(search)) {
1301
+ return true
1302
+ }
1303
+ }
1304
+ }
1042
1305
  }
1306
+
1043
1307
  return false
1044
- })
1308
+ } else {
1309
+ // No searchFields declared: search all string/number fields
1310
+ return Object.values(item).some(value => {
1311
+ if (typeof value === 'string') {
1312
+ return value.toLowerCase().includes(searchLower)
1313
+ }
1314
+ if (typeof value === 'number') {
1315
+ return value.toString().includes(search)
1316
+ }
1317
+ return false
1318
+ })
1319
+ }
1045
1320
  })
1046
1321
  }
1047
1322
 
1048
- // Apply filters
1049
- for (const [field, value] of Object.entries(filters)) {
1050
- if (value === null || value === undefined || value === '') continue
1051
- result = result.filter(item => {
1052
- const itemValue = item[field]
1053
- // Array filter (e.g., status in ['active', 'pending'])
1054
- if (Array.isArray(value)) {
1055
- return value.includes(itemValue)
1056
- }
1057
- // Exact match
1058
- return itemValue === value
1059
- })
1323
+ // Build query and apply filters using QueryExecutor
1324
+ const query = this._buildQuery(filters)
1325
+ if (Object.keys(query).length > 0) {
1326
+ const { items: filtered } = QueryExecutor.execute(result, query)
1327
+ result = filtered
1060
1328
  }
1061
1329
 
1062
1330
  // Total after filtering (before pagination)
1063
1331
  const total = result.length
1064
1332
 
1065
- // Apply sort
1333
+ // Apply sort (QueryExecutor does not sort)
1066
1334
  if (sort_by) {
1067
1335
  result.sort((a, b) => {
1068
1336
  const aVal = a[sort_by]
@@ -1097,6 +1365,7 @@ export class EntityManager {
1097
1365
  getCacheInfo() {
1098
1366
  return {
1099
1367
  enabled: this.isCacheEnabled,
1368
+ storageSupportsTotal: this.storageSupportsTotal,
1100
1369
  threshold: this.effectiveThreshold,
1101
1370
  valid: this._cache.valid,
1102
1371
  overflow: this.overflow,
@@ -22,9 +22,26 @@
22
22
  */
23
23
  export class ApiStorage {
24
24
  /**
25
- * API calls benefit from EntityManager cache layer to reduce network requests
25
+ * Storage capabilities declaration.
26
+ * Describes what features this storage adapter supports.
27
+ *
28
+ * @type {import('./index.js').StorageCapabilities}
26
29
  */
27
- supportsCaching = true
30
+ static capabilities = {
31
+ supportsTotal: true,
32
+ supportsFilters: true,
33
+ supportsPagination: true,
34
+ supportsCaching: true
35
+ }
36
+
37
+ /**
38
+ * Backward-compatible instance getter for supportsCaching.
39
+ * @deprecated Use static ApiStorage.capabilities.supportsCaching instead
40
+ * @returns {boolean}
41
+ */
42
+ get supportsCaching() {
43
+ return ApiStorage.capabilities.supportsCaching
44
+ }
28
45
 
29
46
  constructor(options = {}) {
30
47
  const {
@@ -19,9 +19,32 @@
19
19
  */
20
20
  export class LocalStorage {
21
21
  /**
22
- * LocalStorage is already in-memory, no need for EntityManager cache layer
22
+ * Storage capabilities declaration.
23
+ * Describes what features this storage adapter supports.
24
+ *
25
+ * LocalStorage operates with browser localStorage:
26
+ * - supportsTotal: true - Returns accurate total from stored data
27
+ * - supportsFilters: true - Filters in-memory via list() params
28
+ * - supportsPagination: true - Paginates in-memory
29
+ * - supportsCaching: false - Already local, no cache benefit
30
+ *
31
+ * @type {import('./index.js').StorageCapabilities}
23
32
  */
24
- supportsCaching = false
33
+ static capabilities = {
34
+ supportsTotal: true,
35
+ supportsFilters: true,
36
+ supportsPagination: true,
37
+ supportsCaching: false
38
+ }
39
+
40
+ /**
41
+ * Backward-compatible instance getter for supportsCaching.
42
+ * @deprecated Use static LocalStorage.capabilities.supportsCaching instead
43
+ * @returns {boolean}
44
+ */
45
+ get supportsCaching() {
46
+ return LocalStorage.capabilities.supportsCaching
47
+ }
25
48
 
26
49
  constructor(options = {}) {
27
50
  const {
@@ -18,6 +18,34 @@
18
18
  * ```
19
19
  */
20
20
  export class MemoryStorage {
21
+ /**
22
+ * Storage capabilities declaration.
23
+ * Describes what features this storage adapter supports.
24
+ *
25
+ * MemoryStorage operates entirely in-memory:
26
+ * - supportsTotal: true - Returns accurate total from in-memory data
27
+ * - supportsFilters: true - Filters in-memory via list() params
28
+ * - supportsPagination: true - Paginates in-memory
29
+ * - supportsCaching: false - Already in-memory, no cache benefit
30
+ *
31
+ * @type {import('./index.js').StorageCapabilities}
32
+ */
33
+ static capabilities = {
34
+ supportsTotal: true,
35
+ supportsFilters: true,
36
+ supportsPagination: true,
37
+ supportsCaching: false
38
+ }
39
+
40
+ /**
41
+ * Backward-compatible instance getter for supportsCaching.
42
+ * @deprecated Use static MemoryStorage.capabilities.supportsCaching instead
43
+ * @returns {boolean}
44
+ */
45
+ get supportsCaching() {
46
+ return MemoryStorage.capabilities.supportsCaching
47
+ }
48
+
21
49
  constructor(options = {}) {
22
50
  const {
23
51
  idField = 'id',
@@ -20,9 +20,32 @@
20
20
  */
21
21
  export class MockApiStorage {
22
22
  /**
23
- * In-memory storage with persistence doesn't benefit from EntityManager cache
23
+ * Storage capabilities declaration.
24
+ * Describes what features this storage adapter supports.
25
+ *
26
+ * MockApiStorage operates with in-memory Map + localStorage persistence:
27
+ * - supportsTotal: true - Returns accurate total from in-memory data
28
+ * - supportsFilters: true - Filters in-memory via list() params
29
+ * - supportsPagination: true - Paginates in-memory
30
+ * - supportsCaching: false - Already in-memory, no cache benefit
31
+ *
32
+ * @type {import('./index.js').StorageCapabilities}
24
33
  */
25
- supportsCaching = false
34
+ static capabilities = {
35
+ supportsTotal: true,
36
+ supportsFilters: true,
37
+ supportsPagination: true,
38
+ supportsCaching: false
39
+ }
40
+
41
+ /**
42
+ * Backward-compatible instance getter for supportsCaching.
43
+ * @deprecated Use static MockApiStorage.capabilities.supportsCaching instead
44
+ * @returns {boolean}
45
+ */
46
+ get supportsCaching() {
47
+ return MockApiStorage.capabilities.supportsCaching
48
+ }
26
49
 
27
50
  constructor(options = {}) {
28
51
  const {
@@ -95,9 +95,24 @@
95
95
  */
96
96
  export class SdkStorage {
97
97
  /**
98
- * SDK calls benefit from EntityManager cache layer to reduce network requests
98
+ * Storage capabilities declaration
99
+ * @type {import('./index.js').StorageCapabilities}
99
100
  */
100
- supportsCaching = true
101
+ static capabilities = {
102
+ supportsTotal: true, // list() returns { items, total }
103
+ supportsFilters: true, // list() accepts filters param
104
+ supportsPagination: true, // list() accepts page/page_size
105
+ supportsCaching: true // Benefits from EntityManager cache layer
106
+ }
107
+
108
+ /**
109
+ * Backward-compat instance getter for supportsCaching
110
+ * @deprecated Use SdkStorage.capabilities.supportsCaching instead
111
+ * @returns {boolean}
112
+ */
113
+ get supportsCaching() {
114
+ return SdkStorage.capabilities.supportsCaching
115
+ }
101
116
 
102
117
  /**
103
118
  * @param {object} options