@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,615 @@
|
|
|
1
|
+
import type { ListParams, ListResult } from '../types'
|
|
2
|
+
import type { EntityManagerInternal, Orchestrator, RoutingContext } from './EntityManager.types'
|
|
3
|
+
|
|
4
|
+
type Self = EntityManagerInternal<any>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* API Response Contract for Caching
|
|
8
|
+
* ==================================
|
|
9
|
+
*
|
|
10
|
+
* The storage/API must return responses in this format:
|
|
11
|
+
*
|
|
12
|
+
* {
|
|
13
|
+
* items: T[], // Items for current page/request
|
|
14
|
+
* total: number // TOTAL count of ALL items matching the query (not just this page)
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Why `total` is critical:
|
|
18
|
+
* ------------------------
|
|
19
|
+
* The `total` field enables the smart caching mechanism:
|
|
20
|
+
*
|
|
21
|
+
* 1. Cache eligibility: if `total <= localFilterThreshold`, EntityManager will cache all items
|
|
22
|
+
* 2. Cache validation: `items.length >= total` confirms we received the complete dataset
|
|
23
|
+
* 3. Background loading: if `items.length < total` but `total <= threshold`, loads all items in background
|
|
24
|
+
*
|
|
25
|
+
* Once cached, all filtering/sorting/pagination happens locally via QueryExecutor (MongoDB-like syntax).
|
|
26
|
+
* This dramatically reduces API calls for small datasets.
|
|
27
|
+
*
|
|
28
|
+
* Common pitfalls:
|
|
29
|
+
* ----------------
|
|
30
|
+
* - `total` missing or 0: falls back to `items.length`, may create incomplete cache
|
|
31
|
+
* - `total` incorrect (e.g., always returns page size): cache thinks it's complete when it's not
|
|
32
|
+
* - `total` always equals real total (ignoring filters): cache works but may overflow threshold
|
|
33
|
+
*
|
|
34
|
+
* Example:
|
|
35
|
+
* --------
|
|
36
|
+
* // Good: API returns correct total
|
|
37
|
+
* GET /api/users?page=1&page_size=20
|
|
38
|
+
* → { items: [...20 users...], total: 45 } // 45 users exist, got first 20
|
|
39
|
+
*
|
|
40
|
+
* // EntityManager sees: total(45) <= threshold(100), items(20) < total(45)
|
|
41
|
+
* // → Triggers background load of all 45 users for cache
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Patch EntityManager prototype with CRUD methods.
|
|
46
|
+
*/
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
export function applyCrudMethods(EntityManagerClass: { prototype: any }): void {
|
|
49
|
+
const proto = EntityManagerClass.prototype as Self
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List entities with pagination/filtering
|
|
53
|
+
*/
|
|
54
|
+
proto.list = async function (
|
|
55
|
+
this: Self,
|
|
56
|
+
params: ListParams & { _internal?: boolean } = {},
|
|
57
|
+
context?: RoutingContext
|
|
58
|
+
): Promise<ListResult<any>> {
|
|
59
|
+
const resolved = this._normalizeResolveResult(
|
|
60
|
+
this.resolveStorage('list', context),
|
|
61
|
+
context
|
|
62
|
+
)
|
|
63
|
+
const { storage, endpoint, params: resolvedParams } = resolved
|
|
64
|
+
if (!storage) {
|
|
65
|
+
throw new Error(`[EntityManager:${this.name}] list() not implemented`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract internal flag and cacheSafe flag
|
|
69
|
+
const { _internal = false, cacheSafe = false, ...queryParams } = params
|
|
70
|
+
|
|
71
|
+
// Merge resolved params (defaults) with query params (overrides)
|
|
72
|
+
const mergedParams = resolvedParams
|
|
73
|
+
? { ...resolvedParams, ...queryParams }
|
|
74
|
+
: queryParams
|
|
75
|
+
|
|
76
|
+
const stats = this._stats
|
|
77
|
+
const cache = this._cache
|
|
78
|
+
|
|
79
|
+
// Only count stats for non-internal operations
|
|
80
|
+
if (!_internal) {
|
|
81
|
+
stats.list++
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const hasFilters =
|
|
85
|
+
mergedParams.search ||
|
|
86
|
+
Object.keys(mergedParams.filters || {}).length > 0
|
|
87
|
+
// Disable cache when resolveStorage provides an endpoint override:
|
|
88
|
+
// the endpoint varies by routing context (e.g. parent chain), so
|
|
89
|
+
// caching would return stale data from a different context.
|
|
90
|
+
const canUseCache = (!hasFilters || cacheSafe) && !endpoint
|
|
91
|
+
|
|
92
|
+
// Check TTL expiration before using cache
|
|
93
|
+
if (this._isCacheExpired()) {
|
|
94
|
+
this.invalidateCache()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 1. Cache valid + cacheable -> use cache with local filtering
|
|
98
|
+
if (cache.valid && canUseCache) {
|
|
99
|
+
if (!_internal) stats.cacheHits++
|
|
100
|
+
console.log('[cache] Using local cache for entity:', this.name)
|
|
101
|
+
const filtered = this._filterLocally(cache.items, mergedParams)
|
|
102
|
+
// Update max stats
|
|
103
|
+
if (filtered.items.length > stats.maxItemsSeen) {
|
|
104
|
+
stats.maxItemsSeen = filtered.items.length
|
|
105
|
+
}
|
|
106
|
+
if (filtered.total > stats.maxTotal) {
|
|
107
|
+
stats.maxTotal = filtered.total
|
|
108
|
+
}
|
|
109
|
+
return { ...filtered, fromCache: true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!_internal) stats.cacheMisses++
|
|
113
|
+
|
|
114
|
+
// 2. Fetch from API
|
|
115
|
+
let response: { items?: any[]; data?: any[] | { items?: any[]; data?: any[] }; total?: number; pagination?: { total?: number } }
|
|
116
|
+
if (endpoint && storage.request) {
|
|
117
|
+
// Use request() with endpoint for multi-storage routing.
|
|
118
|
+
// When using a dynamic endpoint, parent IDs are already encoded in the URL.
|
|
119
|
+
// Strip parent-related filters to avoid duplication in query params.
|
|
120
|
+
let requestParams = mergedParams
|
|
121
|
+
if (context?.parentChain && mergedParams.filters) {
|
|
122
|
+
const parentIds = new Set(
|
|
123
|
+
context.parentChain.map((p: { id: string | number }) => String(p.id))
|
|
124
|
+
)
|
|
125
|
+
const cleaned = Object.fromEntries(
|
|
126
|
+
Object.entries(mergedParams.filters as Record<string, unknown>).filter(
|
|
127
|
+
([, value]) => !parentIds.has(String(value))
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
requestParams = { ...mergedParams }
|
|
131
|
+
if (Object.keys(cleaned).length > 0) {
|
|
132
|
+
requestParams.filters = cleaned
|
|
133
|
+
} else {
|
|
134
|
+
delete requestParams.filters
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const apiResponse = (await storage.request('GET', endpoint, {
|
|
138
|
+
params: requestParams,
|
|
139
|
+
context,
|
|
140
|
+
})) as { data?: unknown }
|
|
141
|
+
// Normalize response
|
|
142
|
+
const data = apiResponse.data ?? apiResponse
|
|
143
|
+
const dataObj = data as {
|
|
144
|
+
items?: any[]
|
|
145
|
+
data?: any[]
|
|
146
|
+
total?: number
|
|
147
|
+
pagination?: { total?: number }
|
|
148
|
+
}
|
|
149
|
+
response = {
|
|
150
|
+
items: Array.isArray(data)
|
|
151
|
+
? (data as any[])
|
|
152
|
+
: dataObj.items || dataObj.data || [],
|
|
153
|
+
total:
|
|
154
|
+
dataObj.total ??
|
|
155
|
+
dataObj.pagination?.total ??
|
|
156
|
+
(Array.isArray(data) ? (data as any[]).length : 0),
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
// Standard storage.list() (normalizes response to { items, total })
|
|
160
|
+
response = await storage.list(mergedParams, context)
|
|
161
|
+
}
|
|
162
|
+
const items = response.items || []
|
|
163
|
+
const total = response.total ?? items.length
|
|
164
|
+
|
|
165
|
+
// Update max stats
|
|
166
|
+
if (items.length > stats.maxItemsSeen) {
|
|
167
|
+
stats.maxItemsSeen = items.length
|
|
168
|
+
}
|
|
169
|
+
if (total > stats.maxTotal) {
|
|
170
|
+
stats.maxTotal = total
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 3. Fill cache opportunistically
|
|
174
|
+
if (canUseCache && this.isCacheEnabled && total <= this.effectiveThreshold) {
|
|
175
|
+
// Only cache if we received ALL items (not a partial page)
|
|
176
|
+
if (items.length >= total) {
|
|
177
|
+
cache.items = items
|
|
178
|
+
cache.total = total
|
|
179
|
+
cache.valid = true
|
|
180
|
+
cache.loadedAt = Date.now()
|
|
181
|
+
// Resolve parent fields for search (book.title, user.username, etc.)
|
|
182
|
+
await this._resolveSearchFields(items)
|
|
183
|
+
}
|
|
184
|
+
// If we got partial results but total fits threshold, load all items for cache
|
|
185
|
+
else if (!this._cacheLoading) {
|
|
186
|
+
// Fire-and-forget: load full cache in background
|
|
187
|
+
this._loadCacheInBackground()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { items, total, fromCache: false }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get a single entity by ID
|
|
196
|
+
*/
|
|
197
|
+
proto.get = async function (
|
|
198
|
+
this: Self,
|
|
199
|
+
id: string | number,
|
|
200
|
+
context?: RoutingContext
|
|
201
|
+
): Promise<any> {
|
|
202
|
+
const { storage, endpoint } = this._normalizeResolveResult(
|
|
203
|
+
this.resolveStorage('get', context),
|
|
204
|
+
context
|
|
205
|
+
)
|
|
206
|
+
const stats = this._stats
|
|
207
|
+
stats.get++
|
|
208
|
+
|
|
209
|
+
// Check TTL expiration before using cache
|
|
210
|
+
if (this._isCacheExpired()) {
|
|
211
|
+
this.invalidateCache()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const cache = this._cache
|
|
215
|
+
const idStr = String(id)
|
|
216
|
+
|
|
217
|
+
// ── Asymmetric mode: skip list cache, use detail cache ──
|
|
218
|
+
if (this.isAsymmetric) {
|
|
219
|
+
// Check detail cache first
|
|
220
|
+
if (this.isDetailCacheEnabled) {
|
|
221
|
+
const entry = this._detailCache.items.get(idStr)
|
|
222
|
+
if (entry && !this._isDetailCacheEntryExpired(entry.loadedAt)) {
|
|
223
|
+
stats.detailCacheHits++
|
|
224
|
+
return { ...entry.item }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Deduplicate concurrent get() calls for the same ID
|
|
229
|
+
const inflight = this._detailInflight.get(idStr)
|
|
230
|
+
if (inflight) {
|
|
231
|
+
stats.detailCacheHits++
|
|
232
|
+
const result = await inflight
|
|
233
|
+
return { ...result }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fetch from storage
|
|
237
|
+
stats.detailCacheMisses++
|
|
238
|
+
if (!storage) {
|
|
239
|
+
throw new Error(`[EntityManager:${this.name}] get() not implemented`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fetchPromise = (async () => {
|
|
243
|
+
let result: any
|
|
244
|
+
if (endpoint && storage.request) {
|
|
245
|
+
const response = (await storage.request('GET', `${endpoint}/${id}`, {
|
|
246
|
+
context,
|
|
247
|
+
})) as { data?: any }
|
|
248
|
+
result = response.data ?? response
|
|
249
|
+
} else {
|
|
250
|
+
result = await storage.get(id, context)
|
|
251
|
+
if (!result) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`[EntityManager:${this.name}] Entity not found: ${id}`
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Store in detail cache
|
|
259
|
+
if (this.isDetailCacheEnabled) {
|
|
260
|
+
this._detailCache.items.set(idStr, { item: result, loadedAt: Date.now() })
|
|
261
|
+
this._evictDetailCache()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return result
|
|
265
|
+
})()
|
|
266
|
+
|
|
267
|
+
// Register inflight promise
|
|
268
|
+
this._detailInflight.set(idStr, fetchPromise)
|
|
269
|
+
try {
|
|
270
|
+
const result = await fetchPromise
|
|
271
|
+
return result
|
|
272
|
+
} finally {
|
|
273
|
+
this._detailInflight.delete(idStr)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Symmetric mode (default): try list cache ──
|
|
278
|
+
|
|
279
|
+
// Try cache first if valid and complete
|
|
280
|
+
if (cache.valid && !this.overflow) {
|
|
281
|
+
const cached = cache.items.find(
|
|
282
|
+
(item: any) => String(item[this.idField]) === idStr
|
|
283
|
+
)
|
|
284
|
+
if (cached) {
|
|
285
|
+
stats.cacheHits++
|
|
286
|
+
return { ...cached } // Return copy to avoid mutation
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Fallback to storage
|
|
291
|
+
stats.cacheMisses++
|
|
292
|
+
if (storage) {
|
|
293
|
+
// Use request() with endpoint for multi-storage routing, otherwise use get()
|
|
294
|
+
if (endpoint && storage.request) {
|
|
295
|
+
const response = (await storage.request('GET', `${endpoint}/${id}`, {
|
|
296
|
+
context,
|
|
297
|
+
})) as { data?: any }
|
|
298
|
+
return (response.data ?? response)
|
|
299
|
+
}
|
|
300
|
+
const result = await storage.get(id, context)
|
|
301
|
+
if (!result) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`[EntityManager:${this.name}] Entity not found: ${id}`
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
return result
|
|
307
|
+
}
|
|
308
|
+
throw new Error(`[EntityManager:${this.name}] get() not implemented`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get multiple entities by IDs (batch fetch)
|
|
313
|
+
*/
|
|
314
|
+
proto.getMany = async function (
|
|
315
|
+
this: Self,
|
|
316
|
+
ids: Array<string | number>,
|
|
317
|
+
context?: RoutingContext
|
|
318
|
+
): Promise<any[]> {
|
|
319
|
+
if (!ids || ids.length === 0) return []
|
|
320
|
+
|
|
321
|
+
// Check TTL expiration before using cache
|
|
322
|
+
if (this._isCacheExpired()) {
|
|
323
|
+
this.invalidateCache()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const stats = this._stats
|
|
327
|
+
const cache = this._cache
|
|
328
|
+
|
|
329
|
+
// ── Asymmetric mode: check detail cache, fetch missing via get() ──
|
|
330
|
+
if (this.isAsymmetric) {
|
|
331
|
+
const found: any[] = []
|
|
332
|
+
const missingIds: Array<string | number> = []
|
|
333
|
+
|
|
334
|
+
if (this.isDetailCacheEnabled) {
|
|
335
|
+
for (const id of ids) {
|
|
336
|
+
const entry = this._detailCache.items.get(String(id))
|
|
337
|
+
if (entry && !this._isDetailCacheEntryExpired(entry.loadedAt)) {
|
|
338
|
+
stats.detailCacheHits++
|
|
339
|
+
found.push({ ...entry.item })
|
|
340
|
+
} else {
|
|
341
|
+
missingIds.push(id)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
missingIds.push(...ids)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Fetch missing via individual get() calls (which populate detail cache)
|
|
349
|
+
if (missingIds.length > 0) {
|
|
350
|
+
const fetched = await Promise.all(
|
|
351
|
+
missingIds.map((id) => this.get(id, context).catch((): null => null))
|
|
352
|
+
)
|
|
353
|
+
// get() already counted stats, so just merge results
|
|
354
|
+
for (const item of fetched) {
|
|
355
|
+
if (item !== null) found.push(item)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return found
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Symmetric mode (default): try list cache ──
|
|
363
|
+
|
|
364
|
+
// Try cache first if valid and complete
|
|
365
|
+
if (cache.valid && !this.overflow) {
|
|
366
|
+
const idStrs = new Set(ids.map(String))
|
|
367
|
+
const cached = cache.items
|
|
368
|
+
.filter((item: any) => idStrs.has(String(item[this.idField])))
|
|
369
|
+
.map((item: any) => ({ ...item })) // Return copies
|
|
370
|
+
|
|
371
|
+
// If we found all items in cache
|
|
372
|
+
if (cached.length === ids.length) {
|
|
373
|
+
stats.cacheHits += ids.length
|
|
374
|
+
return cached
|
|
375
|
+
}
|
|
376
|
+
// Partial cache hit - fall through to storage
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
stats.cacheMisses += ids.length
|
|
380
|
+
|
|
381
|
+
const { storage } = this._normalizeResolveResult(
|
|
382
|
+
this.resolveStorage('getMany', context),
|
|
383
|
+
context
|
|
384
|
+
)
|
|
385
|
+
if (storage && 'getMany' in storage && typeof storage.getMany === 'function') {
|
|
386
|
+
return (storage as unknown as { getMany: (ids: Array<string | number>, context?: RoutingContext) => Promise<any[]> }).getMany(ids, context)
|
|
387
|
+
}
|
|
388
|
+
// Fallback: parallel get calls (get() handles its own stats)
|
|
389
|
+
stats.cacheMisses -= ids.length // Avoid double counting
|
|
390
|
+
const results = await Promise.all(
|
|
391
|
+
ids.map((id) => this.get(id, context).catch((): null => null))
|
|
392
|
+
)
|
|
393
|
+
return results.filter((r: any): r is NonNullable<typeof r> => r !== null)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Create a new entity
|
|
398
|
+
*/
|
|
399
|
+
proto.create = async function (
|
|
400
|
+
this: Self,
|
|
401
|
+
data: Partial<any>,
|
|
402
|
+
context?: RoutingContext
|
|
403
|
+
): Promise<any> {
|
|
404
|
+
const { storage, endpoint } = this._normalizeResolveResult(
|
|
405
|
+
this.resolveStorage('create', context),
|
|
406
|
+
context
|
|
407
|
+
)
|
|
408
|
+
this._stats.create++
|
|
409
|
+
if (storage) {
|
|
410
|
+
// Apply field defaults before presave hooks
|
|
411
|
+
const dataWithDefaults = this.applyDefaults(data, context ?? null)
|
|
412
|
+
|
|
413
|
+
// Invoke presave hooks (can modify data or throw to abort)
|
|
414
|
+
const presaveContext = this._buildPresaveContext(dataWithDefaults, true)
|
|
415
|
+
await this._invokeHook('presave', presaveContext as unknown as Record<string, unknown>)
|
|
416
|
+
|
|
417
|
+
// Use request() with endpoint for multi-storage routing, otherwise use create()
|
|
418
|
+
let result: any
|
|
419
|
+
if (endpoint && storage.request) {
|
|
420
|
+
const response = (await storage.request('POST', endpoint, {
|
|
421
|
+
data: presaveContext.record,
|
|
422
|
+
context,
|
|
423
|
+
})) as { data?: any }
|
|
424
|
+
result = (response.data ?? response)
|
|
425
|
+
} else {
|
|
426
|
+
result = await storage.create(presaveContext.record)
|
|
427
|
+
}
|
|
428
|
+
this.invalidateCache()
|
|
429
|
+
|
|
430
|
+
// Invoke postsave hooks (for side effects)
|
|
431
|
+
const postsaveContext = this._buildPostsaveContext(data, result, true)
|
|
432
|
+
await this._invokeHook('postsave', postsaveContext as unknown as Record<string, unknown>)
|
|
433
|
+
|
|
434
|
+
this._emitSignal('created', {
|
|
435
|
+
entity: result,
|
|
436
|
+
manager: this.name,
|
|
437
|
+
id: result?.[this.idField],
|
|
438
|
+
})
|
|
439
|
+
this._emitDataInvalidate('created', result?.[this.idField] as string | number | undefined)
|
|
440
|
+
return result
|
|
441
|
+
}
|
|
442
|
+
throw new Error(`[EntityManager:${this.name}] create() not implemented`)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Update an entity (PUT - full replacement)
|
|
447
|
+
*/
|
|
448
|
+
proto.update = async function (
|
|
449
|
+
this: Self,
|
|
450
|
+
id: string | number,
|
|
451
|
+
data: Partial<any>,
|
|
452
|
+
context?: RoutingContext
|
|
453
|
+
): Promise<any> {
|
|
454
|
+
const { storage, endpoint } = this._normalizeResolveResult(
|
|
455
|
+
this.resolveStorage('update', context),
|
|
456
|
+
context
|
|
457
|
+
)
|
|
458
|
+
this._stats.update++
|
|
459
|
+
if (storage) {
|
|
460
|
+
// Invoke presave hooks (can modify data or throw to abort)
|
|
461
|
+
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
462
|
+
await this._invokeHook('presave', presaveContext as unknown as Record<string, unknown>)
|
|
463
|
+
|
|
464
|
+
// Use request() with endpoint for multi-storage routing, otherwise use update()
|
|
465
|
+
let result: any
|
|
466
|
+
if (endpoint && storage.request) {
|
|
467
|
+
const response = (await storage.request('PUT', `${endpoint}/${id}`, {
|
|
468
|
+
data: presaveContext.record,
|
|
469
|
+
context,
|
|
470
|
+
})) as { data?: any }
|
|
471
|
+
result = (response.data ?? response)
|
|
472
|
+
} else {
|
|
473
|
+
result = await storage.update(id, presaveContext.record)
|
|
474
|
+
}
|
|
475
|
+
this.invalidateCache()
|
|
476
|
+
|
|
477
|
+
// Invoke postsave hooks (for side effects)
|
|
478
|
+
const postsaveContext = this._buildPostsaveContext(data, result, false, id)
|
|
479
|
+
await this._invokeHook('postsave', postsaveContext as unknown as Record<string, unknown>)
|
|
480
|
+
|
|
481
|
+
this._emitSignal('updated', {
|
|
482
|
+
entity: result,
|
|
483
|
+
manager: this.name,
|
|
484
|
+
id,
|
|
485
|
+
})
|
|
486
|
+
this._emitDataInvalidate('updated', id)
|
|
487
|
+
return result
|
|
488
|
+
}
|
|
489
|
+
throw new Error(`[EntityManager:${this.name}] update() not implemented`)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Partially update an entity (PATCH)
|
|
494
|
+
*/
|
|
495
|
+
proto.patch = async function (
|
|
496
|
+
this: Self,
|
|
497
|
+
id: string | number,
|
|
498
|
+
data: Partial<any>,
|
|
499
|
+
context?: RoutingContext
|
|
500
|
+
): Promise<any> {
|
|
501
|
+
const { storage, endpoint } = this._normalizeResolveResult(
|
|
502
|
+
this.resolveStorage('patch', context),
|
|
503
|
+
context
|
|
504
|
+
)
|
|
505
|
+
this._stats.update++ // patch counts as update
|
|
506
|
+
if (storage) {
|
|
507
|
+
// Invoke presave hooks (can modify data or throw to abort)
|
|
508
|
+
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
509
|
+
await this._invokeHook('presave', presaveContext as unknown as Record<string, unknown>)
|
|
510
|
+
|
|
511
|
+
// Use request() with endpoint for multi-storage routing, otherwise use patch()
|
|
512
|
+
let result: any
|
|
513
|
+
if (endpoint && storage.request) {
|
|
514
|
+
const response = (await storage.request('PATCH', `${endpoint}/${id}`, {
|
|
515
|
+
data: presaveContext.record,
|
|
516
|
+
context,
|
|
517
|
+
})) as { data?: any }
|
|
518
|
+
result = (response.data ?? response)
|
|
519
|
+
} else if (storage.patch) {
|
|
520
|
+
result = await storage.patch(id, presaveContext.record)
|
|
521
|
+
} else {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`[EntityManager:${this.name}] Storage does not support patch()`
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
this.invalidateCache()
|
|
527
|
+
|
|
528
|
+
// Invoke postsave hooks (for side effects)
|
|
529
|
+
const postsaveContext = this._buildPostsaveContext(data, result, false, id)
|
|
530
|
+
await this._invokeHook('postsave', postsaveContext as unknown as Record<string, unknown>)
|
|
531
|
+
|
|
532
|
+
this._emitSignal('updated', {
|
|
533
|
+
entity: result,
|
|
534
|
+
manager: this.name,
|
|
535
|
+
id,
|
|
536
|
+
})
|
|
537
|
+
this._emitDataInvalidate('updated', id)
|
|
538
|
+
return result
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Delete an entity
|
|
545
|
+
*/
|
|
546
|
+
proto.delete = async function (
|
|
547
|
+
this: Self,
|
|
548
|
+
id: string | number,
|
|
549
|
+
context?: RoutingContext
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
const { storage, endpoint } = this._normalizeResolveResult(
|
|
552
|
+
this.resolveStorage('delete', context),
|
|
553
|
+
context
|
|
554
|
+
)
|
|
555
|
+
this._stats.delete++
|
|
556
|
+
if (storage) {
|
|
557
|
+
// Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
|
|
558
|
+
const predeleteContext = this._buildPredeleteContext(id)
|
|
559
|
+
await this._invokeHook('predelete', predeleteContext as unknown as Record<string, unknown>)
|
|
560
|
+
|
|
561
|
+
// Use request() with endpoint for multi-storage routing, otherwise use delete()
|
|
562
|
+
if (endpoint && storage.request) {
|
|
563
|
+
await storage.request('DELETE', `${endpoint}/${id}`, { context })
|
|
564
|
+
} else {
|
|
565
|
+
await storage.delete(id)
|
|
566
|
+
}
|
|
567
|
+
this.invalidateCache()
|
|
568
|
+
this._emitSignal('deleted', {
|
|
569
|
+
manager: this.name,
|
|
570
|
+
id,
|
|
571
|
+
})
|
|
572
|
+
this._emitDataInvalidate('deleted', id)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Generic request for special operations
|
|
580
|
+
*/
|
|
581
|
+
proto.request = async function (
|
|
582
|
+
this: Self,
|
|
583
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
584
|
+
path: string,
|
|
585
|
+
options: {
|
|
586
|
+
data?: unknown
|
|
587
|
+
params?: Record<string, unknown>
|
|
588
|
+
headers?: Record<string, string>
|
|
589
|
+
invalidateCache?: boolean
|
|
590
|
+
} = {}
|
|
591
|
+
): Promise<unknown> {
|
|
592
|
+
if (this.storage?.request) {
|
|
593
|
+
const { invalidateCache: shouldInvalidate, ...storageOptions } = options
|
|
594
|
+
const result = await this.storage.request(method, path, storageOptions)
|
|
595
|
+
|
|
596
|
+
// Auto-invalidate cache for mutation methods, or if explicitly requested
|
|
597
|
+
if (shouldInvalidate ?? method !== 'GET') {
|
|
598
|
+
this.invalidateCache()
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return result
|
|
602
|
+
}
|
|
603
|
+
throw new Error(`[EntityManager:${this.name}] request() not implemented`)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Hook: called when manager is registered with orchestrator
|
|
608
|
+
*/
|
|
609
|
+
proto.onRegister = function (
|
|
610
|
+
this: Self,
|
|
611
|
+
orchestrator: Orchestrator
|
|
612
|
+
): void {
|
|
613
|
+
this._orchestrator = orchestrator
|
|
614
|
+
}
|
|
615
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityManager - Minimal decoupled interfaces
|
|
3
|
+
*
|
|
4
|
+
* Centralizes the lightweight "structural" interfaces that composables
|
|
5
|
+
* and other modules use instead of importing the full EntityManager class.
|
|
6
|
+
*
|
|
7
|
+
* Design: tiered hierarchy — pick the smallest interface that fits.
|
|
8
|
+
*
|
|
9
|
+
* EntityManagerBase ← identity + metadata (everyone)
|
|
10
|
+
* ├── EntityManagerRead ← + get/list/query (read-oriented views)
|
|
11
|
+
* ├── EntityManagerCrud ← + create/update/patch/delete (forms)
|
|
12
|
+
* └── EntityManagerList ← + list specifics (list pages)
|
|
13
|
+
*
|
|
14
|
+
* All interfaces accept an optional generic parameter `T` (default: `unknown`)
|
|
15
|
+
* that flows through to method signatures for typed entity data.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ─── Base ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Minimal identity / metadata — used by navigation, breadcrumbs, child pages.
|
|
22
|
+
*/
|
|
23
|
+
export interface EntityManagerBase<T = unknown> {
|
|
24
|
+
readonly idField: string
|
|
25
|
+
label?: string
|
|
26
|
+
labelPlural?: string
|
|
27
|
+
routePrefix?: string
|
|
28
|
+
labelField?: string | ((entity: T) => string)
|
|
29
|
+
readOnly?: boolean
|
|
30
|
+
getEntityLabel: (data: T) => string
|
|
31
|
+
getEntityBadges?: (data: T) => Array<{ label: string; severity?: string }>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Permissions ─────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Permission checks — used by guards, list pages, forms.
|
|
38
|
+
*/
|
|
39
|
+
export interface EntityManagerPermissions<T = unknown> {
|
|
40
|
+
canRead?: (entity?: T) => boolean
|
|
41
|
+
canCreate: () => boolean
|
|
42
|
+
canUpdate: (entity?: T) => boolean
|
|
43
|
+
canDelete: (entity?: T) => boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Read ────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read operations + delete — used by list pages and other read-oriented views.
|
|
50
|
+
*/
|
|
51
|
+
export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, EntityManagerPermissions<T> {
|
|
52
|
+
get: (id: string | number, context?: unknown) => Promise<T>
|
|
53
|
+
list: (params?: unknown, context?: unknown) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
|
|
54
|
+
query?: (params?: unknown, options?: { routingContext?: unknown }) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
|
|
55
|
+
delete: (id: string | number, context?: unknown) => Promise<void>
|
|
56
|
+
request: (method: string, path: string, options?: { data?: unknown }) => Promise<unknown>
|
|
57
|
+
invalidateCache?: () => void
|
|
58
|
+
localFilterThreshold?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Full CRUD — used by form pages.
|
|
65
|
+
*/
|
|
66
|
+
export interface EntityManagerCrud<T = unknown> extends EntityManagerRead<T> {
|
|
67
|
+
fields?: Record<string, unknown> | unknown[]
|
|
68
|
+
getInitialData: () => Record<string, unknown>
|
|
69
|
+
getFormFields: () => Array<{ name: string; [key: string]: unknown }>
|
|
70
|
+
getFieldConfig: (name: string) => unknown | null
|
|
71
|
+
resolveReferenceOptions: (fieldName: string) => Promise<unknown[]>
|
|
72
|
+
create: (data: unknown, context?: unknown) => Promise<T>
|
|
73
|
+
update: (id: string | number, data: unknown, context?: unknown) => Promise<T>
|
|
74
|
+
patch: (id: string | number, data: unknown, context?: unknown) => Promise<T>
|
|
75
|
+
hasSeverityMap?: (field: string) => boolean
|
|
76
|
+
getSeverity?: (field: string, value: string | number, defaultSeverity?: string) => string
|
|
77
|
+
getSeverityDescriptor?: (field: string, value: string | number, defaultSeverity?: string) => { severity: string; icon?: string; label?: string }
|
|
78
|
+
}
|