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.
@@ -5,6 +5,111 @@
5
5
  * EntityManagers can use these or implement their own storage.
6
6
  */
7
7
 
8
+ /**
9
+ * Storage Capabilities Interface
10
+ *
11
+ * Static capabilities declaration that storage adapters expose via `static capabilities = {...}`.
12
+ * EntityManager reads these via `storage.constructor.capabilities` to determine feature support.
13
+ *
14
+ * All properties default to `false` if not declared (conservative assumption).
15
+ * Custom storage implementations that don't declare capabilities will degrade gracefully
16
+ * via fallback to empty `{}`.
17
+ *
18
+ * @typedef {object} StorageCapabilities
19
+ * @property {boolean} [supportsTotal=false] - Storage `list()` returns `{ items, total }` with accurate total count
20
+ * @property {boolean} [supportsFilters=false] - Storage `list()` accepts `filters` param and handles filtering
21
+ * @property {boolean} [supportsPagination=false] - Storage `list()` accepts `page`/`page_size` params
22
+ * @property {boolean} [supportsCaching=false] - Storage benefits from EntityManager cache layer (network-based storages)
23
+ * @property {string[]} [searchFields] - Fields to search when filtering locally. Supports own fields ('title')
24
+ * and parent entity fields ('book.title') via EntityManager.parents config. When undefined, all string
25
+ * fields are searched (default behavior). When defined, only listed fields are searched.
26
+ */
27
+
28
+ /**
29
+ * Migration Guide for Custom Storage Adapters
30
+ *
31
+ * If you have a custom storage adapter, you can add capabilities support in two ways:
32
+ *
33
+ * **Option 1: Static capabilities (recommended)**
34
+ * Add a static `capabilities` property to your class. EntityManager reads this via
35
+ * `storage.constructor.capabilities`. This is the preferred pattern for new code.
36
+ *
37
+ * ```js
38
+ * export class MyCustomStorage {
39
+ * static capabilities = {
40
+ * supportsTotal: true,
41
+ * supportsFilters: true,
42
+ * supportsPagination: true,
43
+ * supportsCaching: true // set to false for in-memory storages
44
+ * }
45
+ *
46
+ * // Backward-compat instance getter (optional, for smooth migration)
47
+ * get supportsCaching() {
48
+ * return MyCustomStorage.capabilities.supportsCaching
49
+ * }
50
+ *
51
+ * async list(params) { ... }
52
+ * async get(id) { ... }
53
+ * // ... other methods
54
+ * }
55
+ * ```
56
+ *
57
+ * **Option 2: Instance property only (legacy)**
58
+ * Keep using instance properties. EntityManager's `isCacheEnabled` check uses:
59
+ * `if (storage?.supportsCaching === false) return false`
60
+ * This works with instance properties directly.
61
+ *
62
+ * ```js
63
+ * export class MyLegacyStorage {
64
+ * supportsCaching = true // or false for in-memory
65
+ *
66
+ * async list(params) { ... }
67
+ * }
68
+ * ```
69
+ *
70
+ * **No capabilities declared**
71
+ * If your storage doesn't declare capabilities, EntityManager will:
72
+ * - Assume `supportsTotal: false` (disables auto-caching threshold check)
73
+ * - Assume `supportsCaching: undefined` (caching allowed, but threshold check fails)
74
+ * - Gracefully degrade without errors
75
+ *
76
+ * Use `getStorageCapabilities(storage)` helper to read merged capabilities with defaults.
77
+ */
78
+
79
+ /**
80
+ * Default capabilities for storages that don't declare their own.
81
+ * All capabilities default to false for safe degradation.
82
+ *
83
+ * @type {StorageCapabilities}
84
+ */
85
+ export const DEFAULT_STORAGE_CAPABILITIES = {
86
+ supportsTotal: false,
87
+ supportsFilters: false,
88
+ supportsPagination: false,
89
+ supportsCaching: false
90
+ }
91
+
92
+ /**
93
+ * Read capabilities from a storage instance.
94
+ * Accesses static `capabilities` property via constructor with fallback to defaults.
95
+ *
96
+ * @param {object} storage - Storage adapter instance
97
+ * @returns {StorageCapabilities} Merged capabilities with defaults
98
+ *
99
+ * @example
100
+ * const caps = getStorageCapabilities(myStorage)
101
+ * if (caps.supportsTotal) {
102
+ * // Storage provides accurate total count
103
+ * }
104
+ */
105
+ export function getStorageCapabilities(storage) {
106
+ const declared = storage?.constructor?.capabilities || {}
107
+ return {
108
+ ...DEFAULT_STORAGE_CAPABILITIES,
109
+ ...declared
110
+ }
111
+ }
112
+
8
113
  export { ApiStorage, createApiStorage } from './ApiStorage.js'
9
114
  export { LocalStorage, createLocalStorage } from './LocalStorage.js'
