@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,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook Bundle Pattern for qdadm
|
|
3
|
+
*
|
|
4
|
+
* A hook bundle is a function that registers multiple related hooks to implement
|
|
5
|
+
* a complete feature (soft delete, audit trail, timestamps, versioning).
|
|
6
|
+
*
|
|
7
|
+
* Bundles are composable and can be applied to any entity via the hook system.
|
|
8
|
+
* Unlike decorators that wrap a manager instance, bundles work globally through
|
|
9
|
+
* hooks that react to entity lifecycle events.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Define a custom bundle
|
|
13
|
+
* const withMyFeature = createHookBundle('myFeature', (register, context) => {
|
|
14
|
+
* register('entity:presave', (event) => {
|
|
15
|
+
* event.data.entity.myField = 'value'
|
|
16
|
+
* })
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* // Apply bundles to the kernel
|
|
20
|
+
* const cleanup = applyBundles(kernel.hooks, [
|
|
21
|
+
* withSoftDelete(),
|
|
22
|
+
* withTimestamps(),
|
|
23
|
+
* withVersioning()
|
|
24
|
+
* ])
|
|
25
|
+
*
|
|
26
|
+
* // Later: cleanup() // Removes all bundle hooks
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { HOOK_PRIORITY, type HookRegistry, type HookRegistrationOptions } from '../hooks/index'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Bundle register function type
|
|
33
|
+
*/
|
|
34
|
+
export type BundleRegisterFn = (
|
|
35
|
+
hookName: string,
|
|
36
|
+
handler: (data: unknown) => unknown | Promise<unknown>,
|
|
37
|
+
options?: HookRegistrationOptions
|
|
38
|
+
) => () => void
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bundle context passed to setup function
|
|
42
|
+
*/
|
|
43
|
+
export interface BundleContext<T = Record<string, unknown>> {
|
|
44
|
+
target: string
|
|
45
|
+
options: T
|
|
46
|
+
hooks: HookRegistry
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bundle setup function type
|
|
51
|
+
*/
|
|
52
|
+
export type BundleSetupFn<T = Record<string, unknown>> = (
|
|
53
|
+
register: BundleRegisterFn,
|
|
54
|
+
context: BundleContext<T>
|
|
55
|
+
) => void
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bundle instance
|
|
59
|
+
*/
|
|
60
|
+
export interface Bundle<T = Record<string, unknown>> {
|
|
61
|
+
name: string
|
|
62
|
+
options: T
|
|
63
|
+
setup: BundleSetupFn<T>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Bundle factory function type
|
|
68
|
+
*/
|
|
69
|
+
export type BundleFactory<T = Record<string, unknown>> = (options?: T) => Bundle<T>
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply bundle context
|
|
73
|
+
*/
|
|
74
|
+
export interface ApplyBundleContext {
|
|
75
|
+
target?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a hook bundle factory
|
|
80
|
+
*
|
|
81
|
+
* A bundle factory creates bundle instances with optional configuration.
|
|
82
|
+
* The bundle itself registers hooks via the provided register function.
|
|
83
|
+
*
|
|
84
|
+
* @param name - Bundle name for identification and debugging
|
|
85
|
+
* @param setup - Setup function: (register, context) => void
|
|
86
|
+
* @returns Bundle factory: (options?) => Bundle
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const withAudit = createHookBundle('audit', (register, context) => {
|
|
90
|
+
* const { logger = console.log } = context.options
|
|
91
|
+
*
|
|
92
|
+
* register('entity:postsave', (event) => {
|
|
93
|
+
* logger('Saved:', event.data.entity)
|
|
94
|
+
* }, { priority: HOOK_PRIORITY.LOW })
|
|
95
|
+
* })
|
|
96
|
+
*
|
|
97
|
+
* // Usage: withAudit({ logger: myLogger })
|
|
98
|
+
*/
|
|
99
|
+
export function createHookBundle<T = Record<string, unknown>>(
|
|
100
|
+
name: string,
|
|
101
|
+
setup: BundleSetupFn<T>
|
|
102
|
+
): BundleFactory<T> {
|
|
103
|
+
if (!name || typeof name !== 'string') {
|
|
104
|
+
throw new Error('[createHookBundle] Bundle name must be a non-empty string')
|
|
105
|
+
}
|
|
106
|
+
if (typeof setup !== 'function') {
|
|
107
|
+
throw new Error('[createHookBundle] Setup must be a function')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Return the bundle factory
|
|
111
|
+
return function bundleFactory(options: T = {} as T): Bundle<T> {
|
|
112
|
+
// Return the bundle instance (applied by applyBundle)
|
|
113
|
+
return {
|
|
114
|
+
name,
|
|
115
|
+
options,
|
|
116
|
+
setup,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Apply a single bundle to a HookRegistry
|
|
123
|
+
*
|
|
124
|
+
* Registers all hooks defined by the bundle and returns a cleanup function.
|
|
125
|
+
*
|
|
126
|
+
* @param hooks - HookRegistry instance
|
|
127
|
+
* @param bundle - Bundle instance from bundle factory
|
|
128
|
+
* @param context - Additional context
|
|
129
|
+
* @returns Cleanup function to remove all bundle hooks
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const cleanup = applyBundle(kernel.hooks, withTimestamps())
|
|
133
|
+
* // ... later
|
|
134
|
+
* cleanup()
|
|
135
|
+
*/
|
|
136
|
+
export function applyBundle<T>(
|
|
137
|
+
hooks: HookRegistry,
|
|
138
|
+
bundle: Bundle<T>,
|
|
139
|
+
context: ApplyBundleContext = {}
|
|
140
|
+
): () => void {
|
|
141
|
+
if (!hooks || typeof hooks.register !== 'function') {
|
|
142
|
+
throw new Error('[applyBundle] First argument must be a HookRegistry')
|
|
143
|
+
}
|
|
144
|
+
if (!bundle || !bundle.setup) {
|
|
145
|
+
throw new Error('[applyBundle] Second argument must be a bundle instance')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { target = '*' } = context
|
|
149
|
+
const unbindFns: Array<() => void> = []
|
|
150
|
+
|
|
151
|
+
// Create a register function that tracks all registrations
|
|
152
|
+
const register: BundleRegisterFn = (hookName, handler, options = {}) => {
|
|
153
|
+
// Prefix handler ID with bundle name for debugging
|
|
154
|
+
const id = options.id
|
|
155
|
+
? `${bundle.name}:${options.id}`
|
|
156
|
+
: `${bundle.name}:${hookName.replace(/:/g, '-')}`
|
|
157
|
+
|
|
158
|
+
const unbind = hooks.register(hookName, handler, {
|
|
159
|
+
...options,
|
|
160
|
+
id,
|
|
161
|
+
})
|
|
162
|
+
unbindFns.push(unbind)
|
|
163
|
+
return unbind
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Execute bundle setup with context
|
|
167
|
+
bundle.setup(register, {
|
|
168
|
+
target,
|
|
169
|
+
options: bundle.options,
|
|
170
|
+
hooks,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Return cleanup function
|
|
174
|
+
return () => {
|
|
175
|
+
for (const unbind of unbindFns) {
|
|
176
|
+
unbind()
|
|
177
|
+
}
|
|
178
|
+
unbindFns.length = 0
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Apply multiple bundles to a HookRegistry
|
|
184
|
+
*
|
|
185
|
+
* Convenience function to apply multiple bundles at once.
|
|
186
|
+
*
|
|
187
|
+
* @param hooks - HookRegistry instance
|
|
188
|
+
* @param bundles - Array of bundle instances
|
|
189
|
+
* @param context - Context passed to each bundle
|
|
190
|
+
* @returns Combined cleanup function
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* const cleanup = applyBundles(kernel.hooks, [
|
|
194
|
+
* withTimestamps(),
|
|
195
|
+
* withSoftDelete({ field: 'deleted_at' }),
|
|
196
|
+
* withVersioning()
|
|
197
|
+
* ])
|
|
198
|
+
*/
|
|
199
|
+
export function applyBundles(
|
|
200
|
+
hooks: HookRegistry,
|
|
201
|
+
bundles: Bundle[],
|
|
202
|
+
context: ApplyBundleContext = {}
|
|
203
|
+
): () => void {
|
|
204
|
+
if (!Array.isArray(bundles)) {
|
|
205
|
+
throw new Error('[applyBundles] Bundles must be an array')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const cleanupFns = bundles.map((bundle) => applyBundle(hooks, bundle, context))
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
for (const cleanup of cleanupFns) {
|
|
212
|
+
cleanup()
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Built-in Bundles
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Entity manager interface for bundles
|
|
223
|
+
*/
|
|
224
|
+
interface BundleEntityManager {
|
|
225
|
+
name: string
|
|
226
|
+
idField?: string
|
|
227
|
+
patch: (id: unknown, data: Record<string, unknown>) => Promise<unknown>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Entity event data structure
|
|
232
|
+
*/
|
|
233
|
+
interface EntityEventData {
|
|
234
|
+
entity: Record<string, unknown>
|
|
235
|
+
manager: BundleEntityManager
|
|
236
|
+
isNew?: boolean
|
|
237
|
+
originalEntity?: Record<string, unknown>
|
|
238
|
+
cancel?: () => void
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Soft delete bundle options
|
|
243
|
+
*/
|
|
244
|
+
export interface SoftDeleteOptions {
|
|
245
|
+
field?: string
|
|
246
|
+
timestamp?: () => string
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* withSoftDelete bundle
|
|
251
|
+
*
|
|
252
|
+
* Prevents actual deletion by setting a deleted_at timestamp instead.
|
|
253
|
+
* Also provides list filtering to exclude deleted records by default.
|
|
254
|
+
*
|
|
255
|
+
* Hooks registered:
|
|
256
|
+
* - `entity:predelete` - Intercepts delete, sets deleted_at via patch
|
|
257
|
+
* - `list:alter` - Filters out deleted records (unless includeDeleted option)
|
|
258
|
+
*
|
|
259
|
+
* @param options - Options
|
|
260
|
+
* @returns Bundle instance
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* applyBundle(hooks, withSoftDelete())
|
|
264
|
+
* applyBundle(hooks, withSoftDelete({ field: 'removed_at' }))
|
|
265
|
+
*/
|
|
266
|
+
export const withSoftDelete = createHookBundle<SoftDeleteOptions>(
|
|
267
|
+
'softDelete',
|
|
268
|
+
(register, context) => {
|
|
269
|
+
const { field = 'deleted_at', timestamp = () => new Date().toISOString() } = context.options
|
|
270
|
+
|
|
271
|
+
// Intercept delete operations
|
|
272
|
+
register(
|
|
273
|
+
'entity:predelete',
|
|
274
|
+
(event) => {
|
|
275
|
+
const { entity, manager, cancel } = (event as { data: EntityEventData }).data
|
|
276
|
+
|
|
277
|
+
// Get entity ID
|
|
278
|
+
const id = entity[manager.idField || 'id']
|
|
279
|
+
|
|
280
|
+
// Cancel the actual delete
|
|
281
|
+
cancel?.()
|
|
282
|
+
|
|
283
|
+
// Set soft-delete timestamp via patch
|
|
284
|
+
manager.patch(id, { [field]: timestamp() })
|
|
285
|
+
},
|
|
286
|
+
{ priority: HOOK_PRIORITY.FIRST, id: 'intercept' }
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
// Filter deleted records from lists by default
|
|
290
|
+
register(
|
|
291
|
+
'list:alter',
|
|
292
|
+
(config) => {
|
|
293
|
+
const cfg = config as Record<string, unknown>
|
|
294
|
+
// Add default filter unless explicitly including deleted
|
|
295
|
+
if (!cfg.includeDeleted) {
|
|
296
|
+
if (!cfg.filters) {
|
|
297
|
+
cfg.filters = []
|
|
298
|
+
}
|
|
299
|
+
// Add filter: field is null (not deleted)
|
|
300
|
+
;(cfg.filters as unknown[]).push({
|
|
301
|
+
field,
|
|
302
|
+
operator: 'is_null',
|
|
303
|
+
value: true,
|
|
304
|
+
_bundleManaged: true,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
return config
|
|
308
|
+
},
|
|
309
|
+
{ priority: HOOK_PRIORITY.LOW, id: 'filter-list' }
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Timestamp bundle options
|
|
316
|
+
*/
|
|
317
|
+
export interface TimestampOptions {
|
|
318
|
+
createdAtField?: string
|
|
319
|
+
updatedAtField?: string
|
|
320
|
+
timestamp?: () => string
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* withTimestamps bundle
|
|
325
|
+
*
|
|
326
|
+
* Automatically manages created_at and updated_at timestamps.
|
|
327
|
+
*
|
|
328
|
+
* Hooks registered:
|
|
329
|
+
* - `entity:presave` - Sets created_at on new records, updated_at on all saves
|
|
330
|
+
*
|
|
331
|
+
* @param options - Options
|
|
332
|
+
* @returns Bundle instance
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* applyBundle(hooks, withTimestamps())
|
|
336
|
+
* applyBundle(hooks, withTimestamps({ createdAtField: 'createdAt' }))
|
|
337
|
+
*/
|
|
338
|
+
export const withTimestamps = createHookBundle<TimestampOptions>(
|
|
339
|
+
'timestamps',
|
|
340
|
+
(register, context) => {
|
|
341
|
+
const {
|
|
342
|
+
createdAtField = 'created_at',
|
|
343
|
+
updatedAtField = 'updated_at',
|
|
344
|
+
timestamp = () => new Date().toISOString(),
|
|
345
|
+
} = context.options
|
|
346
|
+
|
|
347
|
+
register(
|
|
348
|
+
'entity:presave',
|
|
349
|
+
(event) => {
|
|
350
|
+
const { entity, isNew } = (event as { data: EntityEventData }).data
|
|
351
|
+
const now = timestamp()
|
|
352
|
+
|
|
353
|
+
// Set created_at only on new records
|
|
354
|
+
if (isNew && !entity[createdAtField]) {
|
|
355
|
+
entity[createdAtField] = now
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Always update updated_at
|
|
359
|
+
entity[updatedAtField] = now
|
|
360
|
+
},
|
|
361
|
+
{ priority: HOOK_PRIORITY.HIGH, id: 'set-timestamps' }
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Versioning bundle options
|
|
368
|
+
*/
|
|
369
|
+
export interface VersioningOptions {
|
|
370
|
+
field?: string
|
|
371
|
+
validateOnUpdate?: boolean
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Version conflict error
|
|
376
|
+
*/
|
|
377
|
+
export interface VersionConflictError extends Error {
|
|
378
|
+
name: 'VersionConflictError'
|
|
379
|
+
entityName: string
|
|
380
|
+
expectedVersion: number
|
|
381
|
+
actualVersion: number
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* withVersioning bundle
|
|
386
|
+
*
|
|
387
|
+
* Implements optimistic locking via version field.
|
|
388
|
+
* Increments version on each save and validates version matches for updates.
|
|
389
|
+
*
|
|
390
|
+
* Hooks registered:
|
|
391
|
+
* - `entity:presave` - Increments version and validates for conflicts
|
|
392
|
+
*
|
|
393
|
+
* @param options - Options
|
|
394
|
+
* @returns Bundle instance
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* applyBundle(hooks, withVersioning())
|
|
398
|
+
* applyBundle(hooks, withVersioning({ field: '_version', validateOnUpdate: false }))
|
|
399
|
+
*/
|
|
400
|
+
export const withVersioning = createHookBundle<VersioningOptions>(
|
|
401
|
+
'versioning',
|
|
402
|
+
(register, context) => {
|
|
403
|
+
const { field = 'version', validateOnUpdate = true } = context.options
|
|
404
|
+
|
|
405
|
+
register(
|
|
406
|
+
'entity:presave',
|
|
407
|
+
async (event) => {
|
|
408
|
+
const { entity, isNew, manager, originalEntity } = (event as { data: EntityEventData }).data
|
|
409
|
+
|
|
410
|
+
if (isNew) {
|
|
411
|
+
// New record: initialize version to 1
|
|
412
|
+
entity[field] = 1
|
|
413
|
+
} else {
|
|
414
|
+
// Existing record: validate and increment version
|
|
415
|
+
if (validateOnUpdate && originalEntity) {
|
|
416
|
+
const currentVersion = entity[field] as number | undefined
|
|
417
|
+
const originalVersion = originalEntity[field] as number | undefined
|
|
418
|
+
|
|
419
|
+
// Check for version conflict
|
|
420
|
+
if (currentVersion !== undefined && originalVersion !== undefined) {
|
|
421
|
+
if (currentVersion !== originalVersion) {
|
|
422
|
+
const error = new Error(
|
|
423
|
+
`Version conflict: expected ${originalVersion}, got ${currentVersion}`
|
|
424
|
+
) as VersionConflictError
|
|
425
|
+
error.name = 'VersionConflictError'
|
|
426
|
+
error.entityName = manager.name
|
|
427
|
+
error.expectedVersion = originalVersion
|
|
428
|
+
error.actualVersion = currentVersion
|
|
429
|
+
throw error
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Increment version
|
|
435
|
+
const currentVersion = (entity[field] as number) || 0
|
|
436
|
+
entity[field] = currentVersion + 1
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
{ priority: HOOK_PRIORITY.HIGH, id: 'manage-version' }
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Audit log bundle options
|
|
446
|
+
*/
|
|
447
|
+
export interface AuditLogOptions {
|
|
448
|
+
logger?: (entry: AuditLogEntry) => void
|
|
449
|
+
includeData?: boolean
|
|
450
|
+
includeDiff?: boolean
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Audit log entry
|
|
455
|
+
*/
|
|
456
|
+
export interface AuditLogEntry {
|
|
457
|
+
action: 'create' | 'update' | 'delete'
|
|
458
|
+
timestamp: string
|
|
459
|
+
entity: string
|
|
460
|
+
id: unknown
|
|
461
|
+
data?: Record<string, unknown>
|
|
462
|
+
changes?: Record<string, { from: unknown; to: unknown }>
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* withAuditLog bundle
|
|
467
|
+
*
|
|
468
|
+
* Logs all entity operations for audit trail.
|
|
469
|
+
*
|
|
470
|
+
* Hooks registered:
|
|
471
|
+
* - `entity:postsave` - Logs create/update operations
|
|
472
|
+
* - `entity:postdelete` - Logs delete operations
|
|
473
|
+
*
|
|
474
|
+
* @param options - Options
|
|
475
|
+
* @returns Bundle instance
|
|
476
|
+
*
|
|
477
|
+
* @example
|
|
478
|
+
* applyBundle(hooks, withAuditLog())
|
|
479
|
+
* applyBundle(hooks, withAuditLog({ logger: auditService.log, includeData: true }))
|
|
480
|
+
*/
|
|
481
|
+
export const withAuditLog = createHookBundle<AuditLogOptions>('auditLog', (register, context) => {
|
|
482
|
+
const { logger = console.log, includeData = false, includeDiff = false } = context.options
|
|
483
|
+
|
|
484
|
+
const log = (action: AuditLogEntry['action'], details: Omit<AuditLogEntry, 'action' | 'timestamp'>) => {
|
|
485
|
+
logger({
|
|
486
|
+
action,
|
|
487
|
+
timestamp: new Date().toISOString(),
|
|
488
|
+
...details,
|
|
489
|
+
} as AuditLogEntry)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
register(
|
|
493
|
+
'entity:postsave',
|
|
494
|
+
(event) => {
|
|
495
|
+
const { entity, isNew, manager, originalEntity } = (event as { data: EntityEventData }).data
|
|
496
|
+
const action: 'create' | 'update' = isNew ? 'create' : 'update'
|
|
497
|
+
const id = entity[manager.idField || 'id']
|
|
498
|
+
|
|
499
|
+
const details: Partial<AuditLogEntry> = {
|
|
500
|
+
entity: manager.name,
|
|
501
|
+
id,
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (includeData) {
|
|
505
|
+
details.data = entity
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (includeDiff && !isNew && originalEntity) {
|
|
509
|
+
details.changes = computeDiff(originalEntity, entity)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
log(action, details as Omit<AuditLogEntry, 'action' | 'timestamp'>)
|
|
513
|
+
},
|
|
514
|
+
{ priority: HOOK_PRIORITY.LAST, id: 'log-save' }
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
register(
|
|
518
|
+
'entity:postdelete',
|
|
519
|
+
(event) => {
|
|
520
|
+
const { entity, manager } = (event as { data: EntityEventData }).data
|
|
521
|
+
const id = entity[manager.idField || 'id']
|
|
522
|
+
|
|
523
|
+
log('delete', {
|
|
524
|
+
entity: manager.name,
|
|
525
|
+
id,
|
|
526
|
+
...(includeData ? { data: entity } : {}),
|
|
527
|
+
})
|
|
528
|
+
},
|
|
529
|
+
{ priority: HOOK_PRIORITY.LAST, id: 'log-delete' }
|
|
530
|
+
)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Compute simple diff between two objects
|
|
535
|
+
*
|
|
536
|
+
* @param original - Original object
|
|
537
|
+
* @param updated - Updated object
|
|
538
|
+
* @returns Object with changed fields: { field: { from, to } }
|
|
539
|
+
*/
|
|
540
|
+
function computeDiff(
|
|
541
|
+
original: Record<string, unknown>,
|
|
542
|
+
updated: Record<string, unknown>
|
|
543
|
+
): Record<string, { from: unknown; to: unknown }> {
|
|
544
|
+
const diff: Record<string, { from: unknown; to: unknown }> = {}
|
|
545
|
+
const allKeys = new Set([...Object.keys(original), ...Object.keys(updated)])
|
|
546
|
+
|
|
547
|
+
for (const key of allKeys) {
|
|
548
|
+
const oldVal = original[key]
|
|
549
|
+
const newVal = updated[key]
|
|
550
|
+
|
|
551
|
+
// Simple equality check (handles primitives, not deep objects)
|
|
552
|
+
if (oldVal !== newVal) {
|
|
553
|
+
diff[key] = { from: oldVal, to: newVal }
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return diff
|
|
558
|
+
}
|