qdadm 0.30.0 → 0.31.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.
Files changed (43) hide show
  1. package/package.json +2 -1
  2. package/src/components/forms/FormPage.vue +1 -1
  3. package/src/components/layout/AppLayout.vue +13 -1
  4. package/src/components/layout/Zone.vue +40 -23
  5. package/src/composables/index.js +1 -0
  6. package/src/composables/useAuth.js +43 -4
  7. package/src/composables/useCurrentEntity.js +44 -0
  8. package/src/composables/useFormPageBuilder.js +3 -3
  9. package/src/composables/useNavContext.js +24 -8
  10. package/src/debug/AuthCollector.js +254 -0
  11. package/src/debug/Collector.js +235 -0
  12. package/src/debug/DebugBridge.js +163 -0
  13. package/src/debug/DebugModule.js +215 -0
  14. package/src/debug/EntitiesCollector.js +376 -0
  15. package/src/debug/ErrorCollector.js +66 -0
  16. package/src/debug/LocalStorageAdapter.js +150 -0
  17. package/src/debug/SignalCollector.js +87 -0
  18. package/src/debug/ToastCollector.js +82 -0
  19. package/src/debug/ZonesCollector.js +300 -0
  20. package/src/debug/components/DebugBar.vue +1232 -0
  21. package/src/debug/components/ObjectTree.vue +194 -0
  22. package/src/debug/components/index.js +8 -0
  23. package/src/debug/components/panels/AuthPanel.vue +103 -0
  24. package/src/debug/components/panels/EntitiesPanel.vue +616 -0
  25. package/src/debug/components/panels/EntriesPanel.vue +188 -0
  26. package/src/debug/components/panels/ToastsPanel.vue +112 -0
  27. package/src/debug/components/panels/ZonesPanel.vue +232 -0
  28. package/src/debug/components/panels/index.js +8 -0
  29. package/src/debug/index.js +31 -0
  30. package/src/entity/EntityManager.js +142 -20
  31. package/src/entity/storage/MockApiStorage.js +17 -1
  32. package/src/entity/storage/index.js +9 -2
  33. package/src/index.js +7 -0
  34. package/src/kernel/Kernel.js +436 -48
  35. package/src/kernel/KernelContext.js +385 -0
  36. package/src/kernel/Module.js +111 -0
  37. package/src/kernel/ModuleLoader.js +573 -0
  38. package/src/kernel/SignalBus.js +2 -7
  39. package/src/kernel/index.js +14 -0
  40. package/src/toast/ToastBridgeModule.js +70 -0
  41. package/src/toast/ToastListener.vue +47 -0
  42. package/src/toast/index.js +15 -0
  43. package/src/toast/useSignalToast.js +113 -0