10
115
  export { MemoryStorage, createMemoryStorage } from './MemoryStorage.js'
package/src/index.js CHANGED
@@ -38,6 +38,9 @@ export * from './hooks/index.js'
38
38
  // Core (extension helpers)
39
39
  export * from './core/index.js'
40
40
 
41
+ // Query (MongoDB-like filtering)
42
+ export * from './query/index.js'
43
+
41
44
  // Utils
42
45
  export * from './utils/index.js'
43
46
 
@@ -0,0 +1,277 @@
1
+ /**
2
+ * FilterQuery - Resolves dropdown options for filters
3
+ *
4
+ * Abstracts option resolution from two sources:
5
+ * - 'entity': fetch options from a related EntityManager
6
+ * - 'field': extract distinct values from parent manager's cached items
7
+ *
8
+ * Usage:
9
+ * ```js
10
+ * // Entity source - fetch from genres EntityManager
11
+ * const query = new FilterQuery({
12
+ * source: 'entity',
13
+ * entity: 'genres',
14
+ * label: 'name',
15
+ * value: 'id'
16
+ * })
17
+ *
18
+ * // Field source - extract unique status values
19
+ * const query = new FilterQuery({
20
+ * source: 'field',
21
+ * field: 'status'
22
+ * })
23
+ *
24
+ * // Get options (async)
25
+ * const options = await query.getOptions(orchestrator)
26
+ * // → [{ label: 'Rock', value: 1 }, { label: 'Jazz', value: 2 }]
27
+ * ```
28
+ */
29
+ import { getNestedValue } from './QueryExecutor.js'
30
+
31
+ const VALID_SOURCES = ['entity', 'field']
32
+
33
+ /**
34
+ * Resolve a value from an item using a path or function
35
+ *
36
+ * @param {object} item - The item to extract value from
37
+ * @param {string|Function} resolver - Path string ('name', 'author.country') or function (item => value)
38
+ * @returns {*} The resolved value
39
+ */
40
+ function resolveValue(item, resolver) {
41
+ if (typeof resolver === 'function') {
42
+ return resolver(item)
43
+ }
44
+
45
+ // Use shared getNestedValue for path resolution
46
+ return getNestedValue(resolver, item)
47
+ }
48
+
49
+ export class FilterQuery {
50
+ /**
51
+ * @param {object} options
52
+ * @param {'entity'|'field'} options.source - Source type for options
53
+ * @param {string} [options.entity] - Entity name (required if source='entity')
54
+ * @param {string} [options.field] - Field name to extract values from (required if source='field')
55
+ * @param {object} [options.parentManager] - Parent EntityManager (for field source, set by builder)
56
+ * @param {string|Function} [options.label='name'] - Label resolver (path or function)
57
+ * @param {string|Function} [options.value='id'] - Value resolver (path or function)
58
+ * @param {Function} [options.transform] - Post-processing function for options
59
+ * @param {Function} [options.toQuery] - Transform filter value to query object
60
+ */
61
+ constructor(options = {}) {
62
+ const {
63
+ source,
64
+ entity = null,
65
+ field = null,
66
+ parentManager = null,
67
+ label = 'name',
68
+ value = 'id',
69
+ transform = null,
70
+ toQuery = null
71
+ } = options
72
+
73
+ // Validate source type
74
+ if (!source || !VALID_SOURCES.includes(source)) {
75
+ throw new Error(
76
+ `FilterQuery: source must be one of: ${VALID_SOURCES.join(', ')}. Got: ${source}`
77
+ )
78
+ }
79
+
80
+ // Validate source-specific requirements
81
+ if (source === 'entity' && !entity) {
82
+ throw new Error('FilterQuery: entity is required when source is "entity"')
83
+ }
84
+
85
+ if (source === 'field' && !field) {
86
+ throw new Error('FilterQuery: field is required when source is "field"')
87
+ }
88
+
89
+ this.source = source
90
+ this.entity = entity
91
+ this.field = field
92
+ this.parentManager = parentManager
93
+ this.label = label
94
+ this.value = value
95
+ this.transform = transform
96
+ this.toQuery = toQuery
97
+
98
+ // Internal cache
99
+ this._options = null
100
+ this._signals = null
101
+ this._subscriptions = [] // Signal unsubscribe functions for cleanup
102
+ }
103
+
104
+ /**
105
+ * Set the parent manager (called by useListPageBuilder for field source)
106
+ *
107
+ * @param {EntityManager} manager
108
+ * @returns {FilterQuery} this for chaining
109
+ */
110
+ setParentManager(manager) {
111
+ this.parentManager = manager
112
+ return this
113
+ }
114
+
115
+ /**
116
+ * Set the SignalBus for cache invalidation
117
+ *
118
+ * For entity sources, subscribes to CRUD signals ({entity}:created, {entity}:updated,
119
+ * {entity}:deleted) to automatically invalidate cached options when the source
120
+ * entity changes.
121
+ *
122
+ * @param {SignalBus} signals
123
+ * @returns {FilterQuery} this for chaining
124
+ */
125
+ setSignals(signals) {
126
+ // Clean up existing subscriptions if any
127
+ this._cleanupSubscriptions()
128
+
129
+ this._signals = signals
130
+
131
+ // Subscribe to entity CRUD signals for cache invalidation
132
+ if (this.source === 'entity' && this.entity && signals) {
133
+ const actions = ['created', 'updated', 'deleted']
134
+ for (const action of actions) {
135
+ const signalName = `${this.entity}:${action}`
136
+ const unbind = signals.on(signalName, () => this.invalidate())
137
+ this._subscriptions.push(unbind)
138
+ }
139
+ }
140
+
141
+ return this
142
+ }
143
+
144
+ /**
145
+ * Cleanup signal subscriptions
146
+ * @private
147
+ */
148
+ _cleanupSubscriptions() {
149
+ for (const unbind of this._subscriptions) {
150
+ if (typeof unbind === 'function') {
151
+ unbind()
152
+ }
153
+ }
154
+ this._subscriptions = []
155
+ }
156
+
157
+ /**
158
+ * Dispose the FilterQuery, cleaning up signal subscriptions
159
+ *
160
+ * Call this when the FilterQuery is no longer needed to prevent memory leaks.
161
+ */
162
+ dispose() {
163
+ this._cleanupSubscriptions()
164
+ this._signals = null
165
+ this._options = null
166
+ }
167
+
168
+ /**
169
+ * Invalidate the cached options (forces refresh on next getOptions)
170
+ */
171
+ invalidate() {
172
+ this._options = null
173
+ }
174
+
175
+ /**
176
+ * Get options for dropdown
177
+ *
178
+ * @param {Orchestrator} [orchestrator] - Required for entity source
179
+ * @returns {Promise<Array<{label: string, value: *}>>}
180
+ */
181
+ async getOptions(orchestrator = null) {
182
+ // Return cached options if available
183
+ if (this._options !== null) {
184
+ return this._options
185
+ }
186
+
187
+ let items = []
188
+
189
+ if (this.source === 'entity') {
190
+ items = await this._fetchFromEntity(orchestrator)
191
+ } else if (this.source === 'field') {
192
+ items = this._extractFromField()
193
+ }
194
+
195
+ // Map items to { label, value } format
196
+ let options = items.map(item => ({
197
+ label: resolveValue(item, this.label),
198
+ value: resolveValue(item, this.value)
199
+ }))
200
+
201
+ // Apply transform if provided
202
+ if (typeof this.transform === 'function') {
203
+ options = this.transform(options)
204
+ }
205
+
206
+ // Cache the result
207
+ this._options = options
208
+
209
+ return options
210
+ }
211
+
212
+ /**
213
+ * Fetch items from entity manager
214
+ *
215
+ * @param {Orchestrator} orchestrator
216
+ * @returns {Promise<Array>}
217
+ * @private
218
+ */
219
+ async _fetchFromEntity(orchestrator) {
220
+ if (!orchestrator) {
221
+ throw new Error('FilterQuery: orchestrator is required for entity source')
222
+ }
223
+
224
+ const manager = orchestrator.get(this.entity)
225
+ if (!manager) {
226
+ throw new Error(`FilterQuery: entity "${this.entity}" not found in orchestrator`)
227
+ }
228
+
229
+ // Fetch all items (large page_size to get everything)
230
+ const result = await manager.list({ page_size: 1000 })
231
+ return result.items || []
232
+ }
233
+
234
+ /**
235
+ * Extract unique values from parent manager's cached items
236
+ *
237
+ * @returns {Array}
238
+ * @private
239
+ */
240
+ _extractFromField() {
241
+ if (!this.parentManager) {
242
+ throw new Error('FilterQuery: parentManager is required for field source')
243
+ }
244
+
245
+ // Get cached items from parent manager
246
+ const cachedItems = this.parentManager._cache || []
247
+
248
+ if (cachedItems.length === 0) {
249
+ return []
250
+ }
251
+
252
+ // Extract unique values from the field
253
+ const seen = new Set()
254
+ const items = []
255
+
256
+ for (const item of cachedItems) {
257
+ const fieldValue = resolveValue(item, this.field)
258
+ if (fieldValue == null) continue
259
+
260
+ // Create a unique key for deduplication
261
+ const key = typeof fieldValue === 'object' ? JSON.stringify(fieldValue) : fieldValue
262
+
263
+ if (!seen.has(key)) {
264
+ seen.add(key)
265
+ // For field source, the item IS the value (create a simple object)
266
+ items.push({
267
+ [this.field]: fieldValue,
268
+ // Also set 'name' and 'id' defaults for simple resolution
269
+ name: fieldValue,
270
+ id: fieldValue
271
+ })
272
+ }
273
+ }
274
+
275
+ return items
276
+ }
277
+ }