qdadm 1.13.1 → 1.19.2
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 +7 -3
- package/src/chain/ActiveStack.ts +79 -98
- package/src/chain/StackHydrator.ts +3 -2
- package/src/chain/index.ts +7 -1
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/edit/FormActions.vue +9 -6
- package/src/components/edit/LookupPickerDialog.vue +6 -3
- package/src/components/index.ts +6 -0
- package/src/composables/useEntityItemFormPage.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +1 -0
- package/src/composables/useFieldManager.ts +100 -3
- package/src/composables/useListPage.ts +50 -59
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavigation.ts +26 -3
- package/src/composables/useOptionsLookup.ts +5 -1
- package/src/gen/generateManagers.test.js +27 -0
- package/src/gen/generateManagers.ts +12 -0
- package/src/hooks/HookRegistry.ts +14 -435
- package/src/i18n/I18n.ts +344 -0
- package/src/i18n/IncrementalDomainProvider.ts +153 -0
- package/src/i18n/InlineTranslationProvider.ts +4 -0
- package/src/i18n/LazyTranslationProvider.ts +102 -0
- package/src/i18n/MessagesRegistry.ts +4 -0
- package/src/i18n/Resolver.ts +4 -0
- package/src/i18n/__tests__/I18n.test.ts +169 -0
- package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
- package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
- package/src/i18n/__tests__/Resolver.test.ts +271 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
- package/src/i18n/defaults/core.en.yml +55 -0
- package/src/i18n/defaults/core.fr.yml +55 -0
- package/src/i18n/index.ts +55 -0
- package/src/i18n/loaders/raw-modules.d.ts +15 -0
- package/src/i18n/loaders/yaml.ts +35 -0
- package/src/i18n/strategies.ts +4 -0
- package/src/i18n/types.ts +34 -0
- package/src/i18n/useI18n.ts +34 -0
- package/src/index.ts +37 -0
- package/src/kernel/EventRouter.ts +17 -300
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +6 -0
- package/src/kernel/Kernel.registries.ts +10 -2
- package/src/kernel/Kernel.routing.ts +43 -1
- package/src/kernel/Kernel.ts +43 -0
- package/src/kernel/Kernel.types.ts +52 -1
- package/src/kernel/Kernel.vue.ts +121 -15
- package/src/kernel/KernelContext.entities.ts +80 -0
- package/src/kernel/KernelContext.events.ts +57 -0
- package/src/kernel/KernelContext.i18n.ts +37 -0
- package/src/kernel/KernelContext.permissions.ts +38 -0
- package/src/kernel/KernelContext.routing.ts +280 -0
- package/src/kernel/KernelContext.ts +125 -834
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/SSEBridge.ts +7 -362
- package/src/kernel/SignalBus.ts +24 -148
- package/src/modules/debug/AuthCollector.ts +48 -1
- package/src/modules/debug/Collector.ts +16 -302
- package/src/modules/debug/DebugBridge.ts +10 -171
- package/src/modules/debug/DebugModule.ts +35 -5
- package/src/modules/debug/EntitiesCollector.ts +97 -1
- package/src/modules/debug/ErrorCollector.ts +2 -77
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +3 -147
- package/src/modules/debug/RouterCollector.ts +101 -1
- package/src/modules/debug/SignalCollector.ts +2 -150
- package/src/modules/debug/ToastCollector.ts +2 -91
- package/src/modules/debug/ZonesCollector.ts +93 -1
- package/src/modules/debug/components/DebugBar.vue +19 -775
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +4 -3
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -1
- package/src/modules/debug/components/panels/EntitiesPanel.vue +5 -5
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +1 -1
- package/src/modules/debug/components/panels/index.ts +10 -4
- package/src/modules/debug/index.ts +15 -0
- package/src/modules/debug/styles.scss +22 -18
- package/src/utils/index.ts +0 -3
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +16 -0
- package/src/modules/debug/components/ObjectTree.vue +0 -123
- package/src/modules/debug/components/panels/EntriesPanel.vue +0 -100
- package/src/modules/debug/components/panels/SignalsPanel.vue +0 -188
- package/src/modules/debug/components/panels/ToastsPanel.vue +0 -45
- package/src/utils/debugInjector.ts +0 -306
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default core provider — ships qdadm's `core.*` defaults (EN + FR) as a
|
|
3
|
+
* `LazyTranslationProvider` configured with a YAML loader.
|
|
4
|
+
*
|
|
5
|
+
* The YAML files (`core.en.yml`, `core.fr.yml`) are imported via Vite's
|
|
6
|
+
* `?raw` query so the text is fetched on-demand, then parsed at runtime.
|
|
7
|
+
* Splitting the bundle by locale (and, in the future, by domain) keeps the
|
|
8
|
+
* provider tree-shakeable and translator-friendly.
|
|
9
|
+
*
|
|
10
|
+
* Adding a built-in locale = drop `core.<locale>.yml` next to this file and
|
|
11
|
+
* add it to the loader map below.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { LazyTranslationProvider } from '../LazyTranslationProvider'
|
|
15
|
+
import { createYamlLoader } from '../loaders/yaml'
|
|
16
|
+
|
|
17
|
+
export function createDefaultCoreProvider(): LazyTranslationProvider {
|
|
18
|
+
const yamlLoader = createYamlLoader({
|
|
19
|
+
en: () => import('./core.en.yml?raw'),
|
|
20
|
+
fr: () => import('./core.fr.yml?raw'),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return new LazyTranslationProvider({
|
|
24
|
+
name: 'default-core',
|
|
25
|
+
loaders: [yamlLoader],
|
|
26
|
+
locales: ['en', 'fr'],
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
core:
|
|
2
|
+
actions:
|
|
3
|
+
save: Save
|
|
4
|
+
cancel: Cancel
|
|
5
|
+
delete: Delete
|
|
6
|
+
edit: Edit
|
|
7
|
+
create: Create
|
|
8
|
+
update: Update
|
|
9
|
+
createAndClose: Create & Close
|
|
10
|
+
updateAndClose: Update & Close
|
|
11
|
+
add: Add
|
|
12
|
+
remove: Remove
|
|
13
|
+
submit: Submit
|
|
14
|
+
reset: Reset
|
|
15
|
+
back: Back
|
|
16
|
+
next: Next
|
|
17
|
+
previous: Previous
|
|
18
|
+
close: Close
|
|
19
|
+
confirm: Confirm
|
|
20
|
+
search: Search
|
|
21
|
+
filter: Filter
|
|
22
|
+
clear: Clear
|
|
23
|
+
refresh: Refresh
|
|
24
|
+
export: Export
|
|
25
|
+
import: Import
|
|
26
|
+
tooltips:
|
|
27
|
+
saveAndContinue: Save and continue editing
|
|
28
|
+
saveAndReturn: Save and return to list
|
|
29
|
+
unsavedChanges: Unsaved changes
|
|
30
|
+
placeholders:
|
|
31
|
+
search: Search…
|
|
32
|
+
noSelection: Click to select…
|
|
33
|
+
fields:
|
|
34
|
+
id: ID
|
|
35
|
+
created_at: Created at
|
|
36
|
+
updated_at: Updated at
|
|
37
|
+
created_by: Created by
|
|
38
|
+
updated_by: Updated by
|
|
39
|
+
messages:
|
|
40
|
+
empty: No items available
|
|
41
|
+
loading: Loading…
|
|
42
|
+
noResults: No results
|
|
43
|
+
noMatching: No matching items
|
|
44
|
+
saved: Saved
|
|
45
|
+
deleted: Deleted
|
|
46
|
+
created: Created
|
|
47
|
+
updated: Updated
|
|
48
|
+
confirmDelete: Are you sure you want to delete this item?
|
|
49
|
+
errors:
|
|
50
|
+
required: Required
|
|
51
|
+
tooShort: Too short (min {min})
|
|
52
|
+
tooLong: Too long (max {max})
|
|
53
|
+
invalid: Invalid value
|
|
54
|
+
notFound: Not found
|
|
55
|
+
unknown: An unknown error occurred
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
core:
|
|
2
|
+
actions:
|
|
3
|
+
save: Enregistrer
|
|
4
|
+
cancel: Annuler
|
|
5
|
+
delete: Supprimer
|
|
6
|
+
edit: Modifier
|
|
7
|
+
create: Créer
|
|
8
|
+
update: Mettre à jour
|
|
9
|
+
createAndClose: Créer et fermer
|
|
10
|
+
updateAndClose: Mettre à jour et fermer
|
|
11
|
+
add: Ajouter
|
|
12
|
+
remove: Retirer
|
|
13
|
+
submit: Valider
|
|
14
|
+
reset: Réinitialiser
|
|
15
|
+
back: Retour
|
|
16
|
+
next: Suivant
|
|
17
|
+
previous: Précédent
|
|
18
|
+
close: Fermer
|
|
19
|
+
confirm: Confirmer
|
|
20
|
+
search: Rechercher
|
|
21
|
+
filter: Filtrer
|
|
22
|
+
clear: Effacer
|
|
23
|
+
refresh: Actualiser
|
|
24
|
+
export: Exporter
|
|
25
|
+
import: Importer
|
|
26
|
+
tooltips:
|
|
27
|
+
saveAndContinue: Enregistrer et continuer la modification
|
|
28
|
+
saveAndReturn: Enregistrer et revenir à la liste
|
|
29
|
+
unsavedChanges: Modifications non enregistrées
|
|
30
|
+
placeholders:
|
|
31
|
+
search: Rechercher…
|
|
32
|
+
noSelection: Cliquez pour sélectionner…
|
|
33
|
+
fields:
|
|
34
|
+
id: Identifiant
|
|
35
|
+
created_at: Créé le
|
|
36
|
+
updated_at: Modifié le
|
|
37
|
+
created_by: Créé par
|
|
38
|
+
updated_by: Modifié par
|
|
39
|
+
messages:
|
|
40
|
+
empty: Aucun élément
|
|
41
|
+
loading: Chargement…
|
|
42
|
+
noResults: Aucun résultat
|
|
43
|
+
noMatching: Aucune correspondance
|
|
44
|
+
saved: Enregistré
|
|
45
|
+
deleted: Supprimé
|
|
46
|
+
created: Créé
|
|
47
|
+
updated: Modifié
|
|
48
|
+
confirmDelete: Êtes-vous sûr de vouloir supprimer cet élément ?
|
|
49
|
+
errors:
|
|
50
|
+
required: Requis
|
|
51
|
+
tooShort: Trop court (min {min})
|
|
52
|
+
tooLong: Trop long (max {max})
|
|
53
|
+
invalid: Valeur invalide
|
|
54
|
+
notFound: Introuvable
|
|
55
|
+
unknown: Une erreur inconnue est survenue
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm i18n - public exports.
|
|
3
|
+
*
|
|
4
|
+
* ## Two providers to choose from
|
|
5
|
+
*
|
|
6
|
+
* - **`LazyTranslationProvider`** — full bundle on `load(locale)`, split
|
|
7
|
+
* across multiple loaders (one per file/domain) deep-merged on resolve.
|
|
8
|
+
* Use when the locale bundle is reasonable to fetch up-front.
|
|
9
|
+
*
|
|
10
|
+
* - **`IncrementalDomainProvider`** — one loader per top-level domain,
|
|
11
|
+
* fetched on demand. `t()` on an unloaded domain returns the raw key
|
|
12
|
+
* synchronously and emits `i18n:domain-loaded` once merged. Use for
|
|
13
|
+
* large apps with rarely-used sections.
|
|
14
|
+
*
|
|
15
|
+
* Both support `createYamlLoader({ <locale>: () => import('./xx.yml?raw') })`
|
|
16
|
+
* for the typical "split YAML files in a directory" setup.
|
|
17
|
+
*
|
|
18
|
+
* See docs/todo-i18n.md for the broader design rationale.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { I18n } from './I18n'
|
|
22
|
+
export type { I18nDeps } from './I18n'
|
|
23
|
+
export { MessagesRegistry } from './MessagesRegistry'
|
|
24
|
+
export { Resolver, snakeCaseToTitle } from './Resolver'
|
|
25
|
+
export { InlineTranslationProvider } from './InlineTranslationProvider'
|
|
26
|
+
export { defineStrategy, resolveStrategy, STRATEGIES } from './strategies'
|
|
27
|
+
export { LazyTranslationProvider } from './LazyTranslationProvider'
|
|
28
|
+
export type { LazyLoader, LazyTranslationProviderOptions } from './LazyTranslationProvider'
|
|
29
|
+
export {
|
|
30
|
+
IncrementalDomainProvider,
|
|
31
|
+
isDomainAwareProvider,
|
|
32
|
+
} from './IncrementalDomainProvider'
|
|
33
|
+
export type {
|
|
34
|
+
DomainLoader,
|
|
35
|
+
IncrementalDomainProviderOptions,
|
|
36
|
+
} from './IncrementalDomainProvider'
|
|
37
|
+
export { createYamlLoader } from './loaders/yaml'
|
|
38
|
+
export type { YamlTextImport } from './loaders/yaml'
|
|
39
|
+
export { createDefaultCoreProvider } from './defaults/DefaultCoreProvider'
|
|
40
|
+
export { useI18n, I18N_INJECTION_KEY } from './useI18n'
|
|
41
|
+
export type {
|
|
42
|
+
AliasPattern,
|
|
43
|
+
CoreMessages,
|
|
44
|
+
EntityMessages,
|
|
45
|
+
I18nOptions,
|
|
46
|
+
KeyStrategy,
|
|
47
|
+
KeyStrategyName,
|
|
48
|
+
MessagesBundle,
|
|
49
|
+
MessagesNode,
|
|
50
|
+
NavMessages,
|
|
51
|
+
ResolveStep,
|
|
52
|
+
ResolveTrace,
|
|
53
|
+
TranslateParams,
|
|
54
|
+
TranslationProvider,
|
|
55
|
+
} from './types'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declarations for Vite/Vitest `?raw` imports of arbitrary text
|
|
3
|
+
* files (YAML, etc.). Lets `import yamlText from './foo.yml?raw'` type-check
|
|
4
|
+
* without per-call casts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
declare module '*.yml?raw' {
|
|
8
|
+
const content: string
|
|
9
|
+
export default content
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module '*.yaml?raw' {
|
|
13
|
+
const content: string
|
|
14
|
+
export default content
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML loader factory for LazyTranslationProvider.
|
|
3
|
+
*
|
|
4
|
+
* Given a `locale → () => Promise<{ default: string }>` map, returns a
|
|
5
|
+
* `LazyLoader` that resolves the YAML text for the requested locale and
|
|
6
|
+
* parses it through the `yaml` package.
|
|
7
|
+
*
|
|
8
|
+
* Typical usage with Vite/Vitest `?raw` imports (qdadm-internal default):
|
|
9
|
+
*
|
|
10
|
+
* const loader = createYamlLoader({
|
|
11
|
+
* en: () => import('./core.en.yml?raw'),
|
|
12
|
+
* fr: () => import('./core.fr.yml?raw'),
|
|
13
|
+
* })
|
|
14
|
+
*
|
|
15
|
+
* The dynamic-import boundary is what gives us code-splitting: the YAML for
|
|
16
|
+
* a locale is only fetched + parsed when that locale is requested.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parse as parseYaml } from 'yaml'
|
|
20
|
+
|
|
21
|
+
import type { MessagesBundle } from '@quazardous/qdcore'
|
|
22
|
+
import type { LazyLoader } from '../LazyTranslationProvider'
|
|
23
|
+
|
|
24
|
+
export type YamlTextImport = () => Promise<{ default: string }>
|
|
25
|
+
|
|
26
|
+
export function createYamlLoader(textImports: Record<string, YamlTextImport>): LazyLoader {
|
|
27
|
+
return async (locale: string) => {
|
|
28
|
+
const importer = textImports[locale]
|
|
29
|
+
if (!importer) return null
|
|
30
|
+
const mod = await importer()
|
|
31
|
+
const parsed = parseYaml(mod.default)
|
|
32
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
33
|
+
return parsed as MessagesBundle
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qdadm i18n types.
|
|
3
|
+
*
|
|
4
|
+
* Generic types come from `@quazardous/qdcore/i18n`; this module adds the
|
|
5
|
+
* admin-specific `I18nOptions` (kernel constructor option that toggles the
|
|
6
|
+
* default core admin bundles shipped via DefaultCoreProvider).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { BaseI18nOptions } from '@quazardous/qdcore'
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
AliasPattern,
|
|
13
|
+
BaseI18nOptions,
|
|
14
|
+
CoreMessages,
|
|
15
|
+
EntityMessages,
|
|
16
|
+
KeyStrategy,
|
|
17
|
+
KeyStrategyName,
|
|
18
|
+
MessagesBundle,
|
|
19
|
+
MessagesNode,
|
|
20
|
+
NavMessages,
|
|
21
|
+
ResolveStep,
|
|
22
|
+
ResolveTrace,
|
|
23
|
+
TranslateParams,
|
|
24
|
+
TranslationProvider,
|
|
25
|
+
} from '@quazardous/qdcore'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Kernel-level i18n options for qdadm. Extends the generic shared shape with
|
|
29
|
+
* admin-only knobs.
|
|
30
|
+
*/
|
|
31
|
+
export interface I18nOptions extends BaseI18nOptions {
|
|
32
|
+
/** Disable shipping qdadm's default core.* bundles (EN + FR). Default: false. */
|
|
33
|
+
disableDefaultCoreBundle?: boolean
|
|
34
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useI18n - Vue composable for accessing the kernel I18n instance.
|
|
3
|
+
*
|
|
4
|
+
* Falls back to a no-op shim if no I18n has been provided to the app — useful
|
|
5
|
+
* for components rendered outside a kernel (tests, isolated examples).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { computed, inject, type Ref, type ComputedRef } from 'vue'
|
|
9
|
+
import type { I18n } from './I18n'
|
|
10
|
+
import type { TranslateParams } from './types'
|
|
11
|
+
|
|
12
|
+
export const I18N_INJECTION_KEY = Symbol('qdadm.i18n')
|
|
13
|
+
|
|
14
|
+
export interface UseI18nResult {
|
|
15
|
+
t: (key: string, params?: TranslateParams) => string
|
|
16
|
+
locale: ComputedRef<string> | Ref<string>
|
|
17
|
+
i18n: I18n | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useI18n(): UseI18nResult {
|
|
21
|
+
const i18n = inject<I18n | null>(I18N_INJECTION_KEY, null)
|
|
22
|
+
if (!i18n) {
|
|
23
|
+
return {
|
|
24
|
+
t: (key: string) => key,
|
|
25
|
+
locale: computed(() => 'en'),
|
|
26
|
+
i18n: null,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
t: (key, params) => i18n.t(key, params),
|
|
31
|
+
locale: i18n.locale,
|
|
32
|
+
i18n,
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -410,6 +410,43 @@ export * from './notifications/index'
|
|
|
410
410
|
// import { debugBar, DebugModule } from 'qdadm/debug'
|
|
411
411
|
// ════════════════════════════════════════════════════════════════════════════
|
|
412
412
|
|
|
413
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
414
|
+
// I18N (schema-derived keys, pluggable providers, signal-driven locale)
|
|
415
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
416
|
+
export {
|
|
417
|
+
I18n,
|
|
418
|
+
MessagesRegistry,
|
|
419
|
+
Resolver,
|
|
420
|
+
snakeCaseToTitle,
|
|
421
|
+
InlineTranslationProvider,
|
|
422
|
+
defineStrategy,
|
|
423
|
+
resolveStrategy,
|
|
424
|
+
STRATEGIES,
|
|
425
|
+
LazyTranslationProvider,
|
|
426
|
+
IncrementalDomainProvider,
|
|
427
|
+
isDomainAwareProvider,
|
|
428
|
+
createYamlLoader,
|
|
429
|
+
createDefaultCoreProvider,
|
|
430
|
+
useI18n,
|
|
431
|
+
I18N_INJECTION_KEY,
|
|
432
|
+
} from './i18n'
|
|
433
|
+
export type {
|
|
434
|
+
AliasPattern,
|
|
435
|
+
CoreMessages,
|
|
436
|
+
EntityMessages,
|
|
437
|
+
I18nDeps,
|
|
438
|
+
I18nOptions,
|
|
439
|
+
KeyStrategy,
|
|
440
|
+
KeyStrategyName,
|
|
441
|
+
MessagesBundle,
|
|
442
|
+
MessagesNode,
|
|
443
|
+
NavMessages,
|
|
444
|
+
ResolveStep,
|
|
445
|
+
ResolveTrace,
|
|
446
|
+
TranslateParams,
|
|
447
|
+
TranslationProvider,
|
|
448
|
+
} from './i18n'
|
|
449
|
+
|
|
413
450
|
// ════════════════════════════════════════════════════════════════════════════
|
|
414
451
|
// ASSETS
|
|
415
452
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -1,306 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* EventRouter
|
|
2
|
+
* EventRouter — qdadm re-export.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The actual implementation now lives in `@quazardous/qdcore` so it can be
|
|
5
|
+
* shared with qdcms. The legacy `orchestrator` constructor option (which was
|
|
6
|
+
* never actually read by any callback in qdadm) has been dropped; callers
|
|
7
|
+
* needing extras in `RouteContext` should use the new `context` option:
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* signals, // SignalBus instance
|
|
11
|
-
* orchestrator, // Orchestrator instance (optional, for callbacks)
|
|
12
|
-
* routes: {
|
|
13
|
-
* // Auth events → datalayer invalidation with actuator (only authSensitive entities react)
|
|
14
|
-
* 'auth:**': [{ signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }],
|
|
15
|
-
*
|
|
16
|
-
* // Custom routing with transform
|
|
17
|
-
* 'order:completed': [
|
|
18
|
-
* { signal: 'notify', transform: (p) => ({ msg: `Order ${p.id} done` }) },
|
|
19
|
-
* (payload, ctx) => { ... } // callback
|
|
20
|
-
* ]
|
|
21
|
-
* }
|
|
22
|
-
* })
|
|
9
|
+
* ```ts
|
|
10
|
+
* createEventRouter({ signals, context: { orchestrator }, routes })
|
|
11
|
+
* // -> available inside callbacks as ctx.orchestrator
|
|
23
12
|
* ```
|
|
24
13
|
*/
|
|
25
14
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Route callback context
|
|
39
|
-
*/
|
|
40
|
-
export interface RouteContext {
|
|
41
|
-
signals: SignalBus
|
|
42
|
-
orchestrator: Orchestrator | null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Route callback function
|
|
47
|
-
*/
|
|
48
|
-
export type RouteCallback = (payload: unknown, context: RouteContext) => void
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Route target types
|
|
52
|
-
*/
|
|
53
|
-
export type RouteTarget = string | SignalTarget | RouteCallback
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Routes configuration
|
|
57
|
-
*/
|
|
58
|
-
export type RoutesConfig = Record<string, RouteTarget[]>
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* EventRouter options
|
|
62
|
-
*/
|
|
63
|
-
export interface EventRouterOptions {
|
|
64
|
-
signals: SignalBus
|
|
65
|
-
orchestrator?: Orchestrator | null
|
|
66
|
-
routes?: RoutesConfig
|
|
67
|
-
debug?: boolean
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Detect cycles in route graph using DFS
|
|
72
|
-
*/
|
|
73
|
-
function detectCycles(routes: RoutesConfig): string[] | null {
|
|
74
|
-
// Build adjacency list (only signal targets, not callbacks)
|
|
75
|
-
const graph = new Map<string, string[]>()
|
|
76
|
-
|
|
77
|
-
for (const [source, targets] of Object.entries(routes)) {
|
|
78
|
-
const signalTargets: string[] = []
|
|
79
|
-
for (const target of targets) {
|
|
80
|
-
if (typeof target === 'string') {
|
|
81
|
-
signalTargets.push(target)
|
|
82
|
-
} else if (target && typeof target === 'object' && 'signal' in target) {
|
|
83
|
-
signalTargets.push(target.signal)
|
|
84
|
-
}
|
|
85
|
-
// Functions don't create edges in the graph
|
|
86
|
-
}
|
|
87
|
-
graph.set(source, signalTargets)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// DFS cycle detection
|
|
91
|
-
const visited = new Set<string>()
|
|
92
|
-
const recursionStack = new Set<string>()
|
|
93
|
-
const path: string[] = []
|
|
94
|
-
|
|
95
|
-
function dfs(node: string): string[] | null {
|
|
96
|
-
visited.add(node)
|
|
97
|
-
recursionStack.add(node)
|
|
98
|
-
path.push(node)
|
|
99
|
-
|
|
100
|
-
const neighbors = graph.get(node) || []
|
|
101
|
-
for (const neighbor of neighbors) {
|
|
102
|
-
if (!visited.has(neighbor)) {
|
|
103
|
-
const cycle = dfs(neighbor)
|
|
104
|
-
if (cycle) return cycle
|
|
105
|
-
} else if (recursionStack.has(neighbor)) {
|
|
106
|
-
// Found cycle - return path from neighbor to current
|
|
107
|
-
const cycleStart = path.indexOf(neighbor)
|
|
108
|
-
return [...path.slice(cycleStart), neighbor]
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
path.pop()
|
|
113
|
-
recursionStack.delete(node)
|
|
114
|
-
return null
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Check all nodes
|
|
118
|
-
for (const node of graph.keys()) {
|
|
119
|
-
if (!visited.has(node)) {
|
|
120
|
-
const cycle = dfs(node)
|
|
121
|
-
if (cycle) return cycle
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return null
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export class EventRouter {
|
|
129
|
-
private _signals: SignalBus
|
|
130
|
-
private _orchestrator: Orchestrator | null
|
|
131
|
-
private _routes: RoutesConfig
|
|
132
|
-
private _debug: boolean
|
|
133
|
-
private _cleanups: Array<() => void> = []
|
|
134
|
-
|
|
135
|
-
constructor(options: EventRouterOptions) {
|
|
136
|
-
const { signals, orchestrator = null, routes = {}, debug = false } = options
|
|
137
|
-
|
|
138
|
-
if (!signals) {
|
|
139
|
-
throw new Error('[EventRouter] signals is required')
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
this._signals = signals
|
|
143
|
-
this._orchestrator = orchestrator
|
|
144
|
-
this._routes = routes
|
|
145
|
-
this._debug = debug
|
|
146
|
-
|
|
147
|
-
// Validate and setup
|
|
148
|
-
this._validateRoutes()
|
|
149
|
-
this._setupListeners()
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Validate routes configuration and check for cycles
|
|
154
|
-
*/
|
|
155
|
-
private _validateRoutes(): void {
|
|
156
|
-
// Check for cycles
|
|
157
|
-
const cycle = detectCycles(this._routes)
|
|
158
|
-
if (cycle) {
|
|
159
|
-
throw new Error(`[EventRouter] Cycle detected: ${cycle.join(' -> ')}`)
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Validate each route
|
|
163
|
-
for (const [source, targets] of Object.entries(this._routes)) {
|
|
164
|
-
if (!Array.isArray(targets)) {
|
|
165
|
-
throw new Error(
|
|
166
|
-
`[EventRouter] Route "${source}" must be an array of targets`
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
for (let i = 0; i < targets.length; i++) {
|
|
171
|
-
const target = targets[i]
|
|
172
|
-
const isString = typeof target === 'string'
|
|
173
|
-
const isFunction = typeof target === 'function'
|
|
174
|
-
const isObject =
|
|
175
|
-
target && typeof target === 'object' && 'signal' in target
|
|
176
|
-
|
|
177
|
-
if (!isString && !isFunction && !isObject) {
|
|
178
|
-
throw new Error(
|
|
179
|
-
`[EventRouter] Invalid target at "${source}"[${i}]: must be string, function, or { signal, transform? }`
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Setup signal listeners for all routes
|
|
188
|
-
*/
|
|
189
|
-
private _setupListeners(): void {
|
|
190
|
-
for (const [source, targets] of Object.entries(this._routes)) {
|
|
191
|
-
const cleanup = this._signals.on(source, (event: { data: unknown }) => {
|
|
192
|
-
this._handleRoute(source, event.data, targets)
|
|
193
|
-
})
|
|
194
|
-
this._cleanups.push(cleanup)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (this._debug) {
|
|
198
|
-
console.debug(
|
|
199
|
-
`[EventRouter] Registered ${Object.keys(this._routes).length} routes`
|
|
200
|
-
)
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Handle a routed signal
|
|
206
|
-
*/
|
|
207
|
-
private _handleRoute(
|
|
208
|
-
source: string,
|
|
209
|
-
payload: unknown,
|
|
210
|
-
targets: RouteTarget[]
|
|
211
|
-
): void {
|
|
212
|
-
if (this._debug) {
|
|
213
|
-
console.debug(`[EventRouter] ${source} -> ${targets.length} targets`)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const context: RouteContext = {
|
|
217
|
-
signals: this._signals,
|
|
218
|
-
orchestrator: this._orchestrator,
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const target of targets) {
|
|
222
|
-
try {
|
|
223
|
-
if (typeof target === 'string') {
|
|
224
|
-
// String: emit signal with same payload
|
|
225
|
-
this._signals.emit(target, payload)
|
|
226
|
-
if (this._debug) {
|
|
227
|
-
console.debug(`[EventRouter] -> ${target} (forward)`)
|
|
228
|
-
}
|
|
229
|
-
} else if (typeof target === 'function') {
|
|
230
|
-
// Function: call callback
|
|
231
|
-
target(payload, context)
|
|
232
|
-
if (this._debug) {
|
|
233
|
-
console.debug(`[EventRouter] -> callback()`)
|
|
234
|
-
}
|
|
235
|
-
} else if (target && 'signal' in target) {
|
|
236
|
-
// Object: emit signal with transformed payload
|
|
237
|
-
const transformedPayload = target.transform
|
|
238
|
-
? target.transform(payload)
|
|
239
|
-
: payload
|
|
240
|
-
this._signals.emit(target.signal, transformedPayload)
|
|
241
|
-
if (this._debug) {
|
|
242
|
-
console.debug(`[EventRouter] -> ${target.signal} (transform)`)
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
} catch (error) {
|
|
246
|
-
console.error(
|
|
247
|
-
`[EventRouter] Error handling ${source} -> ${JSON.stringify(target)}:`,
|
|
248
|
-
error
|
|
249
|
-
)
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Add a route dynamically
|
|
256
|
-
*/
|
|
257
|
-
addRoute(source: string, targets: RouteTarget[]): void {
|
|
258
|
-
if (this._routes[source]) {
|
|
259
|
-
throw new Error(`[EventRouter] Route "${source}" already exists`)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Validate new route doesn't create cycle
|
|
263
|
-
const testRoutes = { ...this._routes, [source]: targets }
|
|
264
|
-
const cycle = detectCycles(testRoutes)
|
|
265
|
-
if (cycle) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`[EventRouter] Adding route would create cycle: ${cycle.join(' -> ')}`
|
|
268
|
-
)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
this._routes[source] = targets
|
|
272
|
-
|
|
273
|
-
// Setup listener
|
|
274
|
-
const cleanup = this._signals.on(source, (event: { data: unknown }) => {
|
|
275
|
-
this._handleRoute(source, event.data, targets)
|
|
276
|
-
})
|
|
277
|
-
this._cleanups.push(cleanup)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Get all registered routes
|
|
282
|
-
*/
|
|
283
|
-
getRoutes(): RoutesConfig {
|
|
284
|
-
return { ...this._routes }
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Dispose the router, cleaning up all listeners
|
|
289
|
-
*/
|
|
290
|
-
dispose(): void {
|
|
291
|
-
for (const cleanup of this._cleanups) {
|
|
292
|
-
if (typeof cleanup === 'function') {
|
|
293
|
-
cleanup()
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
this._cleanups = []
|
|
297
|
-
this._routes = {}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Factory function for creating EventRouter
|
|
303
|
-
*/
|
|
304
|
-
export function createEventRouter(options: EventRouterOptions): EventRouter {
|
|
305
|
-
return new EventRouter(options)
|
|
306
|
-
}
|
|
15
|
+
export { EventRouter, createEventRouter } from '@quazardous/qdcore'
|
|
16
|
+
export type {
|
|
17
|
+
SignalTarget,
|
|
18
|
+
RouteContext,
|
|
19
|
+
RouteCallback,
|
|
20
|
+
RouteTarget,
|
|
21
|
+
RoutesConfig,
|
|
22
|
+
EventRouterOptions,
|
|
23
|
+
} from '@quazardous/qdcore'
|