@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.
Files changed (291) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -0
  3. package/package.json +101 -0
  4. package/src/assets/logo.svg +6 -0
  5. package/src/auth/SessionAuthAdapter.ts +459 -0
  6. package/src/auth/index.ts +15 -0
  7. package/src/chain/ActiveStack.ts +154 -0
  8. package/src/chain/StackHydrator.ts +292 -0
  9. package/src/chain/index.ts +25 -0
  10. package/src/chain/useActiveStack.ts +120 -0
  11. package/src/chain/useStackHydrator.ts +155 -0
  12. package/src/components/BoolCell.vue +34 -0
  13. package/src/components/InfoBanner.vue +73 -0
  14. package/src/components/QdadmRoot.vue +52 -0
  15. package/src/components/SeverityTag.vue +86 -0
  16. package/src/components/dialogs/BulkStatusDialog.vue +60 -0
  17. package/src/components/dialogs/MultiStepDialog.vue +331 -0
  18. package/src/components/dialogs/SimpleDialog.vue +122 -0
  19. package/src/components/dialogs/UnsavedChangesDialog.vue +80 -0
  20. package/src/components/display/BannerZone.vue +65 -0
  21. package/src/components/display/CardsGrid.vue +72 -0
  22. package/src/components/display/CopyableId.vue +84 -0
  23. package/src/components/display/EmptyState.vue +62 -0
  24. package/src/components/display/IntensityBar.vue +190 -0
  25. package/src/components/display/RichCardsGrid.vue +101 -0
  26. package/src/components/edit/FormActions.vue +78 -0
  27. package/src/components/edit/FormField.vue +82 -0
  28. package/src/components/edit/FormInput.vue +158 -0
  29. package/src/components/edit/FormPage.vue +260 -0
  30. package/src/components/edit/FormTab.vue +217 -0
  31. package/src/components/edit/FormTabs.vue +120 -0
  32. package/src/components/edit/LookupField.vue +406 -0
  33. package/src/components/edit/LookupPickerDialog.vue +230 -0
  34. package/src/components/editors/JsonEditorFoldable.vue +470 -0
  35. package/src/components/editors/JsonStructuredField.vue +264 -0
  36. package/src/components/editors/JsonViewer.vue +51 -0
  37. package/src/components/editors/KeyValueEditor.vue +314 -0
  38. package/src/components/editors/LanguageEditor.vue +259 -0
  39. package/src/components/editors/PermissionEditor.vue +558 -0
  40. package/src/components/editors/ScopeEditor.vue +372 -0
  41. package/src/components/editors/VanillaJsonEditor.vue +183 -0
  42. package/src/components/index.ts +83 -0
  43. package/src/components/item/FieldGroups.vue +392 -0
  44. package/src/components/layout/AppLayout.vue +1032 -0
  45. package/src/components/layout/BaseLayout.vue +190 -0
  46. package/src/components/layout/Breadcrumb.vue +55 -0
  47. package/src/components/layout/DashboardLayout.vue +104 -0
  48. package/src/components/layout/FormLayout.vue +256 -0
  49. package/src/components/layout/ListLayout.vue +278 -0
  50. package/src/components/layout/PageHeader.vue +156 -0
  51. package/src/components/layout/PageLayout.vue +126 -0
  52. package/src/components/layout/PageNav.vue +192 -0
  53. package/src/components/layout/SidebarBox.vue +87 -0
  54. package/src/components/layout/Zone.vue +182 -0
  55. package/src/components/layout/defaults/DefaultBreadcrumb.vue +149 -0
  56. package/src/components/layout/defaults/DefaultFooter.vue +89 -0
  57. package/src/components/layout/defaults/DefaultFormActions.vue +67 -0
  58. package/src/components/layout/defaults/DefaultHeader.vue +78 -0
  59. package/src/components/layout/defaults/DefaultMenu.vue +188 -0
  60. package/src/components/layout/defaults/DefaultPagination.vue +75 -0
  61. package/src/components/layout/defaults/DefaultTable.vue +137 -0
  62. package/src/components/layout/defaults/DefaultUserInfo.vue +111 -0
  63. package/src/components/layout/defaults/index.ts +16 -0
  64. package/src/components/lists/ActionButtons.vue +49 -0
  65. package/src/components/lists/ActionColumn.vue +40 -0
  66. package/src/components/lists/FilterBar.vue +54 -0
  67. package/src/components/lists/ListPage.vue +467 -0
  68. package/src/components/pages/LoginPage.vue +334 -0
  69. package/src/components/pages/NotFoundPage.vue +98 -0
  70. package/src/components/show/ShowDisplay.vue +343 -0
  71. package/src/components/show/ShowField.vue +67 -0
  72. package/src/components/show/ShowPage.vue +431 -0
  73. package/src/composables/index.ts +155 -0
  74. package/src/composables/useApp.ts +67 -0
  75. package/src/composables/useAuth.ts +98 -0
  76. package/src/composables/useBareForm.ts +319 -0
  77. package/src/composables/useBreadcrumb.ts +252 -0
  78. package/src/composables/useChildPage.ts +113 -0
  79. package/src/composables/useCurrentEntity.ts +60 -0
  80. package/src/composables/useDeferred.ts +94 -0
  81. package/src/composables/useDirtyState.ts +138 -0
  82. package/src/composables/useEntityItemFormPage.ts +942 -0
  83. package/src/composables/useEntityItemFormPage.types.ts +451 -0
  84. package/src/composables/useEntityItemPage.ts +438 -0
  85. package/src/composables/useEntityItemShowPage.ts +722 -0
  86. package/src/composables/useFieldManager.ts +692 -0
  87. package/src/composables/useForm.ts +544 -0
  88. package/src/composables/useGuardStore.ts +53 -0
  89. package/src/composables/useHooks.ts +56 -0
  90. package/src/composables/useInfoBanner.ts +217 -0
  91. package/src/composables/useJsonSyntax.ts +122 -0
  92. package/src/composables/useLayoutResolver.ts +329 -0
  93. package/src/composables/useListPage.ts +1535 -0
  94. package/src/composables/useListPage.types.ts +437 -0
  95. package/src/composables/useListPage.utils.ts +101 -0
  96. package/src/composables/useNavContext.ts +439 -0
  97. package/src/composables/useNavigation.ts +216 -0
  98. package/src/composables/useOptionsLookup.ts +323 -0
  99. package/src/composables/usePageTitle.ts +84 -0
  100. package/src/composables/useSSEBridge.ts +142 -0
  101. package/src/composables/useSemanticBreadcrumb.ts +222 -0
  102. package/src/composables/useSidebarState.ts +24 -0
  103. package/src/composables/useSignals.ts +52 -0
  104. package/src/composables/useUnsavedChangesGuard.ts +155 -0
  105. package/src/composables/useUserImpersonator.ts +317 -0
  106. package/src/composables/useZoneRegistry.ts +252 -0
  107. package/src/core/bundles.ts +558 -0
  108. package/src/core/decorator.ts +420 -0
  109. package/src/core/extension.ts +470 -0
  110. package/src/core/index.ts +59 -0
  111. package/src/deferred/DeferredRegistry.ts +353 -0
  112. package/src/deferred/index.ts +15 -0
  113. package/src/editors/index.ts +12 -0
  114. package/src/entity/EntityManager.cache.ts +530 -0
  115. package/src/entity/EntityManager.crud.ts +615 -0
  116. package/src/entity/EntityManager.interface.ts +78 -0
  117. package/src/entity/EntityManager.query.ts +221 -0
  118. package/src/entity/EntityManager.relations.ts +151 -0
  119. package/src/entity/EntityManager.ts +944 -0
  120. package/src/entity/EntityManager.types.ts +288 -0
  121. package/src/entity/auth/CompositeAuthAdapter.ts +194 -0
  122. package/src/entity/auth/EntityAuthAdapter.ts +119 -0
  123. package/src/entity/auth/PermissiveAdapter.ts +43 -0
  124. package/src/entity/auth/RoleHierarchy.ts +128 -0
  125. package/src/entity/auth/SecurityChecker.ts +202 -0
  126. package/src/entity/auth/factory.ts +203 -0
  127. package/src/entity/auth/index.ts +47 -0
  128. package/src/entity/factory.test.js +189 -0
  129. package/src/entity/factory.ts +146 -0
  130. package/src/entity/index.ts +49 -0
  131. package/src/entity/storage/ApiStorage.ts +228 -0
  132. package/src/entity/storage/IStorage.ts +100 -0
  133. package/src/entity/storage/LocalStorage.ts +200 -0
  134. package/src/entity/storage/MemoryStorage.ts +184 -0
  135. package/src/entity/storage/MockApiStorage.ts +318 -0
  136. package/src/entity/storage/SdkStorage.ts +377 -0
  137. package/src/entity/storage/factory.test.js +159 -0
  138. package/src/entity/storage/factory.ts +209 -0
  139. package/src/entity/storage/index.ts +75 -0
  140. package/src/gen/FieldMapper.ts +122 -0
  141. package/src/gen/StorageProfileFactory.ts +93 -0
  142. package/src/gen/connectors/BaseConnector.ts +150 -0
  143. package/src/gen/connectors/ManualConnector.test.js +499 -0
  144. package/src/gen/connectors/ManualConnector.ts +414 -0
  145. package/src/gen/connectors/OpenAPIConnector.test.js +836 -0
  146. package/src/gen/connectors/OpenAPIConnector.ts +693 -0
  147. package/src/gen/connectors/__fixtures__/sample-openapi.json +311 -0
  148. package/src/gen/connectors/index.ts +27 -0
  149. package/src/gen/createManagers.ts +245 -0
  150. package/src/gen/decorators.ts +134 -0
  151. package/src/gen/generateManagers.test.js +540 -0
  152. package/src/gen/generateManagers.ts +398 -0
  153. package/src/gen/index.ts +66 -0
  154. package/src/gen/schema.ts +248 -0
  155. package/src/gen/vite-plugin.ts +114 -0
  156. package/src/generated/managers/testManager.js +45 -0
  157. package/src/generated/managers/testManager.ts +46 -0
  158. package/src/hooks/HookRegistry.ts +16 -0
  159. package/src/hooks/index.ts +15 -0
  160. package/src/i18n/I18n.ts +344 -0
  161. package/src/i18n/IncrementalDomainProvider.ts +153 -0
  162. package/src/i18n/InlineTranslationProvider.ts +4 -0
  163. package/src/i18n/LazyTranslationProvider.ts +102 -0
  164. package/src/i18n/MessagesRegistry.ts +4 -0
  165. package/src/i18n/Resolver.ts +4 -0
  166. package/src/i18n/__tests__/I18n.test.ts +169 -0
  167. package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
  168. package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
  169. package/src/i18n/__tests__/Resolver.test.ts +271 -0
  170. package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
  171. package/src/i18n/defaults/core.en.yml +55 -0
  172. package/src/i18n/defaults/core.fr.yml +55 -0
  173. package/src/i18n/index.ts +55 -0
  174. package/src/i18n/loaders/raw-modules.d.ts +15 -0
  175. package/src/i18n/loaders/yaml.ts +35 -0
  176. package/src/i18n/strategies.ts +4 -0
  177. package/src/i18n/types.ts +34 -0
  178. package/src/i18n/useI18n.ts +34 -0
  179. package/src/index.ts +453 -0
  180. package/src/kernel/EventRouter.ts +23 -0
  181. package/src/kernel/Kernel.api.ts +79 -0
  182. package/src/kernel/Kernel.auth.ts +120 -0
  183. package/src/kernel/Kernel.i18n.ts +29 -0
  184. package/src/kernel/Kernel.modules.ts +170 -0
  185. package/src/kernel/Kernel.registries.ts +237 -0
  186. package/src/kernel/Kernel.routing.ts +334 -0
  187. package/src/kernel/Kernel.ts +460 -0
  188. package/src/kernel/Kernel.types.ts +263 -0
  189. package/src/kernel/Kernel.vue.ts +304 -0
  190. package/src/kernel/KernelContext.entities.ts +80 -0
  191. package/src/kernel/KernelContext.events.ts +57 -0
  192. package/src/kernel/KernelContext.i18n.ts +37 -0
  193. package/src/kernel/KernelContext.permissions.ts +38 -0
  194. package/src/kernel/KernelContext.routing.ts +280 -0
  195. package/src/kernel/KernelContext.ts +252 -0
  196. package/src/kernel/KernelContext.types.ts +173 -0
  197. package/src/kernel/KernelContext.zones.ts +54 -0
  198. package/src/kernel/Module.ts +169 -0
  199. package/src/kernel/ModuleLoader.ts +645 -0
  200. package/src/kernel/SSEBridge.ts +11 -0
  201. package/src/kernel/SignalBus.ts +48 -0
  202. package/src/kernel/index.ts +56 -0
  203. package/src/module/index.ts +33 -0
  204. package/src/module/moduleRegistry.ts +492 -0
  205. package/src/modules/debug/AuthCollector.ts +501 -0
  206. package/src/modules/debug/Collector.ts +20 -0
  207. package/src/modules/debug/DebugBridge.ts +14 -0
  208. package/src/modules/debug/DebugModule.ts +220 -0
  209. package/src/modules/debug/EntitiesCollector.ts +839 -0
  210. package/src/modules/debug/ErrorCollector.ts +4 -0
  211. package/src/modules/debug/I18nCollector.ts +9 -0
  212. package/src/modules/debug/LocalStorageAdapter.ts +7 -0
  213. package/src/modules/debug/RouterCollector.ts +449 -0
  214. package/src/modules/debug/SignalCollector.ts +4 -0
  215. package/src/modules/debug/ToastCollector.ts +4 -0
  216. package/src/modules/debug/ZonesCollector.ts +447 -0
  217. package/src/modules/debug/components/DebugBar.vue +31 -0
  218. package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
  219. package/src/modules/debug/components/index.ts +9 -0
  220. package/src/modules/debug/components/panels/AuthPanel.vue +267 -0
  221. package/src/modules/debug/components/panels/EntitiesPanel.vue +572 -0
  222. package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
  223. package/src/modules/debug/components/panels/RouterPanel.vue +440 -0
  224. package/src/modules/debug/components/panels/ZonesPanel.vue +133 -0
  225. package/src/modules/debug/components/panels/index.ts +16 -0
  226. package/src/modules/debug/index.ts +58 -0
  227. package/src/modules/debug/styles.scss +2671 -0
  228. package/src/notifications/NotificationBadge.vue +35 -0
  229. package/src/notifications/NotificationListener.vue +71 -0
  230. package/src/notifications/NotificationModule.ts +76 -0
  231. package/src/notifications/NotificationPanel.vue +478 -0
  232. package/src/notifications/NotificationStore.ts +288 -0
  233. package/src/notifications/index.ts +42 -0
  234. package/src/notifications/styles.scss +32 -0
  235. package/src/orchestrator/Orchestrator.ts +427 -0
  236. package/src/orchestrator/index.ts +8 -0
  237. package/src/orchestrator/useOrchestrator.ts +80 -0
  238. package/src/plugin.ts +251 -0
  239. package/src/query/FilterQuery.ts +306 -0
  240. package/src/query/QueryExecutor.ts +341 -0
  241. package/src/query/index.ts +16 -0
  242. package/src/security/EntityRolesProvider.ts +385 -0
  243. package/src/security/PermissionMatcher.ts +152 -0
  244. package/src/security/PermissionRegistry.ts +273 -0
  245. package/src/security/PersistableRolesProvider.ts +584 -0
  246. package/src/security/RolesManager.ts +136 -0
  247. package/src/security/RolesProvider.ts +167 -0
  248. package/src/security/RolesProviderStorage.ts +211 -0
  249. package/src/security/SecurityModule.ts +94 -0
  250. package/src/security/StaticRolesProvider.ts +117 -0
  251. package/src/security/UsersManager.ts +163 -0
  252. package/src/security/index.ts +77 -0
  253. package/src/security/pages/RoleForm.vue +276 -0
  254. package/src/security/pages/RoleList.vue +123 -0
  255. package/src/styles/_alerts.scss +95 -0
  256. package/src/styles/_buttons.scss +391 -0
  257. package/src/styles/_cards.scss +241 -0
  258. package/src/styles/_code.scss +78 -0
  259. package/src/styles/_dialogs.scss +55 -0
  260. package/src/styles/_filter-bar.scss +70 -0
  261. package/src/styles/_forms.scss +434 -0
  262. package/src/styles/_helpers.scss +153 -0
  263. package/src/styles/_lists.scss +225 -0
  264. package/src/styles/_main.scss +356 -0
  265. package/src/styles/_markdown.scss +88 -0
  266. package/src/styles/_responsive.scss +255 -0
  267. package/src/styles/_show-pages.scss +186 -0
  268. package/src/styles/_sidebar.scss +380 -0
  269. package/src/styles/_states.scss +115 -0
  270. package/src/styles/_stats.scss +47 -0
  271. package/src/styles/_variables.scss +224 -0
  272. package/src/styles/index.scss +38 -0
  273. package/src/styles/theme/_components.scss +331 -0
  274. package/src/styles/theme/_index.scss +10 -0
  275. package/src/styles/theme/_tokens.scss +147 -0
  276. package/src/styles/theme/_utilities.scss +175 -0
  277. package/src/toast/ToastBridgeModule.ts +68 -0
  278. package/src/toast/ToastListener.vue +55 -0
  279. package/src/toast/index.ts +26 -0
  280. package/src/toast/useSignalToast.ts +154 -0
  281. package/src/types/index.ts +285 -0
  282. package/src/types/pluralize.d.ts +19 -0
  283. package/src/types/quarkernel.d.ts +50 -0
  284. package/src/utils/formatters.ts +174 -0
  285. package/src/utils/index.ts +35 -0
  286. package/src/utils/transformers.ts +131 -0
  287. package/src/vite/qdadmDebugPlugin.ts +401 -0
  288. package/src/vite-env.d.ts +50 -0
  289. package/src/zones/ZoneRegistry.ts +934 -0
  290. package/src/zones/index.ts +29 -0
  291. 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
+ }