@@ -0,0 +1,573 @@
1
+ /**
2
+ * ModuleLoader - Duck typing module loader with dependency resolution
3
+ *
4
+ * Detects and normalizes multiple module formats:
5
+ * 1. Module instance - use directly
6
+ * 2. Module class - instantiate with new
7
+ * 3. Plain object with connect() - wrap in adapter
8
+ * 4. Plain function - wrap as legacy init({ registry, zones })
9
+ *
10
+ * Provides topological sorting based on requires + priority.
11
+ */
12
+
13
+ import { Module } from './Module.js'
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Custom Errors
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Error thrown when a required module is not registered
21
+ */
22
+ export class ModuleNotFoundError extends Error {
23
+ /**
24
+ * @param {string} moduleName - Name of the missing module
25
+ * @param {string} requiredBy - Name of the module that requires it
26
+ */
27
+ constructor(moduleName, requiredBy) {
28
+ super(`Module '${moduleName}' not found (required by '${requiredBy}')`)
29
+ this.name = 'ModuleNotFoundError'
30
+ this.moduleName = moduleName
31
+ this.requiredBy = requiredBy
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Error thrown when circular dependencies are detected
37
+ */
38
+ export class CircularDependencyError extends Error {
39
+ /**
40
+ * @param {string[]} cycle - Array of module names forming the cycle
41
+ */
42
+ constructor(cycle) {
43
+ const cyclePath = cycle.join(' → ')
44
+ super(`Circular dependency detected: ${cyclePath}`)
45
+ this.name = 'CircularDependencyError'
46
+ this.cycle = cycle
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Error thrown when module connect() fails
52
+ */
53
+ export class ModuleLoadError extends Error {
54
+ /**
55
+ * @param {string} moduleName - Name of the module that failed
56
+ * @param {Error} cause - Original error
57
+ */
58
+ constructor(moduleName, cause) {
59
+ super(`Failed to load module '${moduleName}': ${cause.message}`)
60
+ this.name = 'ModuleLoadError'
61
+ this.moduleName = moduleName
62
+ this.cause = cause
63
+ }
64
+ }
65
+
66
+ // ─────────────────────────────────────────────────────────────────────────────
67
+ // Module Adapter for plain objects
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Wraps a plain object with connect() into a Module-like interface
72
+ */
73
+ class ObjectModuleAdapter {
74
+ /**
75
+ * @param {object} def - Plain object module definition
76
+ */
77
+ constructor(def) {
78
+ this._def = def
79
+ this._ctx = null
80
+ }
81
+
82
+ get name() {
83
+ return this._def.name || 'anonymous'
84
+ }
85
+
86
+ get requires() {
87
+ return this._def.requires || []
88
+ }
89
+
90
+ get priority() {
91
+ return this._def.priority ?? 0
92
+ }
93
+
94
+ enabled(ctx) {
95
+ if (typeof this._def.enabled === 'function') {
96
+ return this._def.enabled(ctx)
97
+ }
98
+ return this._def.enabled !== false
99
+ }
100
+
101
+ async connect(ctx) {
102
+ this._ctx = ctx
103
+ if (typeof this._def.connect === 'function') {
104
+ await this._def.connect(ctx)
105
+ }
106
+ }
107
+
108
+ async disconnect() {
109
+ if (typeof this._def.disconnect === 'function') {
110
+ await this._def.disconnect()
111
+ }
112
+ this._ctx = null
113
+ }
114
+ }
115
+
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+ // Class Adapter for non-Module classes
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Wraps a class with static name and connect method into a Module-like interface
122
+ */
123
+ class ClassModuleAdapter {
124
+ /**
125
+ * @param {Function} ClassDef - Class definition with static name
126
+ */
127
+ constructor(ClassDef) {
128
+ this._ClassDef = ClassDef
129
+ this._instance = new ClassDef()
130
+ this._ctx = null
131
+ }
132
+
133
+ get name() {
134
+ return this._ClassDef.name || 'anonymous'
135
+ }
136
+
137
+ get requires() {
138
+ return this._ClassDef.requires || []
139
+ }
140
+
141
+ get priority() {
142
+ return this._ClassDef.priority ?? 0
143
+ }
144
+
145
+ enabled(ctx) {
146
+ if (typeof this._instance.enabled === 'function') {
147
+ return this._instance.enabled(ctx)
148
+ }
149
+ if (typeof this._ClassDef.enabled === 'function') {
150
+ return this._ClassDef.enabled(ctx)
151
+ }
152
+ return true
153
+ }
154
+
155
+ async connect(ctx) {
156
+ this._ctx = ctx
157
+ if (typeof this._instance.connect === 'function') {
158
+ await this._instance.connect(ctx)
159
+ }
160
+ }
161
+
162
+ async disconnect() {
163
+ if (typeof this._instance.disconnect === 'function') {
164
+ await this._instance.disconnect()
165
+ }
166
+ this._ctx = null
167
+ }
168
+ }
169
+
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ // Legacy Function Adapter
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Wraps a legacy init function into a Module-like interface
176
+ */
177
+ class LegacyFunctionAdapter {
178
+ /**
179
+ * @param {Function} initFn - Legacy init function
180
+ */
181
+ constructor(initFn) {
182
+ this._initFn = initFn
183
+ this._name = initFn.name || 'legacyModule'
184
+ }
185
+
186
+ get name() {
187
+ return this._name
188
+ }
189
+
190
+ get requires() {
191
+ return []
192
+ }
193
+
194
+ get priority() {
195
+ return 0
196
+ }
197
+
198
+ enabled() {
199
+ return true
200
+ }
201
+
202
+ async connect(ctx) {
203
+ // Legacy pattern: init({ registry, zones })
204
+ // Adapt KernelContext to legacy interface
205
+ const legacyApi = {
206
+ registry: {
207
+ addRoutes: (basePath, routes, opts) => ctx.routes(basePath, routes, opts),
208
+ addNavItem: (item) => ctx.navItem(item),
209
+ addRouteFamily: (base, prefixes) => ctx.routeFamily(base, prefixes),
210
+ },
211
+ zones: ctx.zones,
212
+ ctx,
213
+ }
214
+ await this._initFn(legacyApi)
215
+ }
216
+
217
+ async disconnect() {
218
+ // Legacy functions don't support disconnect
219
+ }
220
+ }
221
+
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+ // ModuleLoader
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * ModuleLoader - Loads and manages modules with dependency resolution
228
+ */
229
+ export class ModuleLoader {
230
+ constructor() {
231
+ /** @type {Map<string, object>} Registered module definitions (before normalization) */
232
+ this._registered = new Map()
233
+
234
+ /** @type {Map<string, object>} Loaded module instances */
235
+ this._loaded = new Map()
236
+
237
+ /** @type {string[]} Load order for proper unloading */
238
+ this._loadOrder = []
239
+ }
240
+
241
+ /**
242
+ * Register a module (any format)
243
+ *
244
+ * Accepts:
245
+ * - Module instance (instanceof Module)
246
+ * - Module class (has static name + prototype.connect or extends Module)
247
+ * - Plain object with connect function
248
+ * - Plain function (legacy init pattern)
249
+ *
250
+ * @param {Module|Function|object} moduleDef - Module definition in any format
251
+ * @returns {this} For chaining
252
+ *
253
+ * @example
254
+ * loader.add(new UsersModule())
255
+ * loader.add(UsersModule)
256
+ * loader.add({ name: 'simple', connect(ctx) { ... } })
257
+ * loader.add(function initLegacy({ registry }) { ... })
258
+ */
259
+ add(moduleDef) {
260
+ const normalized = this._normalize(moduleDef)
261
+ const name = normalized.name
262
+
263
+ if (!name || name === 'anonymous') {
264
+ throw new Error('Module must have a name (static name property, options.name, or function name)')
265
+ }
266
+
267
+ if (this._registered.has(name)) {
268
+ throw new Error(`Module '${name}' is already registered`)
269
+ }
270
+
271
+ this._registered.set(name, normalized)
272
+ return this
273
+ }
274
+
275
+ /**
276
+ * Load all registered modules in dependency order
277
+ *
278
+ * @param {object} ctx - Context to pass to connect() (typically KernelContext-like)
279
+ * @returns {Promise<void>}
280
+ * @throws {ModuleNotFoundError} When a required module is not registered
281
+ * @throws {CircularDependencyError} When circular dependencies exist
282
+ * @throws {ModuleLoadError} When a module's connect() fails
283
+ */
284
+ async loadAll(ctx) {
285
+ // Get sorted modules
286
+ const sorted = this._topologicalSort()
287
+
288
+ // Load in order
289
+ for (const name of sorted) {
290
+ const module = this._registered.get(name)
291
+
292
+ // Check if enabled
293
+ if (!module.enabled(ctx)) {
294
+ continue
295
+ }
296
+
297
+ // Connect module
298
+ try {
299
+ await module.connect(ctx)
300
+ this._loaded.set(name, module)
301
+ this._loadOrder.push(name)
302
+ } catch (err) {
303
+ throw new ModuleLoadError(name, err)
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Unload all modules in reverse order
310
+ *
311
+ * @returns {Promise<void>}
312
+ */
313
+ async unloadAll() {
314
+ // Unload in reverse order
315
+ const reversed = [...this._loadOrder].reverse()
316
+
317
+ for (const name of reversed) {
318
+ const module = this._loaded.get(name)
319
+ if (module && typeof module.disconnect === 'function') {
320
+ await module.disconnect()
321
+ }
322
+ }
323
+
324
+ this._loaded.clear()
325
+ this._loadOrder = []
326
+ }
327
+
328
+ /**
329
+ * Get loaded modules (for debug/introspection)
330
+ *
331
+ * @returns {Map<string, object>} Map of module name to module instance
332
+ */
333
+ getModules() {
334
+ return new Map(this._loaded)
335
+ }
336
+
337
+ /**
338
+ * Normalize any module format to Module-like interface
339
+ *
340
+ * @param {Module|Function|object} moduleDef
341
+ * @returns {object} Normalized module with name, requires, priority, enabled, connect, disconnect
342
+ * @private
343
+ */
344
+ _normalize(moduleDef) {
345
+ // 1. Already a Module instance
346
+ if (moduleDef instanceof Module) {
347
+ return moduleDef
348
+ }
349
+
350
+ // 2. Module class (constructor that extends Module or has static name + connect method)
351
+ if (typeof moduleDef === 'function') {
352
+ // Check if it's a class extending Module
353
+ if (moduleDef.prototype instanceof Module) {
354
+ return new moduleDef()
355
+ }
356
+
357
+ // Check if it looks like a Module class:
358
+ // - Has own static 'name' property (not just inherited function name)
359
+ // - Prototype has connect method
360
+ const hasOwnStaticName = Object.prototype.hasOwnProperty.call(moduleDef, 'name')
361
+ if (
362
+ hasOwnStaticName &&
363
+ typeof moduleDef.name === 'string' &&
364
+ moduleDef.name !== '' &&
365
+ typeof moduleDef.prototype?.connect === 'function'
366
+ ) {
367
+ // Use adapter to properly expose static properties
368
+ return new ClassModuleAdapter(moduleDef)
369
+ }
370
+
371
+ // Otherwise it's a legacy init function
372
+ return new LegacyFunctionAdapter(moduleDef)
373
+ }
374
+
375
+ // 3. Plain object with connect function
376
+ if (
377
+ moduleDef &&
378
+ typeof moduleDef === 'object' &&
379
+ typeof moduleDef.connect === 'function'
380
+ ) {
381
+ return new ObjectModuleAdapter(moduleDef)
382
+ }
383
+
384
+ throw new Error(
385
+ 'Invalid module format. Expected: Module instance, Module class, object with connect(), or function'
386
+ )
387
+ }
388
+
389
+ /**
390
+ * Get the requires array from a module (handles static vs instance properties)
391
+ *
392
+ * @param {object} module - Module instance
393
+ * @returns {string[]}
394
+ * @private
395
+ */
396
+ _getRequires(module) {
397
+ // Check instance property first
398
+ if (Array.isArray(module.requires)) {
399
+ return module.requires
400
+ }
401
+ // Check constructor (static) property for Module subclasses
402
+ if (module.constructor && Array.isArray(module.constructor.requires)) {
403
+ return module.constructor.requires
404
+ }
405
+ return []
406
+ }
407
+
408
+ /**
409
+ * Get the priority from a module (handles static vs instance properties)
410
+ *
411
+ * @param {object} module - Module instance
412
+ * @returns {number}
413
+ * @private
414
+ */
415
+ _getPriority(module) {
416
+ // Check instance property first
417
+ if (typeof module.priority === 'number') {
418
+ return module.priority
419
+ }
420
+ // Check constructor (static) property for Module subclasses
421
+ if (module.constructor && typeof module.constructor.priority === 'number') {
422
+ return module.constructor.priority
423
+ }
424
+ return 0
425
+ }
426
+
427
+ /**
428
+ * Sort modules topologically based on requires + priority
429
+ *
430
+ * Uses Kahn's algorithm for topological sort with priority tie-breaking.
431
+ *
432
+ * @returns {string[]} Sorted module names
433
+ * @throws {ModuleNotFoundError} When a required module is not registered
434
+ * @throws {CircularDependencyError} When circular dependencies exist
435
+ * @private
436
+ */
437
+ _topologicalSort() {
438
+ const modules = this._registered
439
+ const names = Array.from(modules.keys())
440
+
441
+ // Build dependency graph
442
+ // inDegree: number of dependencies for each module
443
+ // dependents: modules that depend on this module
444
+ const inDegree = new Map()
445
+ const dependents = new Map()
446
+
447
+ for (const name of names) {
448
+ inDegree.set(name, 0)
449
+ dependents.set(name, [])
450
+ }
451
+
452
+ // Process requires for each module
453
+ for (const [name, module] of modules) {
454
+ const requires = this._getRequires(module)
455
+
456
+ for (const req of requires) {
457
+ if (!modules.has(req)) {
458
+ throw new ModuleNotFoundError(req, name)
459
+ }
460
+ inDegree.set(name, inDegree.get(name) + 1)
461
+ dependents.get(req).push(name)
462
+ }
463
+ }
464
+
465
+ // Initialize queue with modules that have no dependencies
466
+ // Sort by priority (lower first) for consistent ordering
467
+ let queue = names
468
+ .filter((name) => inDegree.get(name) === 0)
469
+ .sort((a, b) => {
470
+ const modA = modules.get(a)
471
+ const modB = modules.get(b)
472
+ return this._getPriority(modA) - this._getPriority(modB)
473
+ })
474
+
475
+ const result = []
476
+
477
+ while (queue.length > 0) {
478
+ // Take first (lowest priority)
479
+ const current = queue.shift()
480
+ result.push(current)
481
+
482
+ // Reduce in-degree for dependents
483
+ for (const dep of dependents.get(current)) {
484
+ inDegree.set(dep, inDegree.get(dep) - 1)
485
+
486
+ if (inDegree.get(dep) === 0) {
487
+ queue.push(dep)
488
+ }
489
+ }
490
+
491
+ // Re-sort queue by priority
492
+ queue.sort((a, b) => {
493
+ const modA = modules.get(a)
494
+ const modB = modules.get(b)
495
+ return this._getPriority(modA) - this._getPriority(modB)
496
+ })
497
+ }
498
+
499
+ // Check for cycles
500
+ if (result.length !== names.length) {
501
+ // Find the cycle for error message
502
+ const cycle = this._findCycle(modules)
503
+ throw new CircularDependencyError(cycle)
504
+ }
505
+
506
+ return result
507
+ }
508
+
509
+ /**
510
+ * Find a cycle in the dependency graph for error reporting
511
+ *
512
+ * @param {Map<string, object>} modules
513
+ * @returns {string[]} Cycle path
514
+ * @private
515
+ */
516
+ _findCycle(modules) {
517
+ const visited = new Set()
518
+ const stack = new Set()
519
+ const path = []
520
+
521
+ const dfs = (name) => {
522
+ if (stack.has(name)) {
523
+ // Found cycle - extract it from path
524
+ const cycleStart = path.indexOf(name)
525
+ return [...path.slice(cycleStart), name]
526
+ }
527
+
528
+ if (visited.has(name)) {
529
+ return null
530
+ }
531
+
532
+ visited.add(name)
533
+ stack.add(name)
534
+ path.push(name)
535
+
536
+ const module = modules.get(name)
537
+ const requires = module ? this._getRequires(module) : []
538
+
539
+ for (const req of requires) {
540
+ if (modules.has(req)) {
541
+ const cycle = dfs(req)
542
+ if (cycle) {
543
+ return cycle
544
+ }
545
+ }
546
+ }
547
+
548
+ stack.delete(name)
549
+ path.pop()
550
+ return null
551
+ }
552
+
553
+ for (const name of modules.keys()) {
554
+ const cycle = dfs(name)
555
+ if (cycle) {
556
+ return cycle
557
+ }
558
+ }
559
+
560
+ return ['unknown cycle']
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Factory function to create a ModuleLoader instance
566
+ *
567
+ * @returns {ModuleLoader}
568
+ */
569
+ export function createModuleLoader() {
570
+ return new ModuleLoader()
571
+ }
572
+
573
+ export default ModuleLoader
@@ -126,13 +126,8 @@ export class SignalBus {
126
126
  * @returns {Promise<void>}
127
127
  */
128
128
  async emitEntity(entityName, action, data) {
129
- const specificSignal = buildSignal(entityName, action)
130
- const genericSignal = buildSignal('entity', action)
131
-
132
- // Emit both specific and generic signals
133
- // Specific first, then generic
134
- await this.emit(specificSignal, { entity: entityName, data })
135
- await this.emit(genericSignal, { entity: entityName, data })
129
+ const signal = buildSignal('entity', action)
130
+ await this.emit(signal, { entity: entityName, data })
136
131
  }
137
132
 
138
133
  /**
@@ -21,3 +21,17 @@ export {
21
21
  createSSEBridge,
22
22
  SSE_SIGNALS,
23
23
  } from './SSEBridge.js'
24
+ export {
25
+ Module,
26
+ } from './Module.js'
27
+ export {
28
+ KernelContext,
29
+ createKernelContext,
30
+ } from './KernelContext.js'
31
+ export {
32
+ ModuleLoader,
33
+ createModuleLoader,
34
+ ModuleNotFoundError,
35
+ CircularDependencyError,
36
+ ModuleLoadError,
37
+ } from './ModuleLoader.js'
@@ -0,0 +1,70 @@
1
+ /**
2
+ * ToastBridgeModule - Bridges signal bus to PrimeVue Toast
3
+ *
4
+ * This module integrates toast functionality via the signal bus:
5
+ * - Registers ToastListener component to handle toast:* signals
6
+ * - Provides useSignalToast() composable for emitting toasts
7
+ *
8
+ * Usage in modules:
9
+ * import { useSignalToast } from 'qdadm'
10
+ * const toast = useSignalToast()
11
+ * toast.success('Saved!', 'Your changes have been saved')
12
+ * toast.error('Error', 'Something went wrong')
13
+ *
14
+ * @example
15
+ * import { createKernel, ToastBridgeModule } from 'qdadm'
16
+ *
17
+ * const kernel = createKernel()
18
+ * kernel.use(new ToastBridgeModule())
19
+ * await kernel.boot()
20
+ */
21
+
22
+ import { Module } from '../kernel/Module.js'
23
+ import ToastListener from './ToastListener.vue'
24
+
25
+ /**
26
+ * Zone name for toast listener component
27
+ * Prefixed with _ to hide from ZonesCollector (internal zone)
28
+ */
29
+ export const TOAST_ZONE = '_app:toasts'
30
+
31
+ /**
32
+ * ToastBridgeModule - Handles toast notifications via signals
33
+ */
34
+ export class ToastBridgeModule extends Module {
35
+ /**
36
+ * Module identifier
37
+ * @type {string}
38
+ */
39
+ static name = 'toast-bridge'
40
+
41
+ /**
42
+ * No dependencies
43
+ * @type {string[]}
44
+ */
45
+ static requires = []
46
+
47
+ /**
48
+ * High priority - should load early
49
+ * @type {number}
50
+ */
51
+ static priority = 10
52
+
53
+ /**
54
+ * Connect module to kernel
55
+ * @param {import('../kernel/KernelContext.js').KernelContext} ctx
56
+ */
57
+ async connect(ctx) {
58
+ // Define the toast zone
59
+ ctx.zone(TOAST_ZONE)
60
+
61
+ // Register ToastListener component in the zone
62
+ ctx.block(TOAST_ZONE, {
63
+ id: 'toast-listener',
64
+ component: ToastListener,
65
+ weight: 0
66
+ })
67
+ }
68
+ }
69
+
70
+ export default ToastBridgeModule