@quazardous/qdadm 1.19.3
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/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +101 -0
- package/src/assets/logo.svg +6 -0
- package/src/auth/SessionAuthAdapter.ts +459 -0
- package/src/auth/index.ts +15 -0
- package/src/chain/ActiveStack.ts +154 -0
- package/src/chain/StackHydrator.ts +292 -0
- package/src/chain/index.ts +25 -0
- package/src/chain/useActiveStack.ts +120 -0
- package/src/chain/useStackHydrator.ts +155 -0
- package/src/components/BoolCell.vue +34 -0
- package/src/components/InfoBanner.vue +73 -0
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/SeverityTag.vue +86 -0
- package/src/components/dialogs/BulkStatusDialog.vue +60 -0
- package/src/components/dialogs/MultiStepDialog.vue +331 -0
- package/src/components/dialogs/SimpleDialog.vue +122 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +80 -0
- package/src/components/display/BannerZone.vue +65 -0
- package/src/components/display/CardsGrid.vue +72 -0
- package/src/components/display/CopyableId.vue +84 -0
- package/src/components/display/EmptyState.vue +62 -0
- package/src/components/display/IntensityBar.vue +190 -0
- package/src/components/display/RichCardsGrid.vue +101 -0
- package/src/components/edit/FormActions.vue +78 -0
- package/src/components/edit/FormField.vue +82 -0
- package/src/components/edit/FormInput.vue +158 -0
- package/src/components/edit/FormPage.vue +260 -0
- package/src/components/edit/FormTab.vue +217 -0
- package/src/components/edit/FormTabs.vue +120 -0
- package/src/components/edit/LookupField.vue +406 -0
- package/src/components/edit/LookupPickerDialog.vue +230 -0
- package/src/components/editors/JsonEditorFoldable.vue +470 -0
- package/src/components/editors/JsonStructuredField.vue +264 -0
- package/src/components/editors/JsonViewer.vue +51 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +259 -0
- package/src/components/editors/PermissionEditor.vue +558 -0
- package/src/components/editors/ScopeEditor.vue +372 -0
- package/src/components/editors/VanillaJsonEditor.vue +183 -0
- package/src/components/index.ts +83 -0
- package/src/components/item/FieldGroups.vue +392 -0
- package/src/components/layout/AppLayout.vue +1032 -0
- package/src/components/layout/BaseLayout.vue +190 -0
- package/src/components/layout/Breadcrumb.vue +55 -0
- package/src/components/layout/DashboardLayout.vue +104 -0
- package/src/components/layout/FormLayout.vue +256 -0
- package/src/components/layout/ListLayout.vue +278 -0
- package/src/components/layout/PageHeader.vue +156 -0
- package/src/components/layout/PageLayout.vue +126 -0
- package/src/components/layout/PageNav.vue +192 -0
- package/src/components/layout/SidebarBox.vue +87 -0
- package/src/components/layout/Zone.vue +182 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +149 -0
- package/src/components/layout/defaults/DefaultFooter.vue +89 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +67 -0
- package/src/components/layout/defaults/DefaultHeader.vue +78 -0
- package/src/components/layout/defaults/DefaultMenu.vue +188 -0
- package/src/components/layout/defaults/DefaultPagination.vue +75 -0
- package/src/components/layout/defaults/DefaultTable.vue +137 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +111 -0
- package/src/components/layout/defaults/index.ts +16 -0
- package/src/components/lists/ActionButtons.vue +49 -0
- package/src/components/lists/ActionColumn.vue +40 -0
- package/src/components/lists/FilterBar.vue +54 -0
- package/src/components/lists/ListPage.vue +467 -0
- package/src/components/pages/LoginPage.vue +334 -0
- package/src/components/pages/NotFoundPage.vue +98 -0
- package/src/components/show/ShowDisplay.vue +343 -0
- package/src/components/show/ShowField.vue +67 -0
- package/src/components/show/ShowPage.vue +431 -0
- package/src/composables/index.ts +155 -0
- package/src/composables/useApp.ts +67 -0
- package/src/composables/useAuth.ts +98 -0
- package/src/composables/useBareForm.ts +319 -0
- package/src/composables/useBreadcrumb.ts +252 -0
- package/src/composables/useChildPage.ts +113 -0
- package/src/composables/useCurrentEntity.ts +60 -0
- package/src/composables/useDeferred.ts +94 -0
- package/src/composables/useDirtyState.ts +138 -0
- package/src/composables/useEntityItemFormPage.ts +942 -0
- package/src/composables/useEntityItemFormPage.types.ts +451 -0
- package/src/composables/useEntityItemPage.ts +438 -0
- package/src/composables/useEntityItemShowPage.ts +722 -0
- package/src/composables/useFieldManager.ts +692 -0
- package/src/composables/useForm.ts +544 -0
- package/src/composables/useGuardStore.ts +53 -0
- package/src/composables/useHooks.ts +56 -0
- package/src/composables/useInfoBanner.ts +217 -0
- package/src/composables/useJsonSyntax.ts +122 -0
- package/src/composables/useLayoutResolver.ts +329 -0
- package/src/composables/useListPage.ts +1535 -0
- package/src/composables/useListPage.types.ts +437 -0
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavContext.ts +439 -0
- package/src/composables/useNavigation.ts +216 -0
- package/src/composables/useOptionsLookup.ts +323 -0
- package/src/composables/usePageTitle.ts +84 -0
- package/src/composables/useSSEBridge.ts +142 -0
- package/src/composables/useSemanticBreadcrumb.ts +222 -0
- package/src/composables/useSidebarState.ts +24 -0
- package/src/composables/useSignals.ts +52 -0
- package/src/composables/useUnsavedChangesGuard.ts +155 -0
- package/src/composables/useUserImpersonator.ts +317 -0
- package/src/composables/useZoneRegistry.ts +252 -0
- package/src/core/bundles.ts +558 -0
- package/src/core/decorator.ts +420 -0
- package/src/core/extension.ts +470 -0
- package/src/core/index.ts +59 -0
- package/src/deferred/DeferredRegistry.ts +353 -0
- package/src/deferred/index.ts +15 -0
- package/src/editors/index.ts +12 -0
- package/src/entity/EntityManager.cache.ts +530 -0
- package/src/entity/EntityManager.crud.ts +615 -0
- package/src/entity/EntityManager.interface.ts +78 -0
- package/src/entity/EntityManager.query.ts +221 -0
- package/src/entity/EntityManager.relations.ts +151 -0
- package/src/entity/EntityManager.ts +944 -0
- package/src/entity/EntityManager.types.ts +288 -0
- package/src/entity/auth/CompositeAuthAdapter.ts +194 -0
- package/src/entity/auth/EntityAuthAdapter.ts +119 -0
- package/src/entity/auth/PermissiveAdapter.ts +43 -0
- package/src/entity/auth/RoleHierarchy.ts +128 -0
- package/src/entity/auth/SecurityChecker.ts +202 -0
- package/src/entity/auth/factory.ts +203 -0
- package/src/entity/auth/index.ts +47 -0
- package/src/entity/factory.test.js +189 -0
- package/src/entity/factory.ts +146 -0
- package/src/entity/index.ts +49 -0
- package/src/entity/storage/ApiStorage.ts +228 -0
- package/src/entity/storage/IStorage.ts +100 -0
- package/src/entity/storage/LocalStorage.ts +200 -0
- package/src/entity/storage/MemoryStorage.ts +184 -0
- package/src/entity/storage/MockApiStorage.ts +318 -0
- package/src/entity/storage/SdkStorage.ts +377 -0
- package/src/entity/storage/factory.test.js +159 -0
- package/src/entity/storage/factory.ts +209 -0
- package/src/entity/storage/index.ts +75 -0
- package/src/gen/FieldMapper.ts +122 -0
- package/src/gen/StorageProfileFactory.ts +93 -0
- package/src/gen/connectors/BaseConnector.ts +150 -0
- package/src/gen/connectors/ManualConnector.test.js +499 -0
- package/src/gen/connectors/ManualConnector.ts +414 -0
- package/src/gen/connectors/OpenAPIConnector.test.js +836 -0
- package/src/gen/connectors/OpenAPIConnector.ts +693 -0
- package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
- package/src/gen/connectors/index.ts +27 -0
- package/src/gen/createManagers.ts +245 -0
- package/src/gen/decorators.ts +134 -0
- package/src/gen/generateManagers.test.js +540 -0
- package/src/gen/generateManagers.ts +398 -0
- package/src/gen/index.ts +66 -0
- package/src/gen/schema.ts +248 -0
- package/src/gen/vite-plugin.ts +114 -0
- package/src/generated/managers/testManager.js +45 -0
- package/src/generated/managers/testManager.ts +46 -0
- package/src/hooks/HookRegistry.ts +16 -0
- package/src/hooks/index.ts +15 -0
- 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 +453 -0
- package/src/kernel/EventRouter.ts +23 -0
- package/src/kernel/Kernel.api.ts +79 -0
- package/src/kernel/Kernel.auth.ts +120 -0
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +170 -0
- package/src/kernel/Kernel.registries.ts +237 -0
- package/src/kernel/Kernel.routing.ts +334 -0
- package/src/kernel/Kernel.ts +460 -0
- package/src/kernel/Kernel.types.ts +263 -0
- package/src/kernel/Kernel.vue.ts +304 -0
- 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 +252 -0
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/Module.ts +169 -0
- package/src/kernel/ModuleLoader.ts +645 -0
- package/src/kernel/SSEBridge.ts +11 -0
- package/src/kernel/SignalBus.ts +48 -0
- package/src/kernel/index.ts +56 -0
- package/src/module/index.ts +33 -0
- package/src/module/moduleRegistry.ts +492 -0
- package/src/modules/debug/AuthCollector.ts +501 -0
- package/src/modules/debug/Collector.ts +20 -0
- package/src/modules/debug/DebugBridge.ts +14 -0
- package/src/modules/debug/DebugModule.ts +220 -0
- package/src/modules/debug/EntitiesCollector.ts +839 -0
- package/src/modules/debug/ErrorCollector.ts +4 -0
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +7 -0
- package/src/modules/debug/RouterCollector.ts +449 -0
- package/src/modules/debug/SignalCollector.ts +4 -0
- package/src/modules/debug/ToastCollector.ts +4 -0
- package/src/modules/debug/ZonesCollector.ts +447 -0
- package/src/modules/debug/components/DebugBar.vue +31 -0
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +9 -0
- package/src/modules/debug/components/panels/AuthPanel.vue +267 -0
- package/src/modules/debug/components/panels/EntitiesPanel.vue +572 -0
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +440 -0
- package/src/modules/debug/components/panels/ZonesPanel.vue +133 -0
- package/src/modules/debug/components/panels/index.ts +16 -0
- package/src/modules/debug/index.ts +58 -0
- package/src/modules/debug/styles.scss +2671 -0
- package/src/notifications/NotificationBadge.vue +35 -0
- package/src/notifications/NotificationListener.vue +71 -0
- package/src/notifications/NotificationModule.ts +76 -0
- package/src/notifications/NotificationPanel.vue +478 -0
- package/src/notifications/NotificationStore.ts +288 -0
- package/src/notifications/index.ts +42 -0
- package/src/notifications/styles.scss +32 -0
- package/src/orchestrator/Orchestrator.ts +427 -0
- package/src/orchestrator/index.ts +8 -0
- package/src/orchestrator/useOrchestrator.ts +80 -0
- package/src/plugin.ts +251 -0
- package/src/query/FilterQuery.ts +306 -0
- package/src/query/QueryExecutor.ts +341 -0
- package/src/query/index.ts +16 -0
- package/src/security/EntityRolesProvider.ts +385 -0
- package/src/security/PermissionMatcher.ts +152 -0
- package/src/security/PermissionRegistry.ts +273 -0
- package/src/security/PersistableRolesProvider.ts +584 -0
- package/src/security/RolesManager.ts +136 -0
- package/src/security/RolesProvider.ts +167 -0
- package/src/security/RolesProviderStorage.ts +211 -0
- package/src/security/SecurityModule.ts +94 -0
- package/src/security/StaticRolesProvider.ts +117 -0
- package/src/security/UsersManager.ts +163 -0
- package/src/security/index.ts +77 -0
- package/src/security/pages/RoleForm.vue +276 -0
- package/src/security/pages/RoleList.vue +123 -0
- package/src/styles/_alerts.scss +95 -0
- package/src/styles/_buttons.scss +391 -0
- package/src/styles/_cards.scss +241 -0
- package/src/styles/_code.scss +78 -0
- package/src/styles/_dialogs.scss +55 -0
- package/src/styles/_filter-bar.scss +70 -0
- package/src/styles/_forms.scss +434 -0
- package/src/styles/_helpers.scss +153 -0
- package/src/styles/_lists.scss +225 -0
- package/src/styles/_main.scss +356 -0
- package/src/styles/_markdown.scss +88 -0
- package/src/styles/_responsive.scss +255 -0
- package/src/styles/_show-pages.scss +186 -0
- package/src/styles/_sidebar.scss +380 -0
- package/src/styles/_states.scss +115 -0
- package/src/styles/_stats.scss +47 -0
- package/src/styles/_variables.scss +224 -0
- package/src/styles/index.scss +38 -0
- package/src/styles/theme/_components.scss +331 -0
- package/src/styles/theme/_index.scss +10 -0
- package/src/styles/theme/_tokens.scss +147 -0
- package/src/styles/theme/_utilities.scss +175 -0
- package/src/toast/ToastBridgeModule.ts +68 -0
- package/src/toast/ToastListener.vue +55 -0
- package/src/toast/index.ts +26 -0
- package/src/toast/useSignalToast.ts +154 -0
- package/src/types/index.ts +285 -0
- package/src/types/pluralize.d.ts +19 -0
- package/src/types/quarkernel.d.ts +50 -0
- package/src/utils/formatters.ts +174 -0
- package/src/utils/index.ts +35 -0
- package/src/utils/transformers.ts +131 -0
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +50 -0
- package/src/zones/ZoneRegistry.ts +934 -0
- package/src/zones/index.ts +29 -0
- package/src/zones/zones.ts +191 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { PermissiveAuthAdapter } from './auth/PermissiveAdapter'
|
|
2
|
+
import { AuthActions, type AuthUser } from './auth/EntityAuthAdapter'
|
|
3
|
+
import type { EntityAuthAdapter as IEntityAuthAdapter } from './auth/EntityAuthAdapter'
|
|
4
|
+
import pluralize from 'pluralize'
|
|
5
|
+
import { applyCacheMethods } from './EntityManager.cache'
|
|
6
|
+
import { applyQueryMethods } from './EntityManager.query'
|
|
7
|
+
import { applyRelationsMethods } from './EntityManager.relations'
|
|
8
|
+
import { applyCrudMethods } from './EntityManager.crud'
|
|
9
|
+
import type {
|
|
10
|
+
EntityRecord,
|
|
11
|
+
ListParams,
|
|
12
|
+
ListResult,
|
|
13
|
+
IStorage,
|
|
14
|
+
FieldConfig,
|
|
15
|
+
ChildConfig,
|
|
16
|
+
ParentConfig,
|
|
17
|
+
RelationConfig,
|
|
18
|
+
NavConfig,
|
|
19
|
+
} from '../types'
|
|
20
|
+
import type { SignalBus } from '../kernel/SignalBus'
|
|
21
|
+
import type { HookRegistry } from '../hooks/HookRegistry'
|
|
22
|
+
import type { QueryObject } from '../query/QueryExecutor'
|
|
23
|
+
|
|
24
|
+
// Import types from dedicated types file
|
|
25
|
+
import type {
|
|
26
|
+
CacheState,
|
|
27
|
+
DetailCacheState,
|
|
28
|
+
OperationStats,
|
|
29
|
+
ResolvedStorage,
|
|
30
|
+
StorageResolution,
|
|
31
|
+
Orchestrator,
|
|
32
|
+
} from './EntityManager.types'
|
|
33
|
+
|
|
34
|
+
// Re-export public types from types file
|
|
35
|
+
export type {
|
|
36
|
+
RoutingContext,
|
|
37
|
+
PresaveContext,
|
|
38
|
+
PostsaveContext,
|
|
39
|
+
PredeleteContext,
|
|
40
|
+
QueryOptions,
|
|
41
|
+
WhitelistContext,
|
|
42
|
+
CacheInfo,
|
|
43
|
+
EntityBadge,
|
|
44
|
+
SeverityDescriptor,
|
|
45
|
+
SeverityMapValue,
|
|
46
|
+
SeverityMap,
|
|
47
|
+
EntityManagerOptions,
|
|
48
|
+
} from './EntityManager.types'
|
|
49
|
+
|
|
50
|
+
// Import concrete types needed in method signatures
|
|
51
|
+
import type {
|
|
52
|
+
RoutingContext,
|
|
53
|
+
PresaveContext,
|
|
54
|
+
PostsaveContext,
|
|
55
|
+
PredeleteContext,
|
|
56
|
+
QueryOptions,
|
|
57
|
+
WhitelistContext,
|
|
58
|
+
CacheInfo,
|
|
59
|
+
EntityBadge,
|
|
60
|
+
SeverityMap,
|
|
61
|
+
SeverityDescriptor,
|
|
62
|
+
EntityManagerOptions,
|
|
63
|
+
} from './EntityManager.types'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* EntityManager - Base class for entity CRUD operations
|
|
67
|
+
*
|
|
68
|
+
* An EntityManager can either:
|
|
69
|
+
* 1. BE its own storage (implement methods directly)
|
|
70
|
+
* 2. DELEGATE to a storage adapter
|
|
71
|
+
*/
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
73
|
+
export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
74
|
+
readonly name: string
|
|
75
|
+
storage: IStorage<T> | null
|
|
76
|
+
readonly idField: string
|
|
77
|
+
|
|
78
|
+
protected _labelField: string | ((entity: T) => string)
|
|
79
|
+
protected _badges: ((entity: T) => EntityBadge[]) | null
|
|
80
|
+
protected _label: string | null
|
|
81
|
+
protected _labelPlural: string | null
|
|
82
|
+
protected _routePrefix: string | null
|
|
83
|
+
protected _fields: Record<string, FieldConfig>
|
|
84
|
+
|
|
85
|
+
localFilterThreshold: number | null
|
|
86
|
+
protected _cacheTtlMs: number | null
|
|
87
|
+
protected _asymmetric: boolean
|
|
88
|
+
protected _detailCache: DetailCacheState<T> = { items: new Map() }
|
|
89
|
+
protected _detailCacheTtlMs: number
|
|
90
|
+
protected _detailCacheMaxSize: number
|
|
91
|
+
protected _detailInflight: Map<string, Promise<T>> = new Map()
|
|
92
|
+
protected _readOnly: boolean
|
|
93
|
+
protected _warmup: boolean
|
|
94
|
+
protected _authSensitive: boolean
|
|
95
|
+
protected _system: boolean
|
|
96
|
+
|
|
97
|
+
protected _scopeWhitelist: string[] | null
|
|
98
|
+
protected _isOwn: ((record: T, user: AuthUser) => boolean) | null
|
|
99
|
+
|
|
100
|
+
protected _children: Record<string, ChildConfig>
|
|
101
|
+
protected _parent: ParentConfig | null
|
|
102
|
+
protected _parents: Record<string, ParentConfig>
|
|
103
|
+
protected _relations: Record<string, RelationConfig>
|
|
104
|
+
protected _orchestrator: Orchestrator | null = null
|
|
105
|
+
|
|
106
|
+
protected _authAdapter: IEntityAuthAdapter | null
|
|
107
|
+
protected _nav: NavConfig
|
|
108
|
+
|
|
109
|
+
protected _hooks: HookRegistry | null = null
|
|
110
|
+
protected _severityMaps: Record<string, SeverityMap> = {}
|
|
111
|
+
|
|
112
|
+
protected _cache: CacheState<T> = {
|
|
113
|
+
items: [],
|
|
114
|
+
total: 0,
|
|
115
|
+
loadedAt: null,
|
|
116
|
+
valid: false,
|
|
117
|
+
}
|
|
118
|
+
protected _cacheLoading: Promise<boolean> | null = null
|
|
119
|
+
|
|
120
|
+
protected _signals: SignalBus | null = null
|
|
121
|
+
protected _signalCleanup: (() => void) | null = null
|
|
122
|
+
|
|
123
|
+
protected _stats: OperationStats = {
|
|
124
|
+
list: 0,
|
|
125
|
+
get: 0,
|
|
126
|
+
create: 0,
|
|
127
|
+
update: 0,
|
|
128
|
+
delete: 0,
|
|
129
|
+
cacheHits: 0,
|
|
130
|
+
cacheMisses: 0,
|
|
131
|
+
detailCacheHits: 0,
|
|
132
|
+
detailCacheMisses: 0,
|
|
133
|
+
maxItemsSeen: 0,
|
|
134
|
+
maxTotal: 0,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Hook for query result customization (can be overridden)
|
|
138
|
+
onQueryResult?: (
|
|
139
|
+
result: ListResult<T>,
|
|
140
|
+
context: WhitelistContext
|
|
141
|
+
) => ListResult<T> | void
|
|
142
|
+
|
|
143
|
+
constructor(options: EntityManagerOptions<T> = {} as EntityManagerOptions<T>) {
|
|
144
|
+
const {
|
|
145
|
+
name,
|
|
146
|
+
storage = null,
|
|
147
|
+
idField = 'id',
|
|
148
|
+
label = null,
|
|
149
|
+
labelPlural = null,
|
|
150
|
+
routePrefix = null,
|
|
151
|
+
labelField = 'name',
|
|
152
|
+
badges = null,
|
|
153
|
+
fields = {},
|
|
154
|
+
localFilterThreshold = null,
|
|
155
|
+
cacheTtlMs = null,
|
|
156
|
+
asymmetric = false,
|
|
157
|
+
detailCacheTtlMs = 0,
|
|
158
|
+
detailCacheMaxSize = 0,
|
|
159
|
+
readOnly = false,
|
|
160
|
+
warmup = true,
|
|
161
|
+
authSensitive,
|
|
162
|
+
system = false,
|
|
163
|
+
scopeWhitelist = null,
|
|
164
|
+
isOwn = null,
|
|
165
|
+
children = {},
|
|
166
|
+
parent = null,
|
|
167
|
+
parents = {},
|
|
168
|
+
relations = {},
|
|
169
|
+
authAdapter = null,
|
|
170
|
+
nav = {},
|
|
171
|
+
} = options
|
|
172
|
+
|
|
173
|
+
this.name = name
|
|
174
|
+
this.storage = storage
|
|
175
|
+
this.idField = idField
|
|
176
|
+
this._labelField = labelField
|
|
177
|
+
this._badges = badges
|
|
178
|
+
|
|
179
|
+
this._label = label
|
|
180
|
+
this._labelPlural = labelPlural
|
|
181
|
+
this._routePrefix = routePrefix
|
|
182
|
+
this._fields = fields
|
|
183
|
+
|
|
184
|
+
this.localFilterThreshold = localFilterThreshold
|
|
185
|
+
this._cacheTtlMs = cacheTtlMs
|
|
186
|
+
this._asymmetric = asymmetric
|
|
187
|
+
this._detailCacheTtlMs = detailCacheTtlMs
|
|
188
|
+
this._detailCacheMaxSize = detailCacheMaxSize
|
|
189
|
+
this._readOnly = readOnly
|
|
190
|
+
this._warmup = warmup
|
|
191
|
+
this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
|
|
192
|
+
this._system = system
|
|
193
|
+
|
|
194
|
+
this._scopeWhitelist = scopeWhitelist
|
|
195
|
+
this._isOwn = isOwn
|
|
196
|
+
|
|
197
|
+
this._children = children
|
|
198
|
+
this._parent = parent
|
|
199
|
+
this._parents = parents
|
|
200
|
+
this._relations = relations
|
|
201
|
+
|
|
202
|
+
this._authAdapter = authAdapter
|
|
203
|
+
this._nav = nav
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============ SIGNALS ============
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Set the SignalBus reference (called by Orchestrator during registration)
|
|
210
|
+
*/
|
|
211
|
+
setSignals(signals: SignalBus): void {
|
|
212
|
+
this._signals = signals
|
|
213
|
+
this._setupCacheListeners()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Set the HookRegistry reference (called by Orchestrator during registration)
|
|
218
|
+
*/
|
|
219
|
+
setHooks(hooks: HookRegistry): void {
|
|
220
|
+
this._hooks = hooks
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get the HookRegistry reference
|
|
225
|
+
*/
|
|
226
|
+
get hooks(): HookRegistry | null {
|
|
227
|
+
return this._hooks
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Emit entity signals after CRUD operations
|
|
232
|
+
*/
|
|
233
|
+
protected _emitSignal(
|
|
234
|
+
action: 'created' | 'updated' | 'deleted',
|
|
235
|
+
data: Record<string, unknown>
|
|
236
|
+
): void {
|
|
237
|
+
if (!this._signals) return
|
|
238
|
+
this._signals.emitEntity(this.name, action, data)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Emit entity:data-invalidate signal for client cache invalidation
|
|
243
|
+
*/
|
|
244
|
+
protected _emitDataInvalidate(
|
|
245
|
+
action: 'created' | 'updated' | 'deleted',
|
|
246
|
+
id: string | number | undefined
|
|
247
|
+
): void {
|
|
248
|
+
if (!this._signals) return
|
|
249
|
+
this._signals.emit('entity:data-invalidate', {
|
|
250
|
+
entity: this.name,
|
|
251
|
+
action,
|
|
252
|
+
id,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ============ LIFECYCLE HOOKS ============
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Invoke a lifecycle hook for this entity
|
|
260
|
+
*/
|
|
261
|
+
protected async _invokeHook(
|
|
262
|
+
hookName: string,
|
|
263
|
+
context: Record<string, unknown>
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
if (!this._hooks) return
|
|
266
|
+
await this._hooks.invoke(`entity:${hookName}`, context)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Build hook context for presave operations
|
|
271
|
+
*/
|
|
272
|
+
protected _buildPresaveContext(
|
|
273
|
+
data: Partial<T>,
|
|
274
|
+
isNew: boolean,
|
|
275
|
+
id: string | number | null = null
|
|
276
|
+
): PresaveContext<T> {
|
|
277
|
+
const context: PresaveContext<T> = {
|
|
278
|
+
entity: this.name,
|
|
279
|
+
record: data,
|
|
280
|
+
isNew,
|
|
281
|
+
manager: this,
|
|
282
|
+
}
|
|
283
|
+
if (!isNew && id !== null) {
|
|
284
|
+
context.id = id
|
|
285
|
+
}
|
|
286
|
+
return context
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Build hook context for postsave operations
|
|
291
|
+
*/
|
|
292
|
+
protected _buildPostsaveContext(
|
|
293
|
+
data: Partial<T>,
|
|
294
|
+
result: T,
|
|
295
|
+
isNew: boolean,
|
|
296
|
+
id: string | number | null = null
|
|
297
|
+
): PostsaveContext<T> {
|
|
298
|
+
const context: PostsaveContext<T> = {
|
|
299
|
+
entity: this.name,
|
|
300
|
+
record: data,
|
|
301
|
+
result,
|
|
302
|
+
isNew,
|
|
303
|
+
manager: this,
|
|
304
|
+
}
|
|
305
|
+
if (id !== null) {
|
|
306
|
+
context.id = id
|
|
307
|
+
} else if (result?.[this.idField] !== undefined) {
|
|
308
|
+
context.id = result[this.idField] as string | number
|
|
309
|
+
}
|
|
310
|
+
return context
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Build hook context for predelete operations
|
|
315
|
+
*/
|
|
316
|
+
protected _buildPredeleteContext(id: string | number): PredeleteContext<T> {
|
|
317
|
+
return {
|
|
318
|
+
entity: this.name,
|
|
319
|
+
id,
|
|
320
|
+
manager: this,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ============ STORAGE RESOLUTION ============
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Resolve which storage to use for an operation
|
|
328
|
+
*/
|
|
329
|
+
resolveStorage(
|
|
330
|
+
_method: string,
|
|
331
|
+
_context?: RoutingContext
|
|
332
|
+
): StorageResolution<T> {
|
|
333
|
+
return { storage: this.storage }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Normalize resolveStorage() return value to standard format
|
|
338
|
+
*/
|
|
339
|
+
protected _normalizeResolveResult(
|
|
340
|
+
result: StorageResolution<T>,
|
|
341
|
+
context?: RoutingContext
|
|
342
|
+
): ResolvedStorage<T> {
|
|
343
|
+
// Function = dynamic endpoint builder
|
|
344
|
+
if (typeof result === 'function') {
|
|
345
|
+
return {
|
|
346
|
+
storage: this.storage,
|
|
347
|
+
endpoint: result(context),
|
|
348
|
+
isDynamic: true,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// String = endpoint with primary storage
|
|
353
|
+
if (typeof result === 'string') {
|
|
354
|
+
return { storage: this.storage, endpoint: result }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Null/undefined = primary storage
|
|
358
|
+
if (!result) {
|
|
359
|
+
return { storage: this.storage }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Object with endpoint function = dynamic endpoint builder
|
|
363
|
+
if (typeof result.endpoint === 'function') {
|
|
364
|
+
return {
|
|
365
|
+
storage: result.storage || this.storage,
|
|
366
|
+
endpoint: result.endpoint(context),
|
|
367
|
+
params: result.params,
|
|
368
|
+
isDynamic: true,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Object without storage = use primary storage
|
|
373
|
+
if (!result.storage) {
|
|
374
|
+
return {
|
|
375
|
+
storage: this.storage,
|
|
376
|
+
endpoint: result.endpoint as string | undefined,
|
|
377
|
+
params: result.params,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
storage: result.storage,
|
|
383
|
+
endpoint: result.endpoint as string | undefined,
|
|
384
|
+
params: result.params,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ============ METADATA ACCESSORS ============
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get entity label (singular)
|
|
392
|
+
*/
|
|
393
|
+
get label(): string {
|
|
394
|
+
if (this._label) return this._label
|
|
395
|
+
if (!this.name) return 'Item'
|
|
396
|
+
const singular = pluralize.singular(this.name)
|
|
397
|
+
return singular.charAt(0).toUpperCase() + singular.slice(1)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Get entity label (plural)
|
|
402
|
+
*/
|
|
403
|
+
get labelPlural(): string {
|
|
404
|
+
if (this._labelPlural) return this._labelPlural
|
|
405
|
+
if (!this.name) return 'Items'
|
|
406
|
+
const plural = pluralize.plural(this.name)
|
|
407
|
+
return plural.charAt(0).toUpperCase() + plural.slice(1)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Get route prefix for this entity
|
|
412
|
+
*/
|
|
413
|
+
get routePrefix(): string {
|
|
414
|
+
if (this._routePrefix) return this._routePrefix
|
|
415
|
+
if (!this.name) return 'item'
|
|
416
|
+
return pluralize.singular(this.name)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get labelField config (string or function)
|
|
421
|
+
*/
|
|
422
|
+
get labelField(): string | ((entity: T) => string) {
|
|
423
|
+
return this._labelField
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Get display label for an entity
|
|
428
|
+
*/
|
|
429
|
+
getEntityLabel(entity: T | null): string | null {
|
|
430
|
+
if (!entity) return null
|
|
431
|
+
if (typeof this._labelField === 'function') {
|
|
432
|
+
return this._labelField(entity)
|
|
433
|
+
}
|
|
434
|
+
return (entity[this._labelField] as string) || null
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Get badges for an entity item header
|
|
439
|
+
*/
|
|
440
|
+
getEntityBadges(entity: T | null): EntityBadge[] {
|
|
441
|
+
if (!entity || !this._badges) return []
|
|
442
|
+
return this._badges(entity)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get navigation config for auto-generated menus
|
|
447
|
+
*/
|
|
448
|
+
get nav(): NavConfig {
|
|
449
|
+
return this._nav || {}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ============ PERMISSIONS ============
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Get the auth adapter (lazy-initialized PermissiveAuthAdapter if not set)
|
|
456
|
+
*/
|
|
457
|
+
get authAdapter(): IEntityAuthAdapter {
|
|
458
|
+
if (!this._authAdapter) {
|
|
459
|
+
this._authAdapter = new PermissiveAuthAdapter()
|
|
460
|
+
}
|
|
461
|
+
return this._authAdapter
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Set the auth adapter
|
|
466
|
+
*/
|
|
467
|
+
set authAdapter(adapter: IEntityAuthAdapter | null) {
|
|
468
|
+
this._authAdapter = adapter
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Build permission string for an entity action
|
|
473
|
+
*/
|
|
474
|
+
protected _getPermissionString(action: string): string {
|
|
475
|
+
return `entity:${this.name}:${action}`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Get the current authenticated user
|
|
480
|
+
*/
|
|
481
|
+
protected _getCurrentUser(): AuthUser | null {
|
|
482
|
+
// Try authAdapter.getCurrentUser() first (EntityAuthAdapter subclass)
|
|
483
|
+
if (typeof this.authAdapter?.getCurrentUser === 'function') {
|
|
484
|
+
try {
|
|
485
|
+
return this.authAdapter.getCurrentUser() ?? null
|
|
486
|
+
} catch {
|
|
487
|
+
// Fallback if not implemented
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Fallback to kernel's authAdapter
|
|
491
|
+
return this._orchestrator?.kernel?.options?.authAdapter?.getUser?.() ?? null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Check if SecurityChecker is configured via authAdapter
|
|
496
|
+
*/
|
|
497
|
+
protected _hasSecurityChecker(): boolean {
|
|
498
|
+
return (
|
|
499
|
+
(this.authAdapter as unknown as { _securityChecker?: unknown })
|
|
500
|
+
?._securityChecker != null
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Check if the current user can perform an action, optionally on a specific record
|
|
506
|
+
*/
|
|
507
|
+
canAccess(action: string, record: T | null = null): boolean {
|
|
508
|
+
// 1. Check readOnly restriction for write actions
|
|
509
|
+
if (
|
|
510
|
+
this._readOnly &&
|
|
511
|
+
action !== AuthActions.READ &&
|
|
512
|
+
action !== AuthActions.LIST
|
|
513
|
+
) {
|
|
514
|
+
return false
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 2. Ownership check: if user owns the record, check entity-own permission
|
|
518
|
+
if (record && this._isOwn && this._hasSecurityChecker()) {
|
|
519
|
+
const user = this._getCurrentUser()
|
|
520
|
+
if (user && this._isOwn(record, user)) {
|
|
521
|
+
// Owner - check entity-own:{entity}:{action} permission
|
|
522
|
+
const ownPerm = `entity-own:${this.name}:${action}`
|
|
523
|
+
if (this.authAdapter.isGranted?.(ownPerm, record)) {
|
|
524
|
+
return true
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 3. Use isGranted() with entity:name:action format when available
|
|
530
|
+
if (this._hasSecurityChecker()) {
|
|
531
|
+
const perm = this._getPermissionString(action)
|
|
532
|
+
return this.authAdapter.isGranted?.(perm, record) ?? false
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 4. Legacy fallback: canPerform() + canAccessRecord()
|
|
536
|
+
const canPerformAction = this.authAdapter.canPerform(this.name, action)
|
|
537
|
+
if (!canPerformAction) {
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 5. Silo check: if record provided, can user access this specific record?
|
|
542
|
+
if (record !== null) {
|
|
543
|
+
return this.authAdapter.canAccessRecord?.(this.name, record) ?? true
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Check if user can read entities
|
|
551
|
+
*/
|
|
552
|
+
canRead(entity: T | null = null): boolean {
|
|
553
|
+
return this.canAccess(AuthActions.READ, entity)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Check if manager is read-only
|
|
558
|
+
*/
|
|
559
|
+
get readOnly(): boolean {
|
|
560
|
+
return this._readOnly
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Check if entity is system-provided (roles, users)
|
|
565
|
+
*/
|
|
566
|
+
get system(): boolean {
|
|
567
|
+
return this._system
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Check if user can create new entities
|
|
572
|
+
*/
|
|
573
|
+
canCreate(): boolean {
|
|
574
|
+
return this.canAccess(AuthActions.CREATE)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Check if user can update entities
|
|
579
|
+
*/
|
|
580
|
+
canUpdate(entity: T | null = null): boolean {
|
|
581
|
+
return this.canAccess(AuthActions.UPDATE, entity)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Check if user can delete entities
|
|
586
|
+
*/
|
|
587
|
+
canDelete(entity: T | null = null): boolean {
|
|
588
|
+
return this.canAccess(AuthActions.DELETE, entity)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Check if user can list entities
|
|
593
|
+
*/
|
|
594
|
+
canList(): boolean {
|
|
595
|
+
return this.canAccess(AuthActions.LIST)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get scope whitelist
|
|
600
|
+
*/
|
|
601
|
+
get scopeWhitelist(): string[] | null {
|
|
602
|
+
return this._scopeWhitelist
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Check if a context is whitelisted (can bypass restrictions)
|
|
607
|
+
*/
|
|
608
|
+
isWhitelisted(context: WhitelistContext = {}): boolean {
|
|
609
|
+
// Explicit bypass with reason
|
|
610
|
+
if (context.bypassPermissions && context.reason) {
|
|
611
|
+
return true
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// No whitelist configured = nothing whitelisted
|
|
615
|
+
if (!this._scopeWhitelist || this._scopeWhitelist.length === 0) {
|
|
616
|
+
return false
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Check if any context property matches the whitelist
|
|
620
|
+
const checkValues = [context.module, context.scope, context.form].filter(
|
|
621
|
+
Boolean
|
|
622
|
+
) as string[]
|
|
623
|
+
return checkValues.some((val) => this._scopeWhitelist!.includes(val))
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Get fields schema
|
|
628
|
+
*/
|
|
629
|
+
get fields(): Record<string, FieldConfig> {
|
|
630
|
+
return this._fields
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Get a specific field config
|
|
635
|
+
*/
|
|
636
|
+
getFieldConfig(fieldName: string): FieldConfig | undefined {
|
|
637
|
+
return this._fields[fieldName]
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Get initial data for a new entity based on field defaults
|
|
642
|
+
*/
|
|
643
|
+
getInitialData(context: RoutingContext | null = null): Partial<T> {
|
|
644
|
+
const data: Record<string, unknown> = {}
|
|
645
|
+
for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
|
|
646
|
+
if (fieldConfig.default !== undefined) {
|
|
647
|
+
data[fieldName] =
|
|
648
|
+
typeof fieldConfig.default === 'function'
|
|
649
|
+
? (fieldConfig.default as (ctx: RoutingContext | null) => unknown)(
|
|
650
|
+
context
|
|
651
|
+
)
|
|
652
|
+
: fieldConfig.default
|
|
653
|
+
} else {
|
|
654
|
+
// Type-based defaults
|
|
655
|
+
switch (fieldConfig.type) {
|
|
656
|
+
case 'boolean':
|
|
657
|
+
data[fieldName] = false
|
|
658
|
+
break
|
|
659
|
+
case 'number':
|
|
660
|
+
data[fieldName] = null
|
|
661
|
+
break
|
|
662
|
+
case 'array':
|
|
663
|
+
data[fieldName] = []
|
|
664
|
+
break
|
|
665
|
+
case 'object':
|
|
666
|
+
data[fieldName] = {}
|
|
667
|
+
break
|
|
668
|
+
default:
|
|
669
|
+
data[fieldName] = ''
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return data as Partial<T>
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Apply field defaults to data (for create operations)
|
|
678
|
+
*/
|
|
679
|
+
applyDefaults(
|
|
680
|
+
data: Partial<T>,
|
|
681
|
+
context: RoutingContext | null = null
|
|
682
|
+
): Partial<T> {
|
|
683
|
+
const result = { ...data } as Record<string, unknown>
|
|
684
|
+
for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
|
|
685
|
+
// Only apply default if field is undefined in data
|
|
686
|
+
if (
|
|
687
|
+
result[fieldName] === undefined &&
|
|
688
|
+
fieldConfig.default !== undefined
|
|
689
|
+
) {
|
|
690
|
+
result[fieldName] =
|
|
691
|
+
typeof fieldConfig.default === 'function'
|
|
692
|
+
? (fieldConfig.default as (ctx: RoutingContext | null) => unknown)(
|
|
693
|
+
context
|
|
694
|
+
)
|
|
695
|
+
: fieldConfig.default
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return result as Partial<T>
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Get field names that are required
|
|
703
|
+
*/
|
|
704
|
+
getRequiredFields(): string[] {
|
|
705
|
+
return Object.entries(this._fields)
|
|
706
|
+
.filter(([, config]) => config.required)
|
|
707
|
+
.map(([name]) => name)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Get fields that should appear in list view
|
|
712
|
+
*/
|
|
713
|
+
getListFields(): Array<{ name: string } & FieldConfig> {
|
|
714
|
+
return Object.entries(this._fields)
|
|
715
|
+
.filter(([, config]) => config.listable !== false)
|
|
716
|
+
.map(([name, config]) => ({ name, ...config }))
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Get fields that should appear in form view
|
|
721
|
+
*/
|
|
722
|
+
getFormFields(): Array<{ name: string } & FieldConfig> {
|
|
723
|
+
return Object.entries(this._fields)
|
|
724
|
+
.filter(([, config]) => config.editable !== false)
|
|
725
|
+
.map(([name, config]) => ({ name, ...config }))
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ============ REFERENCE OPTIONS ============
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Resolve reference options for a field
|
|
732
|
+
*/
|
|
733
|
+
async resolveReferenceOptions(
|
|
734
|
+
fieldName: string
|
|
735
|
+
): Promise<Array<{ label: string; value: unknown }>> {
|
|
736
|
+
const fieldConfig = this._fields[fieldName]
|
|
737
|
+
if (!fieldConfig) {
|
|
738
|
+
console.warn(`[EntityManager:${this.name}] Unknown field '${fieldName}'`)
|
|
739
|
+
return []
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// If field has static options, return them
|
|
743
|
+
if (fieldConfig.options && !fieldConfig.reference) {
|
|
744
|
+
return fieldConfig.options
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// If no reference, return empty
|
|
748
|
+
if (!fieldConfig.reference) {
|
|
749
|
+
return []
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Need orchestrator to access other managers
|
|
753
|
+
if (!this._orchestrator) {
|
|
754
|
+
console.warn(
|
|
755
|
+
`[EntityManager:${this.name}] No orchestrator, cannot resolve reference for '${fieldName}'`
|
|
756
|
+
)
|
|
757
|
+
return fieldConfig.options || []
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const { entity, labelField, valueField } = fieldConfig.reference
|
|
761
|
+
const refManager = this._orchestrator.get(entity)
|
|
762
|
+
|
|
763
|
+
if (!refManager) {
|
|
764
|
+
console.warn(
|
|
765
|
+
`[EntityManager:${this.name}] Referenced entity '${entity}' not found`
|
|
766
|
+
)
|
|
767
|
+
return fieldConfig.options || []
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
// Fetch all items from referenced entity
|
|
772
|
+
const { items } = await refManager.list({ page_size: 1000 })
|
|
773
|
+
|
|
774
|
+
// Build options array
|
|
775
|
+
const refLabelField = labelField || refManager.labelField || 'label'
|
|
776
|
+
const refValueField = valueField || refManager.idField || 'id'
|
|
777
|
+
|
|
778
|
+
return items.map((item) => ({
|
|
779
|
+
label:
|
|
780
|
+
(item[refLabelField as keyof typeof item] as string) ??
|
|
781
|
+
(item[refValueField as keyof typeof item] as string),
|
|
782
|
+
value: item[refValueField as keyof typeof item],
|
|
783
|
+
}))
|
|
784
|
+
} catch (error) {
|
|
785
|
+
console.error(
|
|
786
|
+
`[EntityManager:${this.name}] Failed to resolve reference for '${fieldName}':`,
|
|
787
|
+
error
|
|
788
|
+
)
|
|
789
|
+
return fieldConfig.options || []
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Resolve all reference options for form fields
|
|
795
|
+
*/
|
|
796
|
+
async resolveAllReferenceOptions(): Promise<
|
|
797
|
+
Map<string, Array<{ label: string; value: unknown }>>
|
|
798
|
+
> {
|
|
799
|
+
const optionsMap = new Map<string, Array<{ label: string; value: unknown }>>()
|
|
800
|
+
|
|
801
|
+
for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
|
|
802
|
+
if (fieldConfig.reference) {
|
|
803
|
+
const options = await this.resolveReferenceOptions(fieldName)
|
|
804
|
+
optionsMap.set(fieldName, options)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return optionsMap
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ============ SEVERITY MAPS ============
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Set severity map for a field.
|
|
815
|
+
* Values can be plain severity strings or rich descriptors with icon/label.
|
|
816
|
+
*/
|
|
817
|
+
setSeverityMap(field: string, map: SeverityMap): this {
|
|
818
|
+
this._severityMaps[field] = map
|
|
819
|
+
return this
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Get severity string for a field value (backward compat).
|
|
824
|
+
* Extracts .severity from descriptors automatically.
|
|
825
|
+
*/
|
|
826
|
+
getSeverity(
|
|
827
|
+
field: string,
|
|
828
|
+
value: string | number,
|
|
829
|
+
defaultSeverity: string = 'secondary'
|
|
830
|
+
): string {
|
|
831
|
+
const entry = this._severityMaps[field]?.[String(value)]
|
|
832
|
+
if (!entry) return defaultSeverity
|
|
833
|
+
return typeof entry === 'string' ? entry : entry.severity
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Get full severity descriptor for a field value.
|
|
838
|
+
* Returns normalized descriptor (plain strings wrapped as { severity }).
|
|
839
|
+
*/
|
|
840
|
+
getSeverityDescriptor(
|
|
841
|
+
field: string,
|
|
842
|
+
value: string | number,
|
|
843
|
+
defaultSeverity: string = 'secondary'
|
|
844
|
+
): SeverityDescriptor {
|
|
845
|
+
const entry = this._severityMaps[field]?.[String(value)]
|
|
846
|
+
if (!entry) return { severity: defaultSeverity }
|
|
847
|
+
return typeof entry === 'string' ? { severity: entry } : entry
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Check if a severity map exists for a field
|
|
852
|
+
*/
|
|
853
|
+
hasSeverityMap(field: string): boolean {
|
|
854
|
+
return field in this._severityMaps
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Hook: called when orchestrator is disposed
|
|
859
|
+
*/
|
|
860
|
+
onDispose(): void {
|
|
861
|
+
// Override in subclass if needed
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ============ INTERFACE MERGING ============
|
|
866
|
+
// Declares prototype-patched methods for type safety.
|
|
867
|
+
|
|
868
|
+
export interface EntityManager<T extends EntityRecord = EntityRecord> {
|
|
869
|
+
// --- Cache getters ---
|
|
870
|
+
readonly effectiveThreshold: number
|
|
871
|
+
readonly effectiveCacheTtlMs: number
|
|
872
|
+
readonly isCacheTtlDisabled: boolean
|
|
873
|
+
readonly storageSupportsTotal: boolean
|
|
874
|
+
readonly storageSearchFields: string[] | undefined
|
|
875
|
+
readonly isCacheEnabled: boolean
|
|
876
|
+
readonly overflow: boolean
|
|
877
|
+
readonly warmupEnabled: boolean
|
|
878
|
+
readonly isAsymmetric: boolean
|
|
879
|
+
readonly effectiveDetailCacheTtlMs: number
|
|
880
|
+
readonly effectiveDetailCacheMaxSize: number
|
|
881
|
+
readonly isDetailCacheEnabled: boolean
|
|
882
|
+
|
|
883
|
+
// --- Cache methods ---
|
|
884
|
+
/** @internal */ _isCacheExpired(): boolean
|
|
885
|
+
/** @internal */ _isDetailCacheEntryExpired(loadedAt: number): boolean
|
|
886
|
+
/** @internal */ _evictDetailCache(): void
|
|
887
|
+
invalidateDetailCache(): void
|
|
888
|
+
/** @internal */ _getStorageRequiresAuth(): boolean
|
|
889
|
+
/** @internal */ _parseSearchFields(overrideSearchFields?: string[] | null): { ownFields: string[]; parentFields: Record<string, string[]> }
|
|
890
|
+
/** @internal */ _resolveSearchFields(items: T[]): Promise<void>
|
|
891
|
+
/** @internal */ _setupCacheListeners(): void
|
|
892
|
+
/** @internal */ _clearSearchCache(): void
|
|
893
|
+
/** @internal */ _loadCacheInBackground(): void
|
|
894
|
+
/** @internal */ _loadCache(): Promise<boolean>
|
|
895
|
+
invalidateCache(): void
|
|
896
|
+
invalidateDataLayer(): void
|
|
897
|
+
ensureCache(): Promise<boolean>
|
|
898
|
+
warmup(): Promise<boolean | null>
|
|
899
|
+
getCacheInfo(): CacheInfo
|
|
900
|
+
|
|
901
|
+
// --- Query methods ---
|
|
902
|
+
/** @internal */ _buildQuery(filters: Record<string, unknown>): QueryObject
|
|
903
|
+
/** @internal */ _filterLocally(items: T[], params?: ListParams & { searchFields?: string[] | null }): { items: T[]; total: number }
|
|
904
|
+
query(params?: ListParams, options?: QueryOptions): Promise<ListResult<T>>
|
|
905
|
+
getStats(): OperationStats
|
|
906
|
+
resetStats(): void
|
|
907
|
+
|
|
908
|
+
// --- CRUD methods ---
|
|
909
|
+
list(params?: ListParams & { _internal?: boolean }, context?: RoutingContext): Promise<ListResult<T>>
|
|
910
|
+
get(id: string | number, context?: RoutingContext): Promise<T>
|
|
911
|
+
getMany(ids: Array<string | number>, context?: RoutingContext): Promise<T[]>
|
|
912
|
+
create(data: Partial<T>, context?: RoutingContext): Promise<T>
|
|
913
|
+
update(id: string | number, data: Partial<T>, context?: RoutingContext): Promise<T>
|
|
914
|
+
patch(id: string | number, data: Partial<T>, context?: RoutingContext): Promise<T>
|
|
915
|
+
delete(id: string | number, context?: RoutingContext): Promise<void>
|
|
916
|
+
request(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, options?: { data?: unknown; params?: Record<string, unknown>; headers?: Record<string, string>; invalidateCache?: boolean }): Promise<unknown>
|
|
917
|
+
onRegister(orchestrator: Orchestrator): void
|
|
918
|
+
|
|
919
|
+
// --- Relations methods ---
|
|
920
|
+
getChildConfig(childName: string): ChildConfig | undefined
|
|
921
|
+
getChildNames(): string[]
|
|
922
|
+
getParentConfig(): ParentConfig | null
|
|
923
|
+
listChildren(parentId: string | number, childName: string, params?: ListParams): Promise<ListResult<EntityRecord>>
|
|
924
|
+
getChild(parentId: string | number, childName: string, childId: string | number): Promise<EntityRecord>
|
|
925
|
+
createChild(parentId: string | number, childName: string, data: Record<string, unknown>): Promise<EntityRecord>
|
|
926
|
+
deleteChild(parentId: string | number, childName: string, childId: string | number): Promise<void>
|
|
927
|
+
getParentManager(): EntityManager<EntityRecord> | null
|
|
928
|
+
getChildManager(childName: string): EntityManager<EntityRecord> | null
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ============ APPLY PROTOTYPE PATCHES ============
|
|
932
|
+
applyCacheMethods(EntityManager)
|
|
933
|
+
applyQueryMethods(EntityManager)
|
|
934
|
+
applyRelationsMethods(EntityManager)
|
|
935
|
+
applyCrudMethods(EntityManager)
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Factory function to create an EntityManager
|
|
939
|
+
*/
|
|
940
|
+
export function createEntityManager<T extends EntityRecord = EntityRecord>(
|
|
941
|
+
options: EntityManagerOptions<T>
|
|
942
|
+
): EntityManager<T> {
|
|
943
|
+
return new EntityManager<T>(options)
|
|
944
|
+
}
|