qdadm 0.27.0 → 0.29.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 +4 -2
- package/src/composables/index.js +1 -0
- package/src/composables/useDeferred.js +85 -0
- package/src/composables/useListPageBuilder.js +3 -0
- package/src/deferred/DeferredRegistry.js +323 -0
- package/src/deferred/index.js +7 -0
- package/src/entity/EntityManager.js +82 -14
- package/src/entity/factory.js +155 -0
- package/src/entity/factory.test.js +189 -0
- package/src/entity/index.js +8 -0
- package/src/entity/storage/ApiStorage.js +4 -1
- package/src/entity/storage/IStorage.js +76 -0
- package/src/entity/storage/LocalStorage.js +4 -1
- package/src/entity/storage/MemoryStorage.js +4 -1
- package/src/entity/storage/MockApiStorage.js +4 -1
- package/src/entity/storage/SdkStorage.js +4 -1
- package/src/entity/storage/factory.js +193 -0
- package/src/entity/storage/factory.test.js +159 -0
- package/src/entity/storage/index.js +13 -0
- package/src/gen/FieldMapper.js +116 -0
- package/src/gen/StorageProfileFactory.js +109 -0
- package/src/gen/connectors/BaseConnector.js +142 -0
- package/src/gen/connectors/ManualConnector.js +385 -0
- package/src/gen/connectors/ManualConnector.test.js +499 -0
- package/src/gen/connectors/OpenAPIConnector.js +568 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +737 -0
- package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
- package/src/gen/connectors/index.js +11 -0
- package/src/gen/createManagers.js +224 -0
- package/src/gen/decorators.js +129 -0
- package/src/gen/generateManagers.js +266 -0
- package/src/gen/generateManagers.test.js +358 -0
- package/src/gen/index.js +45 -0
- package/src/gen/schema.js +221 -0
- package/src/gen/vite-plugin.js +105 -0
- package/src/generated/managers/testManager.js +45 -0
- package/src/index.js +3 -0
- package/src/kernel/EventRouter.js +264 -0
- package/src/kernel/Kernel.js +123 -8
- package/src/kernel/index.js +4 -0
- package/src/orchestrator/Orchestrator.js +60 -0
- package/src/query/FilterQuery.js +9 -4
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Schema Types
|
|
3
|
+
*
|
|
4
|
+
* Core contracts that abstract differences between OpenAPI, Pydantic, and manual schema sources.
|
|
5
|
+
* These types provide a common format for entity and field definitions that can be used
|
|
6
|
+
* by connectors (schema sources) and generators (code output).
|
|
7
|
+
*
|
|
8
|
+
* @module gen/schema
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Field Type Enumeration
|
|
13
|
+
*
|
|
14
|
+
* Unified field types that abstract source-specific types (OpenAPI string/integer,
|
|
15
|
+
* Pydantic str/int, etc.) into a common set.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {'text' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'url' | 'uuid' | 'array' | 'object'} UnifiedFieldType
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reference Definition
|
|
22
|
+
*
|
|
23
|
+
* Describes a relation to another entity for foreign key fields.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {object} UnifiedFieldReference
|
|
26
|
+
* @property {string} entity - Target entity name (e.g., 'users', 'categories')
|
|
27
|
+
* @property {string} [labelField] - Field to display as label (e.g., 'name', 'title')
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Unified Field Schema
|
|
32
|
+
*
|
|
33
|
+
* Common field definition that abstracts OpenAPI property, Pydantic field, or manual definition.
|
|
34
|
+
* Connectors transform their source-specific formats into this common shape.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {object} UnifiedFieldSchema
|
|
37
|
+
* @property {string} name - Field name (e.g., 'email', 'created_at')
|
|
38
|
+
* @property {UnifiedFieldType} type - Unified field type
|
|
39
|
+
* @property {string} [label] - Human-readable label (e.g., 'Email Address')
|
|
40
|
+
* @property {boolean} [required] - Whether field is required (default: false)
|
|
41
|
+
* @property {boolean} [readOnly] - Whether field is read-only (default: false)
|
|
42
|
+
* @property {boolean} [hidden] - Whether field should be hidden in UI (default: false)
|
|
43
|
+
* @property {string} [format] - Original format hint from source (e.g., 'date-time', 'uri', 'email')
|
|
44
|
+
* @property {string[]} [enum] - Allowed values for enumeration fields
|
|
45
|
+
* @property {*} [default] - Default value for the field
|
|
46
|
+
* @property {UnifiedFieldReference} [reference] - Relation to another entity
|
|
47
|
+
* @property {Record<string, *>} [extensions] - Project-specific extensions
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* // Simple text field
|
|
51
|
+
* const nameField = {
|
|
52
|
+
* name: 'name',
|
|
53
|
+
* type: 'text',
|
|
54
|
+
* label: 'Full Name',
|
|
55
|
+
* required: true
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* // Enum field with options
|
|
60
|
+
* const statusField = {
|
|
61
|
+
* name: 'status',
|
|
62
|
+
* type: 'text',
|
|
63
|
+
* label: 'Status',
|
|
64
|
+
* enum: ['draft', 'published', 'archived'],
|
|
65
|
+
* default: 'draft'
|
|
66
|
+
* }
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* // Foreign key with reference
|
|
70
|
+
* const authorField = {
|
|
71
|
+
* name: 'author_id',
|
|
72
|
+
* type: 'number',
|
|
73
|
+
* label: 'Author',
|
|
74
|
+
* reference: {
|
|
75
|
+
* entity: 'users',
|
|
76
|
+
* labelField: 'username'
|
|
77
|
+
* }
|
|
78
|
+
* }
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Unified Entity Schema
|
|
83
|
+
*
|
|
84
|
+
* Common entity definition that abstracts OpenAPI path/schema, Pydantic model, or manual definition.
|
|
85
|
+
* This is the primary contract between schema sources (connectors) and consumers (generators, runtime).
|
|
86
|
+
*
|
|
87
|
+
* @typedef {object} UnifiedEntitySchema
|
|
88
|
+
* @property {string} name - Entity name, typically plural lowercase (e.g., 'users', 'blog_posts')
|
|
89
|
+
* @property {string} endpoint - API endpoint path (e.g., '/users', '/api/v1/posts')
|
|
90
|
+
* @property {string} [label] - Human-readable singular label (e.g., 'User', 'Blog Post')
|
|
91
|
+
* @property {string} [labelPlural] - Human-readable plural label (e.g., 'Users', 'Blog Posts')
|
|
92
|
+
* @property {string} [labelField] - Field used as display label (e.g., 'name', 'title')
|
|
93
|
+
* @property {string} [routePrefix] - Route prefix for admin UI (e.g., 'user', 'blog-post')
|
|
94
|
+
* @property {string} [idField] - Primary key field name (default: 'id')
|
|
95
|
+
* @property {boolean} [readOnly] - Whether entity is read-only (no create/update/delete)
|
|
96
|
+
* @property {Record<string, UnifiedFieldSchema>} fields - Field definitions keyed by field name
|
|
97
|
+
* @property {Record<string, *>} [extensions] - Project-specific extensions
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* // Complete entity schema
|
|
101
|
+
* const usersSchema = {
|
|
102
|
+
* name: 'users',
|
|
103
|
+
* endpoint: '/api/users',
|
|
104
|
+
* label: 'User',
|
|
105
|
+
* labelPlural: 'Users',
|
|
106
|
+
* labelField: 'username',
|
|
107
|
+
* routePrefix: 'user',
|
|
108
|
+
* idField: 'id',
|
|
109
|
+
* readOnly: false,
|
|
110
|
+
* fields: {
|
|
111
|
+
* id: { name: 'id', type: 'number', readOnly: true },
|
|
112
|
+
* username: { name: 'username', type: 'text', required: true },
|
|
113
|
+
* email: { name: 'email', type: 'email', required: true },
|
|
114
|
+
* created_at: { name: 'created_at', type: 'datetime', readOnly: true }
|
|
115
|
+
* }
|
|
116
|
+
* }
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // Read-only entity (external API)
|
|
120
|
+
* const countriesSchema = {
|
|
121
|
+
* name: 'countries',
|
|
122
|
+
* endpoint: 'https://restcountries.com/v3.1/all',
|
|
123
|
+
* label: 'Country',
|
|
124
|
+
* labelPlural: 'Countries',
|
|
125
|
+
* labelField: 'name',
|
|
126
|
+
* idField: 'cca3',
|
|
127
|
+
* readOnly: true,
|
|
128
|
+
* fields: {
|
|
129
|
+
* cca3: { name: 'cca3', type: 'text', label: 'Code' },
|
|
130
|
+
* name: { name: 'name', type: 'text', label: 'Name' }
|
|
131
|
+
* }
|
|
132
|
+
* }
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Valid field types for UnifiedFieldSchema
|
|
137
|
+
*
|
|
138
|
+
* Used for validation and documentation. Maps to common UI input types:
|
|
139
|
+
* - text: Single-line text input
|
|
140
|
+
* - number: Numeric input
|
|
141
|
+
* - boolean: Checkbox/toggle
|
|
142
|
+
* - date: Date picker (date only)
|
|
143
|
+
* - datetime: Date-time picker
|
|
144
|
+
* - email: Email input with validation
|
|
145
|
+
* - url: URL input with validation
|
|
146
|
+
* - uuid: UUID text input
|
|
147
|
+
* - array: Multi-value field (tags, list)
|
|
148
|
+
* - object: Nested object (JSON editor or subform)
|
|
149
|
+
*
|
|
150
|
+
* @type {readonly UnifiedFieldType[]}
|
|
151
|
+
*/
|
|
152
|
+
export const UNIFIED_FIELD_TYPES = Object.freeze([
|
|
153
|
+
'text',
|
|
154
|
+
'number',
|
|
155
|
+
'boolean',
|
|
156
|
+
'date',
|
|
157
|
+
'datetime',
|
|
158
|
+
'email',
|
|
159
|
+
'url',
|
|
160
|
+
'uuid',
|
|
161
|
+
'array',
|
|
162
|
+
'object'
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a type is a valid UnifiedFieldType
|
|
167
|
+
*
|
|
168
|
+
* @param {string} type - Type to validate
|
|
169
|
+
* @returns {type is UnifiedFieldType} - True if valid
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* isValidFieldType('text') // true
|
|
173
|
+
* isValidFieldType('string') // false (OpenAPI type, not unified)
|
|
174
|
+
*/
|
|
175
|
+
export function isValidFieldType(type) {
|
|
176
|
+
return UNIFIED_FIELD_TYPES.includes(type)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create a minimal field schema with defaults
|
|
181
|
+
*
|
|
182
|
+
* @param {string} name - Field name
|
|
183
|
+
* @param {UnifiedFieldType} type - Field type
|
|
184
|
+
* @param {Partial<UnifiedFieldSchema>} [overrides] - Additional field properties
|
|
185
|
+
* @returns {UnifiedFieldSchema} - Complete field schema
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* const field = createFieldSchema('email', 'email', { required: true })
|
|
189
|
+
* // { name: 'email', type: 'email', required: true }
|
|
190
|
+
*/
|
|
191
|
+
export function createFieldSchema(name, type, overrides = {}) {
|
|
192
|
+
return {
|
|
193
|
+
name,
|
|
194
|
+
type,
|
|
195
|
+
...overrides
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create a minimal entity schema with defaults
|
|
201
|
+
*
|
|
202
|
+
* @param {string} name - Entity name
|
|
203
|
+
* @param {string} endpoint - API endpoint
|
|
204
|
+
* @param {Record<string, UnifiedFieldSchema>} fields - Field definitions
|
|
205
|
+
* @param {Partial<UnifiedEntitySchema>} [overrides] - Additional entity properties
|
|
206
|
+
* @returns {UnifiedEntitySchema} - Complete entity schema
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* const schema = createEntitySchema('users', '/api/users', {
|
|
210
|
+
* id: createFieldSchema('id', 'number', { readOnly: true }),
|
|
211
|
+
* name: createFieldSchema('name', 'text', { required: true })
|
|
212
|
+
* })
|
|
213
|
+
*/
|
|
214
|
+
export function createEntitySchema(name, endpoint, fields, overrides = {}) {
|
|
215
|
+
return {
|
|
216
|
+
name,
|
|
217
|
+
endpoint,
|
|
218
|
+
fields,
|
|
219
|
+
...overrides
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite Plugin for qdadm EntityManager Generation
|
|
3
|
+
*
|
|
4
|
+
* Integrates generateManagers with Vite's build pipeline, triggering
|
|
5
|
+
* entity manager file generation during the buildStart hook.
|
|
6
|
+
*
|
|
7
|
+
* @module gen/vite-plugin
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { generateManagers } from './generateManagers.js'
|
|
11
|
+
import { pathToFileURL } from 'node:url'
|
|
12
|
+
import { resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plugin options for qdadmGen
|
|
16
|
+
*
|
|
17
|
+
* @typedef {object} QdadmGenOptions
|
|
18
|
+
* @property {string} [config] - Path to qdadm config file (default: 'qdadm.config.js')
|
|
19
|
+
* @property {string} [output] - Override output directory for generated files
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Vite plugin for generating EntityManager files at build time
|
|
24
|
+
*
|
|
25
|
+
* Loads the qdadm configuration file and calls generateManagers during
|
|
26
|
+
* Vite's buildStart hook. Supports both ESM and CommonJS config files.
|
|
27
|
+
*
|
|
28
|
+
* @param {QdadmGenOptions} [options={}] - Plugin options
|
|
29
|
+
* @returns {import('vite').Plugin} Vite plugin object
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* // vite.config.js
|
|
33
|
+
* import { defineConfig } from 'vite'
|
|
34
|
+
* import { qdadmGen } from 'qdadm/gen/vite-plugin'
|
|
35
|
+
*
|
|
36
|
+
* export default defineConfig({
|
|
37
|
+
* plugins: [
|
|
38
|
+
* qdadmGen({
|
|
39
|
+
* config: './qdadm.config.js',
|
|
40
|
+
* output: 'src/generated/managers/'
|
|
41
|
+
* })
|
|
42
|
+
* ]
|
|
43
|
+
* })
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // qdadm.config.js
|
|
47
|
+
* export default {
|
|
48
|
+
* output: 'src/generated/managers/',
|
|
49
|
+
* entities: {
|
|
50
|
+
* users: {
|
|
51
|
+
* schema: { name: 'users', fields: { id: { type: 'integer' } } },
|
|
52
|
+
* endpoint: '/api/users',
|
|
53
|
+
* storageImport: 'qdadm',
|
|
54
|
+
* storageClass: 'ApiStorage'
|
|
55
|
+
* }
|
|
56
|
+
* }
|
|
57
|
+
* }
|
|
58
|
+
*/
|
|
59
|
+
export function qdadmGen(options = {}) {
|
|
60
|
+
const configPath = options.config || 'qdadm.config.js'
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: 'qdadm-gen',
|
|
64
|
+
|
|
65
|
+
async buildStart() {
|
|
66
|
+
try {
|
|
67
|
+
// Resolve config path relative to cwd
|
|
68
|
+
const resolvedConfigPath = resolve(process.cwd(), configPath)
|
|
69
|
+
|
|
70
|
+
// Load config file dynamically
|
|
71
|
+
const configUrl = pathToFileURL(resolvedConfigPath).href
|
|
72
|
+
const configModule = await import(configUrl)
|
|
73
|
+
const loadedConfig = configModule.default || configModule
|
|
74
|
+
|
|
75
|
+
// Validate loaded config
|
|
76
|
+
if (!loadedConfig || typeof loadedConfig !== 'object') {
|
|
77
|
+
throw new Error(`Invalid qdadm config at '${configPath}': must export an object`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!loadedConfig.entities) {
|
|
81
|
+
throw new Error(`Invalid qdadm config at '${configPath}': missing 'entities' property`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merge plugin options with config (plugin options take precedence)
|
|
85
|
+
const mergedConfig = {
|
|
86
|
+
...loadedConfig,
|
|
87
|
+
...(options.output && { output: options.output })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Generate managers
|
|
91
|
+
const generatedFiles = await generateManagers(mergedConfig)
|
|
92
|
+
|
|
93
|
+
// Log results
|
|
94
|
+
console.log(`[qdadm-gen] Generated ${generatedFiles.length} manager file(s)`)
|
|
95
|
+
for (const file of generatedFiles) {
|
|
96
|
+
console.log(` - ${file}`)
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Wrap error with plugin context for clearer messages
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
101
|
+
throw new Error(`[qdadm-gen] Failed to generate managers: ${message}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestManager - Auto-generated EntityManager
|
|
3
|
+
*
|
|
4
|
+
* Generated by qdadm/gen/generateManagers
|
|
5
|
+
* DO NOT EDIT MANUALLY - Changes will be overwritten
|
|
6
|
+
*
|
|
7
|
+
* Entity: test
|
|
8
|
+
* Endpoint: /test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EntityManager } from 'qdadm'
|
|
12
|
+
import { ApiStorage } from 'qdadm'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Schema definition for test
|
|
16
|
+
* @type {import('qdadm/gen').UnifiedEntitySchema}
|
|
17
|
+
*/
|
|
18
|
+
export const testSchema = {
|
|
19
|
+
name: "test",
|
|
20
|
+
endpoint: "/test",
|
|
21
|
+
fields: {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Storage options for test
|
|
26
|
+
*/
|
|
27
|
+
const storageOptions = {
|
|
28
|
+
endpoint: "/test"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* TestManager instance
|
|
33
|
+
*
|
|
34
|
+
* Provides CRUD operations for test entity.
|
|
35
|
+
*
|
|
36
|
+
* @type {EntityManager}
|
|
37
|
+
*/
|
|
38
|
+
export const testManager = new EntityManager({
|
|
39
|
+
...{
|
|
40
|
+
name: "test",
|
|
41
|
+
idField: "id",
|
|
42
|
+
fields: {}
|
|
43
|
+
},
|
|
44
|
+
storage: new ApiStorage(storageOptions)
|
|
45
|
+
})
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventRouter - Declarative signal routing
|
|
3
|
+
*
|
|
4
|
+
* Routes one signal to multiple targets (signals or callbacks).
|
|
5
|
+
* Configured at Kernel level to keep components simple.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* const router = new EventRouter({
|
|
10
|
+
* signals, // SignalBus instance
|
|
11
|
+
* orchestrator, // Orchestrator instance (optional, for callbacks)
|
|
12
|
+
* routes: {
|
|
13
|
+
* 'auth:impersonate': [
|
|
14
|
+
* 'cache:entity:invalidate:loans', // string = emit signal
|
|
15
|
+
* { signal: 'notify', transform: (p) => ({ msg: p.user }) }, // object = transform
|
|
16
|
+
* (payload, ctx) => { ... } // function = callback
|
|
17
|
+
* ]
|
|
18
|
+
* }
|
|
19
|
+
* })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect cycles in route graph using DFS
|
|
25
|
+
*
|
|
26
|
+
* @param {object} routes - Route configuration
|
|
27
|
+
* @returns {string[]|null} - Cycle path if found, null otherwise
|
|
28
|
+
*/
|
|
29
|
+
function detectCycles(routes) {
|
|
30
|
+
// Build adjacency list (only signal targets, not callbacks)
|
|
31
|
+
const graph = new Map()
|
|
32
|
+
|
|
33
|
+
for (const [source, targets] of Object.entries(routes)) {
|
|
34
|
+
const signalTargets = []
|
|
35
|
+
for (const target of targets) {
|
|
36
|
+
if (typeof target === 'string') {
|
|
37
|
+
signalTargets.push(target)
|
|
38
|
+
} else if (target && typeof target === 'object' && target.signal) {
|
|
39
|
+
signalTargets.push(target.signal)
|
|
40
|
+
}
|
|
41
|
+
// Functions don't create edges in the graph
|
|
42
|
+
}
|
|
43
|
+
graph.set(source, signalTargets)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// DFS cycle detection
|
|
47
|
+
const visited = new Set()
|
|
48
|
+
const recursionStack = new Set()
|
|
49
|
+
const path = []
|
|
50
|
+
|
|
51
|
+
function dfs(node) {
|
|
52
|
+
visited.add(node)
|
|
53
|
+
recursionStack.add(node)
|
|
54
|
+
path.push(node)
|
|
55
|
+
|
|
56
|
+
const neighbors = graph.get(node) || []
|
|
57
|
+
for (const neighbor of neighbors) {
|
|
58
|
+
if (!visited.has(neighbor)) {
|
|
59
|
+
const cycle = dfs(neighbor)
|
|
60
|
+
if (cycle) return cycle
|
|
61
|
+
} else if (recursionStack.has(neighbor)) {
|
|
62
|
+
// Found cycle - return path from neighbor to current
|
|
63
|
+
const cycleStart = path.indexOf(neighbor)
|
|
64
|
+
return [...path.slice(cycleStart), neighbor]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
path.pop()
|
|
69
|
+
recursionStack.delete(node)
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check all nodes
|
|
74
|
+
for (const node of graph.keys()) {
|
|
75
|
+
if (!visited.has(node)) {
|
|
76
|
+
const cycle = dfs(node)
|
|
77
|
+
if (cycle) return cycle
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class EventRouter {
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} options
|
|
87
|
+
* @param {SignalBus} options.signals - SignalBus instance
|
|
88
|
+
* @param {Orchestrator} [options.orchestrator] - Orchestrator (for callback context)
|
|
89
|
+
* @param {object} options.routes - Route configuration
|
|
90
|
+
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
91
|
+
*/
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
const { signals, orchestrator = null, routes = {}, debug = false } = options
|
|
94
|
+
|
|
95
|
+
if (!signals) {
|
|
96
|
+
throw new Error('[EventRouter] signals is required')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._signals = signals
|
|
100
|
+
this._orchestrator = orchestrator
|
|
101
|
+
this._routes = routes
|
|
102
|
+
this._debug = debug
|
|
103
|
+
this._cleanups = []
|
|
104
|
+
|
|
105
|
+
// Validate and setup
|
|
106
|
+
this._validateRoutes()
|
|
107
|
+
this._setupListeners()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate routes configuration and check for cycles
|
|
112
|
+
* @private
|
|
113
|
+
*/
|
|
114
|
+
_validateRoutes() {
|
|
115
|
+
// Check for cycles
|
|
116
|
+
const cycle = detectCycles(this._routes)
|
|
117
|
+
if (cycle) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[EventRouter] Cycle detected: ${cycle.join(' -> ')}`
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Validate each route
|
|
124
|
+
for (const [source, targets] of Object.entries(this._routes)) {
|
|
125
|
+
if (!Array.isArray(targets)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`[EventRouter] Route "${source}" must be an array of targets`
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < targets.length; i++) {
|
|
132
|
+
const target = targets[i]
|
|
133
|
+
const isString = typeof target === 'string'
|
|
134
|
+
const isFunction = typeof target === 'function'
|
|
135
|
+
const isObject = target && typeof target === 'object' && target.signal
|
|
136
|
+
|
|
137
|
+
if (!isString && !isFunction && !isObject) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[EventRouter] Invalid target at "${source}"[${i}]: must be string, function, or { signal, transform? }`
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Setup signal listeners for all routes
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
_setupListeners() {
|
|
151
|
+
for (const [source, targets] of Object.entries(this._routes)) {
|
|
152
|
+
const cleanup = this._signals.on(source, (payload) => {
|
|
153
|
+
this._handleRoute(source, payload, targets)
|
|
154
|
+
})
|
|
155
|
+
this._cleanups.push(cleanup)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (this._debug) {
|
|
159
|
+
console.debug(`[EventRouter] Registered ${Object.keys(this._routes).length} routes`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Handle a routed signal
|
|
165
|
+
* @private
|
|
166
|
+
*/
|
|
167
|
+
_handleRoute(source, payload, targets) {
|
|
168
|
+
if (this._debug) {
|
|
169
|
+
console.debug(`[EventRouter] ${source} -> ${targets.length} targets`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const context = {
|
|
173
|
+
signals: this._signals,
|
|
174
|
+
orchestrator: this._orchestrator
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const target of targets) {
|
|
178
|
+
try {
|
|
179
|
+
if (typeof target === 'string') {
|
|
180
|
+
// String: emit signal with same payload
|
|
181
|
+
this._signals.emit(target, payload)
|
|
182
|
+
if (this._debug) {
|
|
183
|
+
console.debug(`[EventRouter] -> ${target} (forward)`)
|
|
184
|
+
}
|
|
185
|
+
} else if (typeof target === 'function') {
|
|
186
|
+
// Function: call callback
|
|
187
|
+
target(payload, context)
|
|
188
|
+
if (this._debug) {
|
|
189
|
+
console.debug(`[EventRouter] -> callback()`)
|
|
190
|
+
}
|
|
191
|
+
} else if (target && target.signal) {
|
|
192
|
+
// Object: emit signal with transformed payload
|
|
193
|
+
const transformedPayload = target.transform
|
|
194
|
+
? target.transform(payload)
|
|
195
|
+
: payload
|
|
196
|
+
this._signals.emit(target.signal, transformedPayload)
|
|
197
|
+
if (this._debug) {
|
|
198
|
+
console.debug(`[EventRouter] -> ${target.signal} (transform)`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error(`[EventRouter] Error handling ${source} -> ${JSON.stringify(target)}:`, error)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Add a route dynamically
|
|
209
|
+
*
|
|
210
|
+
* @param {string} source - Source signal
|
|
211
|
+
* @param {Array} targets - Target array
|
|
212
|
+
*/
|
|
213
|
+
addRoute(source, targets) {
|
|
214
|
+
if (this._routes[source]) {
|
|
215
|
+
throw new Error(`[EventRouter] Route "${source}" already exists`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Validate new route doesn't create cycle
|
|
219
|
+
const testRoutes = { ...this._routes, [source]: targets }
|
|
220
|
+
const cycle = detectCycles(testRoutes)
|
|
221
|
+
if (cycle) {
|
|
222
|
+
throw new Error(`[EventRouter] Adding route would create cycle: ${cycle.join(' -> ')}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this._routes[source] = targets
|
|
226
|
+
|
|
227
|
+
// Setup listener
|
|
228
|
+
const cleanup = this._signals.on(source, (payload) => {
|
|
229
|
+
this._handleRoute(source, payload, targets)
|
|
230
|
+
})
|
|
231
|
+
this._cleanups.push(cleanup)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get all registered routes
|
|
236
|
+
* @returns {object}
|
|
237
|
+
*/
|
|
238
|
+
getRoutes() {
|
|
239
|
+
return { ...this._routes }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Dispose the router, cleaning up all listeners
|
|
244
|
+
*/
|
|
245
|
+
dispose() {
|
|
246
|
+
for (const cleanup of this._cleanups) {
|
|
247
|
+
if (typeof cleanup === 'function') {
|
|
248
|
+
cleanup()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
this._cleanups = []
|
|
252
|
+
this._routes = {}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Factory function for creating EventRouter
|
|
258
|
+
*
|
|
259
|
+
* @param {object} options
|
|
260
|
+
* @returns {EventRouter}
|
|
261
|
+
*/
|
|
262
|
+
export function createEventRouter(options) {
|
|
263
|
+
return new EventRouter(options)
|
|
264
|
+
}
|