qdadm 0.16.0 → 0.17.0
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/README.md +153 -1
- package/package.json +15 -2
- package/src/components/forms/FormField.vue +64 -6
- package/src/components/forms/FormPage.vue +276 -0
- package/src/components/index.js +11 -0
- package/src/components/layout/BaseLayout.vue +183 -0
- package/src/components/layout/DashboardLayout.vue +100 -0
- package/src/components/layout/FormLayout.vue +261 -0
- package/src/components/layout/ListLayout.vue +334 -0
- package/src/components/layout/Zone.vue +165 -0
- package/src/components/layout/defaults/DefaultBreadcrumb.vue +140 -0
- package/src/components/layout/defaults/DefaultFooter.vue +56 -0
- package/src/components/layout/defaults/DefaultFormActions.vue +53 -0
- package/src/components/layout/defaults/DefaultHeader.vue +69 -0
- package/src/components/layout/defaults/DefaultMenu.vue +197 -0
- package/src/components/layout/defaults/DefaultPagination.vue +79 -0
- package/src/components/layout/defaults/DefaultTable.vue +130 -0
- package/src/components/layout/defaults/DefaultToaster.vue +16 -0
- package/src/components/layout/defaults/DefaultUserInfo.vue +96 -0
- package/src/components/layout/defaults/index.js +17 -0
- package/src/composables/index.js +6 -6
- package/src/composables/useForm.js +135 -0
- package/src/composables/useFormPageBuilder.js +1154 -0
- package/src/composables/useHooks.js +53 -0
- package/src/composables/useLayoutResolver.js +260 -0
- package/src/composables/useListPageBuilder.js +336 -52
- package/src/composables/useNavigation.js +38 -2
- package/src/composables/useSignals.js +49 -0
- package/src/composables/useZoneRegistry.js +162 -0
- package/src/core/bundles.js +406 -0
- package/src/core/decorator.js +322 -0
- package/src/core/extension.js +386 -0
- package/src/core/index.js +28 -0
- package/src/entity/EntityManager.js +314 -16
- package/src/entity/auth/AuthAdapter.js +125 -0
- package/src/entity/auth/PermissiveAdapter.js +64 -0
- package/src/entity/auth/index.js +11 -0
- package/src/entity/index.js +3 -0
- package/src/entity/storage/MockApiStorage.js +349 -0
- package/src/entity/storage/SdkStorage.js +478 -0
- package/src/entity/storage/index.js +2 -0
- package/src/hooks/HookRegistry.js +411 -0
- package/src/hooks/index.js +12 -0
- package/src/index.js +9 -0
- package/src/kernel/Kernel.js +136 -4
- package/src/kernel/SignalBus.js +180 -0
- package/src/kernel/index.js +7 -0
- package/src/module/moduleRegistry.js +124 -6
- package/src/orchestrator/Orchestrator.js +73 -1
- package/src/zones/ZoneRegistry.js +821 -0
- package/src/zones/index.js +16 -0
- package/src/zones/zones.js +189 -0
- package/src/composables/useEntityTitle.js +0 -121
- package/src/composables/useManager.js +0 -20
- package/src/composables/usePageBuilder.js +0 -334
- package/src/composables/useStatus.js +0 -146
- package/src/composables/useSubEditor.js +0 -165
- package/src/composables/useTabSync.js +0 -110
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useZoneRegistry - Access the zone registry for extensible UI composition
|
|
3
|
+
*
|
|
4
|
+
* Provides reactive access to the ZoneRegistry created by Kernel during bootstrap.
|
|
5
|
+
* Components can register/unregister blocks and query zones reactively.
|
|
6
|
+
*
|
|
7
|
+
* **Reactivity**: The registry is reactive. When blocks are added, removed, or modified,
|
|
8
|
+
* components using `getBlocks()` will automatically re-render.
|
|
9
|
+
*
|
|
10
|
+
* @returns {object} - Object with registry methods and reactive helpers
|
|
11
|
+
* @throws {Error} If zone registry is not available (Kernel not initialized)
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Basic access to zones (reactive)
|
|
15
|
+
* import { useZoneRegistry } from 'qdadm'
|
|
16
|
+
* import { ZONES } from 'qdadm/zones'
|
|
17
|
+
*
|
|
18
|
+
* const { getBlocks, registerBlock, unregisterBlock } = useZoneRegistry()
|
|
19
|
+
*
|
|
20
|
+
* // Get blocks (reactive - component re-renders when blocks change)
|
|
21
|
+
* const headerBlocks = computed(() => getBlocks(ZONES.HEADER))
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // Register/unregister blocks at component lifecycle
|
|
25
|
+
* import { useZoneRegistry } from 'qdadm'
|
|
26
|
+
* import { ZONES } from 'qdadm/zones'
|
|
27
|
+
* import AdBanner from './AdBanner.vue'
|
|
28
|
+
*
|
|
29
|
+
* const { registerBlock, unregisterBlock } = useZoneRegistry()
|
|
30
|
+
*
|
|
31
|
+
* onMounted(() => {
|
|
32
|
+
* registerBlock(ZONES.SIDEBAR, {
|
|
33
|
+
* id: 'ad-banner',
|
|
34
|
+
* component: AdBanner,
|
|
35
|
+
* weight: 50,
|
|
36
|
+
* props: { variant: 'compact' }
|
|
37
|
+
* })
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* onUnmounted(() => {
|
|
41
|
+
* unregisterBlock(ZONES.SIDEBAR, 'ad-banner')
|
|
42
|
+
* })
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* // Conditional block registration based on feature flag
|
|
46
|
+
* import { useZoneRegistry } from 'qdadm'
|
|
47
|
+
* import { watch } from 'vue'
|
|
48
|
+
*
|
|
49
|
+
* const { registerBlock, unregisterBlock } = useZoneRegistry()
|
|
50
|
+
*
|
|
51
|
+
* watch(featureEnabled, (enabled) => {
|
|
52
|
+
* if (enabled) {
|
|
53
|
+
* registerBlock('sidebar', { id: 'feature-widget', component: FeatureWidget })
|
|
54
|
+
* } else {
|
|
55
|
+
* unregisterBlock('sidebar', 'feature-widget')
|
|
56
|
+
* }
|
|
57
|
+
* }, { immediate: true })
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* // Direct registry access
|
|
61
|
+
* const { registry } = useZoneRegistry()
|
|
62
|
+
* const zones = registry.listZones()
|
|
63
|
+
* console.log('Available zones:', zones)
|
|
64
|
+
*/
|
|
65
|
+
import { inject, computed } from 'vue'
|
|
66
|
+
|
|
67
|
+
export function useZoneRegistry() {
|
|
68
|
+
const registry = inject('qdadmZoneRegistry')
|
|
69
|
+
|
|
70
|
+
if (!registry) {
|
|
71
|
+
throw new Error('[qdadm] Zone registry not provided. Ensure Kernel is initialized.')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Get the reactive version ref for tracking changes
|
|
75
|
+
const version = registry.getVersionRef()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get blocks for a zone (reactive)
|
|
79
|
+
*
|
|
80
|
+
* This function depends on the registry's version counter,
|
|
81
|
+
* so it will return fresh data when blocks change.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} zoneName - Zone name
|
|
84
|
+
* @returns {BlockConfig[]} - Array of block configs
|
|
85
|
+
*/
|
|
86
|
+
function getBlocks(zoneName) {
|
|
87
|
+
// Access version.value to create reactive dependency
|
|
88
|
+
// eslint-disable-next-line no-unused-expressions
|
|
89
|
+
version.value
|
|
90
|
+
return registry.getBlocks(zoneName)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a computed ref for a zone's blocks
|
|
95
|
+
*
|
|
96
|
+
* Convenience method for creating a reactive computed ref
|
|
97
|
+
* that updates when the zone's blocks change.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} zoneName - Zone name
|
|
100
|
+
* @returns {import('vue').ComputedRef<BlockConfig[]>} - Computed ref of blocks
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* const headerBlocks = useBlocks(ZONES.HEADER)
|
|
104
|
+
* // headerBlocks.value is reactive array
|
|
105
|
+
*/
|
|
106
|
+
function useBlocks(zoneName) {
|
|
107
|
+
return computed(() => {
|
|
108
|
+
// Access version to create dependency
|
|
109
|
+
// eslint-disable-next-line no-unused-expressions
|
|
110
|
+
version.value
|
|
111
|
+
return registry.getBlocks(zoneName)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register a block in a zone
|
|
117
|
+
*
|
|
118
|
+
* Proxy to registry.registerBlock() for convenience.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} zoneName - Target zone name
|
|
121
|
+
* @param {BlockConfig} blockConfig - Block configuration
|
|
122
|
+
* @returns {ZoneRegistry} - The registry (for chaining)
|
|
123
|
+
*/
|
|
124
|
+
function registerBlock(zoneName, blockConfig) {
|
|
125
|
+
return registry.registerBlock(zoneName, blockConfig)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Unregister a block from a zone
|
|
130
|
+
*
|
|
131
|
+
* Proxy to registry.unregisterBlock() for convenience.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} zoneName - Zone name
|
|
134
|
+
* @param {string} blockId - Block ID to unregister
|
|
135
|
+
* @returns {boolean} - True if block was found and removed
|
|
136
|
+
*/
|
|
137
|
+
function unregisterBlock(zoneName, blockId) {
|
|
138
|
+
return registry.unregisterBlock(zoneName, blockId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
// Direct registry access for advanced usage
|
|
143
|
+
registry,
|
|
144
|
+
|
|
145
|
+
// Reactive helpers
|
|
146
|
+
getBlocks,
|
|
147
|
+
useBlocks,
|
|
148
|
+
|
|
149
|
+
// Block management
|
|
150
|
+
registerBlock,
|
|
151
|
+
unregisterBlock,
|
|
152
|
+
|
|
153
|
+
// Passthrough common methods
|
|
154
|
+
defineZone: registry.defineZone.bind(registry),
|
|
155
|
+
hasBlocks: registry.hasBlocks.bind(registry),
|
|
156
|
+
hasZone: registry.hasZone.bind(registry),
|
|
157
|
+
getDefault: registry.getDefault.bind(registry),
|
|
158
|
+
listZones: registry.listZones.bind(registry),
|
|
159
|
+
getZoneInfo: registry.getZoneInfo.bind(registry),
|
|
160
|
+
inspect: registry.inspect.bind(registry)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
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 } from '../hooks/index.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a hook bundle factory
|
|
33
|
+
*
|
|
34
|
+
* A bundle factory creates bundle instances with optional configuration.
|
|
35
|
+
* The bundle itself registers hooks via the provided register function.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} name - Bundle name for identification and debugging
|
|
38
|
+
* @param {Function} setup - Setup function: (register, context) => void
|
|
39
|
+
* - register(hookName, handler, options) - Register a hook
|
|
40
|
+
* - context.target - Target entity name (or '*' for all)
|
|
41
|
+
* - context.options - Bundle options passed to the factory
|
|
42
|
+
* @returns {Function} Bundle factory: (options?) => Bundle
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const withAudit = createHookBundle('audit', (register, context) => {
|
|
46
|
+
* const { logger = console.log } = context.options
|
|
47
|
+
*
|
|
48
|
+
* register('entity:postsave', (event) => {
|
|
49
|
+
* logger('Saved:', event.data.entity)
|
|
50
|
+
* }, { priority: HOOK_PRIORITY.LOW })
|
|
51
|
+
* })
|
|
52
|
+
*
|
|
53
|
+
* // Usage: withAudit({ logger: myLogger })
|
|
54
|
+
*/
|
|
55
|
+
export function createHookBundle(name, setup) {
|
|
56
|
+
if (!name || typeof name !== 'string') {
|
|
57
|
+
throw new Error('[createHookBundle] Bundle name must be a non-empty string')
|
|
58
|
+
}
|
|
59
|
+
if (typeof setup !== 'function') {
|
|
60
|
+
throw new Error('[createHookBundle] Setup must be a function')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Return the bundle factory
|
|
64
|
+
return function bundleFactory(options = {}) {
|
|
65
|
+
// Return the bundle instance (applied by applyBundle)
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
options,
|
|
69
|
+
setup
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Apply a single bundle to a HookRegistry
|
|
76
|
+
*
|
|
77
|
+
* Registers all hooks defined by the bundle and returns a cleanup function.
|
|
78
|
+
*
|
|
79
|
+
* @param {HookRegistry} hooks - HookRegistry instance
|
|
80
|
+
* @param {object} bundle - Bundle instance from bundle factory
|
|
81
|
+
* @param {object} [context] - Additional context
|
|
82
|
+
* @param {string} [context.target='*'] - Target entity name (or '*' for all)
|
|
83
|
+
* @returns {Function} Cleanup function to remove all bundle hooks
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const cleanup = applyBundle(kernel.hooks, withTimestamps())
|
|
87
|
+
* // ... later
|
|
88
|
+
* cleanup()
|
|
89
|
+
*/
|
|
90
|
+
export function applyBundle(hooks, bundle, context = {}) {
|
|
91
|
+
if (!hooks || typeof hooks.register !== 'function') {
|
|
92
|
+
throw new Error('[applyBundle] First argument must be a HookRegistry')
|
|
93
|
+
}
|
|
94
|
+
if (!bundle || !bundle.setup) {
|
|
95
|
+
throw new Error('[applyBundle] Second argument must be a bundle instance')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { target = '*' } = context
|
|
99
|
+
const unbindFns = []
|
|
100
|
+
|
|
101
|
+
// Create a register function that tracks all registrations
|
|
102
|
+
const register = (hookName, handler, options = {}) => {
|
|
103
|
+
// Prefix handler ID with bundle name for debugging
|
|
104
|
+
const id = options.id
|
|
105
|
+
? `${bundle.name}:${options.id}`
|
|
106
|
+
: `${bundle.name}:${hookName.replace(/:/g, '-')}`
|
|
107
|
+
|
|
108
|
+
const unbind = hooks.register(hookName, handler, {
|
|
109
|
+
...options,
|
|
110
|
+
id
|
|
111
|
+
})
|
|
112
|
+
unbindFns.push(unbind)
|
|
113
|
+
return unbind
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Execute bundle setup with context
|
|
117
|
+
bundle.setup(register, {
|
|
118
|
+
target,
|
|
119
|
+
options: bundle.options,
|
|
120
|
+
hooks
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Return cleanup function
|
|
124
|
+
return () => {
|
|
125
|
+
for (const unbind of unbindFns) {
|
|
126
|
+
unbind()
|
|
127
|
+
}
|
|
128
|
+
unbindFns.length = 0
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Apply multiple bundles to a HookRegistry
|
|
134
|
+
*
|
|
135
|
+
* Convenience function to apply multiple bundles at once.
|
|
136
|
+
*
|
|
137
|
+
* @param {HookRegistry} hooks - HookRegistry instance
|
|
138
|
+
* @param {Array} bundles - Array of bundle instances
|
|
139
|
+
* @param {object} [context] - Context passed to each bundle
|
|
140
|
+
* @returns {Function} Combined cleanup function
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* const cleanup = applyBundles(kernel.hooks, [
|
|
144
|
+
* withTimestamps(),
|
|
145
|
+
* withSoftDelete({ field: 'deleted_at' }),
|
|
146
|
+
* withVersioning()
|
|
147
|
+
* ])
|
|
148
|
+
*/
|
|
149
|
+
export function applyBundles(hooks, bundles, context = {}) {
|
|
150
|
+
if (!Array.isArray(bundles)) {
|
|
151
|
+
throw new Error('[applyBundles] Bundles must be an array')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cleanupFns = bundles.map(bundle => applyBundle(hooks, bundle, context))
|
|
155
|
+
|
|
156
|
+
return () => {
|
|
157
|
+
for (const cleanup of cleanupFns) {
|
|
158
|
+
cleanup()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* withSoftDelete bundle
|
|
165
|
+
*
|
|
166
|
+
* Prevents actual deletion by setting a deleted_at timestamp instead.
|
|
167
|
+
* Also provides list filtering to exclude deleted records by default.
|
|
168
|
+
*
|
|
169
|
+
* Hooks registered:
|
|
170
|
+
* - `entity:predelete` - Intercepts delete, sets deleted_at via patch
|
|
171
|
+
* - `list:alter` - Filters out deleted records (unless includeDeleted option)
|
|
172
|
+
*
|
|
173
|
+
* @param {object} [options] - Options
|
|
174
|
+
* @param {string} [options.field='deleted_at'] - Field name for deletion timestamp
|
|
175
|
+
* @param {Function} [options.timestamp] - Custom timestamp function
|
|
176
|
+
* @returns {object} Bundle instance
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* applyBundle(hooks, withSoftDelete())
|
|
180
|
+
* applyBundle(hooks, withSoftDelete({ field: 'removed_at' }))
|
|
181
|
+
*/
|
|
182
|
+
export const withSoftDelete = createHookBundle('softDelete', (register, context) => {
|
|
183
|
+
const {
|
|
184
|
+
field = 'deleted_at',
|
|
185
|
+
timestamp = () => new Date().toISOString()
|
|
186
|
+
} = context.options
|
|
187
|
+
|
|
188
|
+
// Intercept delete operations
|
|
189
|
+
register('entity:predelete', (event) => {
|
|
190
|
+
const { entity, manager, cancel } = event.data
|
|
191
|
+
|
|
192
|
+
// Get entity ID
|
|
193
|
+
const id = entity[manager.idField || 'id']
|
|
194
|
+
|
|
195
|
+
// Cancel the actual delete
|
|
196
|
+
cancel()
|
|
197
|
+
|
|
198
|
+
// Set soft-delete timestamp via patch
|
|
199
|
+
manager.patch(id, { [field]: timestamp() })
|
|
200
|
+
}, { priority: HOOK_PRIORITY.FIRST, id: 'intercept' })
|
|
201
|
+
|
|
202
|
+
// Filter deleted records from lists by default
|
|
203
|
+
register('list:alter', (config) => {
|
|
204
|
+
// Add default filter unless explicitly including deleted
|
|
205
|
+
if (!config.includeDeleted) {
|
|
206
|
+
if (!config.filters) {
|
|
207
|
+
config.filters = []
|
|
208
|
+
}
|
|
209
|
+
// Add filter: field is null (not deleted)
|
|
210
|
+
config.filters.push({
|
|
211
|
+
field,
|
|
212
|
+
operator: 'is_null',
|
|
213
|
+
value: true,
|
|
214
|
+
_bundleManaged: true
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
return config
|
|
218
|
+
}, { priority: HOOK_PRIORITY.LOW, id: 'filter-list' })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* withTimestamps bundle
|
|
223
|
+
*
|
|
224
|
+
* Automatically manages created_at and updated_at timestamps.
|
|
225
|
+
*
|
|
226
|
+
* Hooks registered:
|
|
227
|
+
* - `entity:presave` - Sets created_at on new records, updated_at on all saves
|
|
228
|
+
*
|
|
229
|
+
* @param {object} [options] - Options
|
|
230
|
+
* @param {string} [options.createdAtField='created_at'] - Field for creation timestamp
|
|
231
|
+
* @param {string} [options.updatedAtField='updated_at'] - Field for update timestamp
|
|
232
|
+
* @param {Function} [options.timestamp] - Custom timestamp function
|
|
233
|
+
* @returns {object} Bundle instance
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* applyBundle(hooks, withTimestamps())
|
|
237
|
+
* applyBundle(hooks, withTimestamps({ createdAtField: 'createdAt' }))
|
|
238
|
+
*/
|
|
239
|
+
export const withTimestamps = createHookBundle('timestamps', (register, context) => {
|
|
240
|
+
const {
|
|
241
|
+
createdAtField = 'created_at',
|
|
242
|
+
updatedAtField = 'updated_at',
|
|
243
|
+
timestamp = () => new Date().toISOString()
|
|
244
|
+
} = context.options
|
|
245
|
+
|
|
246
|
+
register('entity:presave', (event) => {
|
|
247
|
+
const { entity, isNew } = event.data
|
|
248
|
+
const now = timestamp()
|
|
249
|
+
|
|
250
|
+
// Set created_at only on new records
|
|
251
|
+
if (isNew && !entity[createdAtField]) {
|
|
252
|
+
entity[createdAtField] = now
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Always update updated_at
|
|
256
|
+
entity[updatedAtField] = now
|
|
257
|
+
}, { priority: HOOK_PRIORITY.HIGH, id: 'set-timestamps' })
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* withVersioning bundle
|
|
262
|
+
*
|
|
263
|
+
* Implements optimistic locking via version field.
|
|
264
|
+
* Increments version on each save and validates version matches for updates.
|
|
265
|
+
*
|
|
266
|
+
* Hooks registered:
|
|
267
|
+
* - `entity:presave` - Increments version and validates for conflicts
|
|
268
|
+
*
|
|
269
|
+
* @param {object} [options] - Options
|
|
270
|
+
* @param {string} [options.field='version'] - Version field name
|
|
271
|
+
* @param {boolean} [options.validateOnUpdate=true] - Validate version on updates
|
|
272
|
+
* @returns {object} Bundle instance
|
|
273
|
+
*
|
|
274
|
+
* @example
|
|
275
|
+
* applyBundle(hooks, withVersioning())
|
|
276
|
+
* applyBundle(hooks, withVersioning({ field: '_version', validateOnUpdate: false }))
|
|
277
|
+
*/
|
|
278
|
+
export const withVersioning = createHookBundle('versioning', (register, context) => {
|
|
279
|
+
const {
|
|
280
|
+
field = 'version',
|
|
281
|
+
validateOnUpdate = true
|
|
282
|
+
} = context.options
|
|
283
|
+
|
|
284
|
+
register('entity:presave', async (event) => {
|
|
285
|
+
const { entity, isNew, manager, originalEntity } = event.data
|
|
286
|
+
|
|
287
|
+
if (isNew) {
|
|
288
|
+
// New record: initialize version to 1
|
|
289
|
+
entity[field] = 1
|
|
290
|
+
} else {
|
|
291
|
+
// Existing record: validate and increment version
|
|
292
|
+
if (validateOnUpdate && originalEntity) {
|
|
293
|
+
const currentVersion = entity[field]
|
|
294
|
+
const originalVersion = originalEntity[field]
|
|
295
|
+
|
|
296
|
+
// Check for version conflict
|
|
297
|
+
if (currentVersion !== undefined && originalVersion !== undefined) {
|
|
298
|
+
if (currentVersion !== originalVersion) {
|
|
299
|
+
const error = new Error(`Version conflict: expected ${originalVersion}, got ${currentVersion}`)
|
|
300
|
+
error.name = 'VersionConflictError'
|
|
301
|
+
error.entityName = manager.name
|
|
302
|
+
error.expectedVersion = originalVersion
|
|
303
|
+
error.actualVersion = currentVersion
|
|
304
|
+
throw error
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Increment version
|
|
310
|
+
const currentVersion = entity[field] || 0
|
|
311
|
+
entity[field] = currentVersion + 1
|
|
312
|
+
}
|
|
313
|
+
}, { priority: HOOK_PRIORITY.HIGH, id: 'manage-version' })
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* withAuditLog bundle
|
|
318
|
+
*
|
|
319
|
+
* Logs all entity operations for audit trail.
|
|
320
|
+
*
|
|
321
|
+
* Hooks registered:
|
|
322
|
+
* - `entity:postsave` - Logs create/update operations
|
|
323
|
+
* - `entity:postdelete` - Logs delete operations
|
|
324
|
+
*
|
|
325
|
+
* @param {object} [options] - Options
|
|
326
|
+
* @param {Function} [options.logger=console.log] - Logger function
|
|
327
|
+
* @param {boolean} [options.includeData=false] - Include entity data in logs
|
|
328
|
+
* @param {boolean} [options.includeDiff=false] - Include diff for updates
|
|
329
|
+
* @returns {object} Bundle instance
|
|
330
|
+
*
|
|
331
|
+
* @example
|
|
332
|
+
* applyBundle(hooks, withAuditLog())
|
|
333
|
+
* applyBundle(hooks, withAuditLog({ logger: auditService.log, includeData: true }))
|
|
334
|
+
*/
|
|
335
|
+
export const withAuditLog = createHookBundle('auditLog', (register, context) => {
|
|
336
|
+
const {
|
|
337
|
+
logger = console.log,
|
|
338
|
+
includeData = false,
|
|
339
|
+
includeDiff = false
|
|
340
|
+
} = context.options
|
|
341
|
+
|
|
342
|
+
const log = (action, details) => {
|
|
343
|
+
logger({
|
|
344
|
+
action,
|
|
345
|
+
timestamp: new Date().toISOString(),
|
|
346
|
+
...details
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
register('entity:postsave', (event) => {
|
|
351
|
+
const { entity, isNew, manager, originalEntity } = event.data
|
|
352
|
+
const action = isNew ? 'create' : 'update'
|
|
353
|
+
const id = entity[manager.idField || 'id']
|
|
354
|
+
|
|
355
|
+
const details = {
|
|
356
|
+
entity: manager.name,
|
|
357
|
+
id
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (includeData) {
|
|
361
|
+
details.data = entity
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (includeDiff && !isNew && originalEntity) {
|
|
365
|
+
details.changes = computeDiff(originalEntity, entity)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
log(action, details)
|
|
369
|
+
}, { priority: HOOK_PRIORITY.LAST, id: 'log-save' })
|
|
370
|
+
|
|
371
|
+
register('entity:postdelete', (event) => {
|
|
372
|
+
const { entity, manager } = event.data
|
|
373
|
+
const id = entity[manager.idField || 'id']
|
|
374
|
+
|
|
375
|
+
log('delete', {
|
|
376
|
+
entity: manager.name,
|
|
377
|
+
id,
|
|
378
|
+
...(includeData ? { data: entity } : {})
|
|
379
|
+
})
|
|
380
|
+
}, { priority: HOOK_PRIORITY.LAST, id: 'log-delete' })
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Compute simple diff between two objects
|
|
385
|
+
*
|
|
386
|
+
* @private
|
|
387
|
+
* @param {object} original - Original object
|
|
388
|
+
* @param {object} updated - Updated object
|
|
389
|
+
* @returns {object} Object with changed fields: { field: { from, to } }
|
|
390
|
+
*/
|
|
391
|
+
function computeDiff(original, updated) {
|
|
392
|
+
const diff = {}
|
|
393
|
+
const allKeys = new Set([...Object.keys(original), ...Object.keys(updated)])
|
|
394
|
+
|
|
395
|
+
for (const key of allKeys) {
|
|
396
|
+
const oldVal = original[key]
|
|
397
|
+
const newVal = updated[key]
|
|
398
|
+
|
|
399
|
+
// Simple equality check (handles primitives, not deep objects)
|
|
400
|
+
if (oldVal !== newVal) {
|
|
401
|
+
diff[key] = { from: oldVal, to: newVal }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return diff
|
|
406
|
+
}
|