@open-mercato/shared 0.4.2-canary-c02407ff85

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 (324) hide show
  1. package/build.mjs +101 -0
  2. package/dist/index.js +1 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/lib/api/crud.js +47 -0
  5. package/dist/lib/api/crud.js.map +7 -0
  6. package/dist/lib/api/scoped.js +140 -0
  7. package/dist/lib/api/scoped.js.map +7 -0
  8. package/dist/lib/auth/jwt.js +34 -0
  9. package/dist/lib/auth/jwt.js.map +7 -0
  10. package/dist/lib/auth/server.js +157 -0
  11. package/dist/lib/auth/server.js.map +7 -0
  12. package/dist/lib/boolean.js +22 -0
  13. package/dist/lib/boolean.js.map +7 -0
  14. package/dist/lib/bootstrap/appResolver.js +43 -0
  15. package/dist/lib/bootstrap/appResolver.js.map +7 -0
  16. package/dist/lib/bootstrap/dynamicLoader.js +108 -0
  17. package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
  18. package/dist/lib/bootstrap/factory.js +59 -0
  19. package/dist/lib/bootstrap/factory.js.map +7 -0
  20. package/dist/lib/bootstrap/index.js +11 -0
  21. package/dist/lib/bootstrap/index.js.map +7 -0
  22. package/dist/lib/bootstrap/types.js +1 -0
  23. package/dist/lib/bootstrap/types.js.map +7 -0
  24. package/dist/lib/cache/segments.js +36 -0
  25. package/dist/lib/cache/segments.js.map +7 -0
  26. package/dist/lib/cli/progress.js +46 -0
  27. package/dist/lib/cli/progress.js.map +7 -0
  28. package/dist/lib/commands/command-bus.js +285 -0
  29. package/dist/lib/commands/command-bus.js.map +7 -0
  30. package/dist/lib/commands/customFieldSnapshots.js +66 -0
  31. package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
  32. package/dist/lib/commands/helpers.js +98 -0
  33. package/dist/lib/commands/helpers.js.map +7 -0
  34. package/dist/lib/commands/index.js +8 -0
  35. package/dist/lib/commands/index.js.map +7 -0
  36. package/dist/lib/commands/operationMetadata.js +32 -0
  37. package/dist/lib/commands/operationMetadata.js.map +7 -0
  38. package/dist/lib/commands/registry.js +43 -0
  39. package/dist/lib/commands/registry.js.map +7 -0
  40. package/dist/lib/commands/scope.js +44 -0
  41. package/dist/lib/commands/scope.js.map +7 -0
  42. package/dist/lib/commands/types.js +8 -0
  43. package/dist/lib/commands/types.js.map +7 -0
  44. package/dist/lib/crud/cache-stats.js +98 -0
  45. package/dist/lib/crud/cache-stats.js.map +7 -0
  46. package/dist/lib/crud/cache.js +175 -0
  47. package/dist/lib/crud/cache.js.map +7 -0
  48. package/dist/lib/crud/custom-fields-client.js +52 -0
  49. package/dist/lib/crud/custom-fields-client.js.map +7 -0
  50. package/dist/lib/crud/custom-fields.js +467 -0
  51. package/dist/lib/crud/custom-fields.js.map +7 -0
  52. package/dist/lib/crud/errors.js +24 -0
  53. package/dist/lib/crud/errors.js.map +7 -0
  54. package/dist/lib/crud/exporters.js +154 -0
  55. package/dist/lib/crud/exporters.js.map +7 -0
  56. package/dist/lib/crud/factory.js +1311 -0
  57. package/dist/lib/crud/factory.js.map +7 -0
  58. package/dist/lib/crud/types.js +1 -0
  59. package/dist/lib/crud/types.js.map +7 -0
  60. package/dist/lib/custom-fields/normalize.js +36 -0
  61. package/dist/lib/custom-fields/normalize.js.map +7 -0
  62. package/dist/lib/data/engine.js +396 -0
  63. package/dist/lib/data/engine.js.map +7 -0
  64. package/dist/lib/db/escapeLikePattern.js +5 -0
  65. package/dist/lib/db/escapeLikePattern.js.map +7 -0
  66. package/dist/lib/db/mikro.js +82 -0
  67. package/dist/lib/db/mikro.js.map +7 -0
  68. package/dist/lib/di/container.js +94 -0
  69. package/dist/lib/di/container.js.map +7 -0
  70. package/dist/lib/email/send.js +12 -0
  71. package/dist/lib/email/send.js.map +7 -0
  72. package/dist/lib/encryption/aes.js +58 -0
  73. package/dist/lib/encryption/aes.js.map +7 -0
  74. package/dist/lib/encryption/customFieldValues.js +49 -0
  75. package/dist/lib/encryption/customFieldValues.js.map +7 -0
  76. package/dist/lib/encryption/entityFields.js +26 -0
  77. package/dist/lib/encryption/entityFields.js.map +7 -0
  78. package/dist/lib/encryption/entityIds.js +80 -0
  79. package/dist/lib/encryption/entityIds.js.map +7 -0
  80. package/dist/lib/encryption/find.js +45 -0
  81. package/dist/lib/encryption/find.js.map +7 -0
  82. package/dist/lib/encryption/indexDoc.js +69 -0
  83. package/dist/lib/encryption/indexDoc.js.map +7 -0
  84. package/dist/lib/encryption/kms.js +282 -0
  85. package/dist/lib/encryption/kms.js.map +7 -0
  86. package/dist/lib/encryption/subscriber.js +330 -0
  87. package/dist/lib/encryption/subscriber.js.map +7 -0
  88. package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
  89. package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
  90. package/dist/lib/encryption/toggles.js +18 -0
  91. package/dist/lib/encryption/toggles.js.map +7 -0
  92. package/dist/lib/entities/naming.js +9 -0
  93. package/dist/lib/entities/naming.js.map +7 -0
  94. package/dist/lib/entities/system-entities.js +43 -0
  95. package/dist/lib/entities/system-entities.js.map +7 -0
  96. package/dist/lib/frontend/organizationEvents.js +41 -0
  97. package/dist/lib/frontend/organizationEvents.js.map +7 -0
  98. package/dist/lib/frontend/useOrganizationScope.js +32 -0
  99. package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
  100. package/dist/lib/hotkeys/index.js +128 -0
  101. package/dist/lib/hotkeys/index.js.map +7 -0
  102. package/dist/lib/i18n/app-dictionaries.js +17 -0
  103. package/dist/lib/i18n/app-dictionaries.js.map +7 -0
  104. package/dist/lib/i18n/config.js +7 -0
  105. package/dist/lib/i18n/config.js.map +7 -0
  106. package/dist/lib/i18n/context.js +50 -0
  107. package/dist/lib/i18n/context.js.map +7 -0
  108. package/dist/lib/i18n/server.js +68 -0
  109. package/dist/lib/i18n/server.js.map +7 -0
  110. package/dist/lib/i18n/translate.js +45 -0
  111. package/dist/lib/i18n/translate.js.map +7 -0
  112. package/dist/lib/indexers/error-log.js +82 -0
  113. package/dist/lib/indexers/error-log.js.map +7 -0
  114. package/dist/lib/indexers/status-log.js +80 -0
  115. package/dist/lib/indexers/status-log.js.map +7 -0
  116. package/dist/lib/lib/auth/jwt.js +34 -0
  117. package/dist/lib/lib/auth/jwt.js.map +7 -0
  118. package/dist/lib/lib/auth/server.js +77 -0
  119. package/dist/lib/lib/auth/server.js.map +7 -0
  120. package/dist/lib/lib/email/send.js +12 -0
  121. package/dist/lib/lib/email/send.js.map +7 -0
  122. package/dist/lib/lib/i18n/config.js +7 -0
  123. package/dist/lib/lib/i18n/config.js.map +7 -0
  124. package/dist/lib/lib/i18n/context.js +31 -0
  125. package/dist/lib/lib/i18n/context.js.map +7 -0
  126. package/dist/lib/lib/utils.js +9 -0
  127. package/dist/lib/lib/utils.js.map +7 -0
  128. package/dist/lib/location/countries.js +68 -0
  129. package/dist/lib/location/countries.js.map +7 -0
  130. package/dist/lib/modules/index.js +6 -0
  131. package/dist/lib/modules/index.js.map +7 -0
  132. package/dist/lib/modules/registry.js +18 -0
  133. package/dist/lib/modules/registry.js.map +7 -0
  134. package/dist/lib/openapi/crud.js +137 -0
  135. package/dist/lib/openapi/crud.js.map +7 -0
  136. package/dist/lib/openapi/generator.js +1131 -0
  137. package/dist/lib/openapi/generator.js.map +7 -0
  138. package/dist/lib/openapi/index.js +10 -0
  139. package/dist/lib/openapi/index.js.map +7 -0
  140. package/dist/lib/openapi/sanitize.js +110 -0
  141. package/dist/lib/openapi/sanitize.js.map +7 -0
  142. package/dist/lib/openapi/types.js +1 -0
  143. package/dist/lib/openapi/types.js.map +7 -0
  144. package/dist/lib/profiler/index.js +258 -0
  145. package/dist/lib/profiler/index.js.map +7 -0
  146. package/dist/lib/query/engine.js +729 -0
  147. package/dist/lib/query/engine.js.map +7 -0
  148. package/dist/lib/query/join-utils.js +195 -0
  149. package/dist/lib/query/join-utils.js.map +7 -0
  150. package/dist/lib/query/types.js +9 -0
  151. package/dist/lib/query/types.js.map +7 -0
  152. package/dist/lib/search/config.js +32 -0
  153. package/dist/lib/search/config.js.map +7 -0
  154. package/dist/lib/search/tokenize.js +34 -0
  155. package/dist/lib/search/tokenize.js.map +7 -0
  156. package/dist/lib/slugify.js +24 -0
  157. package/dist/lib/slugify.js.map +7 -0
  158. package/dist/lib/testing/bootstrap.js +51 -0
  159. package/dist/lib/testing/bootstrap.js.map +7 -0
  160. package/dist/lib/testing/index.js +17 -0
  161. package/dist/lib/testing/index.js.map +7 -0
  162. package/dist/lib/testing/renderWithProviders.js +15 -0
  163. package/dist/lib/testing/renderWithProviders.js.map +7 -0
  164. package/dist/lib/url.js +12 -0
  165. package/dist/lib/url.js.map +7 -0
  166. package/dist/lib/utils.js +13 -0
  167. package/dist/lib/utils.js.map +7 -0
  168. package/dist/lib/version.js +7 -0
  169. package/dist/lib/version.js.map +7 -0
  170. package/dist/modules/dashboard/widgets.js +1 -0
  171. package/dist/modules/dashboard/widgets.js.map +7 -0
  172. package/dist/modules/dsl.js +30 -0
  173. package/dist/modules/dsl.js.map +7 -0
  174. package/dist/modules/entities/kinds.js +22 -0
  175. package/dist/modules/entities/kinds.js.map +7 -0
  176. package/dist/modules/entities/options.js +26 -0
  177. package/dist/modules/entities/options.js.map +7 -0
  178. package/dist/modules/entities/validation.js +102 -0
  179. package/dist/modules/entities/validation.js.map +7 -0
  180. package/dist/modules/entities/validators.js +88 -0
  181. package/dist/modules/entities/validators.js.map +7 -0
  182. package/dist/modules/entities.js +1 -0
  183. package/dist/modules/entities.js.map +7 -0
  184. package/dist/modules/navigation/sidebarPreferences.js +50 -0
  185. package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
  186. package/dist/modules/perspectives/types.js +1 -0
  187. package/dist/modules/perspectives/types.js.map +7 -0
  188. package/dist/modules/registry.js +96 -0
  189. package/dist/modules/registry.js.map +7 -0
  190. package/dist/modules/search.js +15 -0
  191. package/dist/modules/search.js.map +7 -0
  192. package/dist/modules/vector.js +1 -0
  193. package/dist/modules/vector.js.map +7 -0
  194. package/dist/modules/widgets/injection-loader.js +180 -0
  195. package/dist/modules/widgets/injection-loader.js.map +7 -0
  196. package/dist/modules/widgets/injection.js +1 -0
  197. package/dist/modules/widgets/injection.js.map +7 -0
  198. package/dist/security/features.js +23 -0
  199. package/dist/security/features.js.map +7 -0
  200. package/dist/types/pg.d.js +1 -0
  201. package/dist/types/pg.d.js.map +7 -0
  202. package/dist/types/react-email.d.js +1 -0
  203. package/dist/types/react-email.d.js.map +7 -0
  204. package/dist/types/resend.d.js +1 -0
  205. package/dist/types/resend.d.js.map +7 -0
  206. package/jest.config.cjs +22 -0
  207. package/package.json +88 -0
  208. package/src/index.ts +0 -0
  209. package/src/lib/api/__tests__/scoped.test.ts +38 -0
  210. package/src/lib/api/crud.ts +59 -0
  211. package/src/lib/api/scoped.ts +239 -0
  212. package/src/lib/auth/jwt.ts +39 -0
  213. package/src/lib/auth/server.ts +199 -0
  214. package/src/lib/boolean.ts +17 -0
  215. package/src/lib/bootstrap/appResolver.ts +85 -0
  216. package/src/lib/bootstrap/dynamicLoader.ts +177 -0
  217. package/src/lib/bootstrap/factory.ts +108 -0
  218. package/src/lib/bootstrap/index.ts +23 -0
  219. package/src/lib/bootstrap/types.ts +31 -0
  220. package/src/lib/cache/segments.ts +56 -0
  221. package/src/lib/cli/progress.ts +55 -0
  222. package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
  223. package/src/lib/commands/__tests__/helpers.test.ts +42 -0
  224. package/src/lib/commands/command-bus.ts +349 -0
  225. package/src/lib/commands/customFieldSnapshots.ts +86 -0
  226. package/src/lib/commands/helpers.ts +143 -0
  227. package/src/lib/commands/index.ts +4 -0
  228. package/src/lib/commands/operationMetadata.ts +40 -0
  229. package/src/lib/commands/registry.ts +46 -0
  230. package/src/lib/commands/scope.ts +59 -0
  231. package/src/lib/commands/types.ts +63 -0
  232. package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
  233. package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
  234. package/src/lib/crud/cache-stats.ts +127 -0
  235. package/src/lib/crud/cache.ts +205 -0
  236. package/src/lib/crud/custom-fields-client.ts +54 -0
  237. package/src/lib/crud/custom-fields.ts +607 -0
  238. package/src/lib/crud/errors.ts +23 -0
  239. package/src/lib/crud/exporters.ts +188 -0
  240. package/src/lib/crud/factory.ts +1622 -0
  241. package/src/lib/crud/types.ts +29 -0
  242. package/src/lib/custom-fields/normalize.ts +45 -0
  243. package/src/lib/data/engine.ts +562 -0
  244. package/src/lib/db/escapeLikePattern.ts +2 -0
  245. package/src/lib/db/mikro.ts +100 -0
  246. package/src/lib/di/container.ts +105 -0
  247. package/src/lib/email/send.ts +18 -0
  248. package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
  249. package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
  250. package/src/lib/encryption/aes.ts +64 -0
  251. package/src/lib/encryption/customFieldValues.ts +67 -0
  252. package/src/lib/encryption/entityFields.ts +39 -0
  253. package/src/lib/encryption/entityIds.ts +107 -0
  254. package/src/lib/encryption/find.ts +81 -0
  255. package/src/lib/encryption/indexDoc.ts +104 -0
  256. package/src/lib/encryption/kms.ts +337 -0
  257. package/src/lib/encryption/subscriber.ts +416 -0
  258. package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
  259. package/src/lib/encryption/toggles.ts +15 -0
  260. package/src/lib/entities/naming.ts +6 -0
  261. package/src/lib/entities/system-entities.ts +43 -0
  262. package/src/lib/frontend/organizationEvents.ts +55 -0
  263. package/src/lib/frontend/useOrganizationScope.ts +30 -0
  264. package/src/lib/hotkeys/index.ts +168 -0
  265. package/src/lib/i18n/app-dictionaries.ts +18 -0
  266. package/src/lib/i18n/config.ts +4 -0
  267. package/src/lib/i18n/context.tsx +66 -0
  268. package/src/lib/i18n/server.ts +74 -0
  269. package/src/lib/i18n/translate.ts +54 -0
  270. package/src/lib/indexers/error-log.ts +106 -0
  271. package/src/lib/indexers/status-log.ts +119 -0
  272. package/src/lib/lib/auth/jwt.ts +39 -0
  273. package/src/lib/lib/auth/server.ts +94 -0
  274. package/src/lib/lib/email/send.ts +18 -0
  275. package/src/lib/lib/i18n/config.ts +4 -0
  276. package/src/lib/lib/i18n/context.tsx +38 -0
  277. package/src/lib/lib/utils.ts +6 -0
  278. package/src/lib/location/countries.ts +97 -0
  279. package/src/lib/modules/index.ts +1 -0
  280. package/src/lib/modules/registry.ts +18 -0
  281. package/src/lib/openapi/crud.ts +218 -0
  282. package/src/lib/openapi/generator.ts +1311 -0
  283. package/src/lib/openapi/index.ts +4 -0
  284. package/src/lib/openapi/sanitize.ts +137 -0
  285. package/src/lib/openapi/types.ts +79 -0
  286. package/src/lib/profiler/index.ts +371 -0
  287. package/src/lib/query/__tests__/engine.test.ts +274 -0
  288. package/src/lib/query/engine.ts +837 -0
  289. package/src/lib/query/join-utils.ts +238 -0
  290. package/src/lib/query/types.ts +121 -0
  291. package/src/lib/search/config.ts +49 -0
  292. package/src/lib/search/tokenize.ts +45 -0
  293. package/src/lib/slugify.ts +28 -0
  294. package/src/lib/testing/bootstrap.ts +124 -0
  295. package/src/lib/testing/index.ts +15 -0
  296. package/src/lib/testing/renderWithProviders.tsx +31 -0
  297. package/src/lib/url.ts +12 -0
  298. package/src/lib/utils.ts +17 -0
  299. package/src/lib/version.ts +5 -0
  300. package/src/modules/__tests__/dsl.test.ts +35 -0
  301. package/src/modules/__tests__/registry.test.ts +300 -0
  302. package/src/modules/dashboard/widgets.ts +57 -0
  303. package/src/modules/dsl.ts +32 -0
  304. package/src/modules/entities/__tests__/validation.test.ts +52 -0
  305. package/src/modules/entities/kinds.ts +20 -0
  306. package/src/modules/entities/options.ts +36 -0
  307. package/src/modules/entities/validation.ts +118 -0
  308. package/src/modules/entities/validators.ts +93 -0
  309. package/src/modules/entities.ts +102 -0
  310. package/src/modules/navigation/sidebarPreferences.ts +62 -0
  311. package/src/modules/perspectives/types.ts +40 -0
  312. package/src/modules/registry.ts +249 -0
  313. package/src/modules/search.ts +325 -0
  314. package/src/modules/vector.ts +122 -0
  315. package/src/modules/widgets/__tests__/injection.test.ts +48 -0
  316. package/src/modules/widgets/injection-loader.ts +235 -0
  317. package/src/modules/widgets/injection.ts +120 -0
  318. package/src/security/features.ts +22 -0
  319. package/src/types/pg.d.ts +2 -0
  320. package/src/types/react-email.d.ts +2 -0
  321. package/src/types/resend.d.ts +2 -0
  322. package/tsconfig.build.json +11 -0
  323. package/tsconfig.json +9 -0
  324. package/watch.mjs +6 -0
@@ -0,0 +1,349 @@
1
+ import type { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities'
2
+ import type { ActionLogCreateInput } from '@open-mercato/core/modules/audit_logs/data/validators'
3
+ import { commandRegistry } from './registry'
4
+ import type {
5
+ CommandExecutionOptions,
6
+ CommandExecuteResult,
7
+ CommandHandler,
8
+ CommandLogBuilderArgs,
9
+ CommandLogMetadata,
10
+ CommandRuntimeContext,
11
+ } from './types'
12
+ import { defaultUndoToken } from './types'
13
+ import type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
14
+ import type { AwilixContainer } from 'awilix'
15
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
16
+ import {
17
+ canonicalizeResourceTag,
18
+ deriveResourceFromCommandId,
19
+ invalidateCrudCache,
20
+ pickFirstIdentifier,
21
+ isCrudCacheDebugEnabled,
22
+ } from '@open-mercato/shared/lib/crud/cache'
23
+
24
+ const SKIPPED_ACTION_LOG_RESOURCE_KINDS = new Set<string>([
25
+ 'audit_logs.access',
26
+ 'audit_logs.action',
27
+ 'dashboards.layout',
28
+ 'dashboards.user_widgets',
29
+ 'dashboards.role_widgets',
30
+ ])
31
+
32
+ function asRecord(input: unknown): Record<string, unknown> | null {
33
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null
34
+ return input as Record<string, unknown>
35
+ }
36
+
37
+ function extractAliasList(source: unknown): string[] {
38
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return []
39
+ const record = source as Record<string, unknown>
40
+ const raw = record.cacheAliases
41
+ if (!Array.isArray(raw)) return []
42
+ const aliases = new Set<string>()
43
+ for (const value of raw) {
44
+ if (typeof value !== 'string') continue
45
+ const normalized = canonicalizeResourceTag(value)
46
+ if (normalized) aliases.add(normalized)
47
+ }
48
+ return Array.from(aliases)
49
+ }
50
+
51
+ export class CommandBus {
52
+ async execute<TInput = unknown, TResult = unknown>(
53
+ commandId: string,
54
+ options: CommandExecutionOptions<TInput>
55
+ ): Promise<CommandExecuteResult<TResult>> {
56
+ const handler = this.resolveHandler<TInput, TResult>(commandId)
57
+ const snapshots = await this.prepareSnapshots(handler, options)
58
+ const result = await handler.execute(options.input, options.ctx)
59
+ const afterSnapshot = await this.captureAfter(handler, options, result)
60
+ const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
61
+ const logMeta = await this.buildLog(handler, options, result, snapshotsWithAfter)
62
+ let mergedMeta = this.mergeMetadata(options.metadata, logMeta)
63
+ const undoable = this.isUndoable(handler)
64
+ if (undoable) {
65
+ mergedMeta = mergedMeta ?? {}
66
+ if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken()
67
+ if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = options.ctx.auth?.sub ?? null
68
+ }
69
+ if (afterSnapshot !== undefined && afterSnapshot !== null) {
70
+ if (!mergedMeta) {
71
+ mergedMeta = { snapshotAfter: afterSnapshot }
72
+ } else if (!mergedMeta.snapshotAfter) {
73
+ mergedMeta.snapshotAfter = afterSnapshot
74
+ }
75
+ }
76
+ if (snapshots.before) {
77
+ if (!mergedMeta) {
78
+ mergedMeta = { snapshotBefore: snapshots.before }
79
+ } else if (!mergedMeta.snapshotBefore) {
80
+ mergedMeta.snapshotBefore = snapshots.before
81
+ }
82
+ }
83
+ const logEntry = await this.persistLog(commandId, options, mergedMeta)
84
+ await this.invalidateCacheAfterExecute(commandId, options, result, mergedMeta)
85
+ await this.flushCrudSideEffects(options.ctx.container)
86
+ return { result, logEntry }
87
+ }
88
+
89
+ async undo(undoToken: string, ctx: CommandRuntimeContext): Promise<void> {
90
+ const service = (ctx.container.resolve('actionLogService') as ActionLogService)
91
+ const log = await service.findByUndoToken(undoToken)
92
+ if (!log) throw new Error('Undo token expired or not found')
93
+ const handler = this.resolveHandler(log.commandId)
94
+ if (!handler.undo || this.isUndoable(handler) === false) {
95
+ throw new Error(`Command ${log.commandId} is not undoable`)
96
+ }
97
+ await handler.undo({
98
+ input: log.commandPayload as Parameters<NonNullable<typeof handler.undo>>[0]['input'],
99
+ ctx,
100
+ logEntry: log,
101
+ })
102
+ await service.markUndone(log.id)
103
+ await this.invalidateCacheAfterUndo(log, ctx)
104
+ await this.flushCrudSideEffects(ctx.container)
105
+ }
106
+
107
+ private resolveHandler<TInput, TResult>(commandId: string): CommandHandler<TInput, TResult> {
108
+ const handler = commandRegistry.get<TInput, TResult>(commandId)
109
+ if (!handler) throw new Error(`Command handler not registered for id ${commandId}`)
110
+ return handler
111
+ }
112
+
113
+ private async prepareSnapshots<TInput, TResult>(
114
+ handler: CommandHandler<TInput, TResult>,
115
+ options: CommandExecutionOptions<TInput>
116
+ ): Promise<{ before?: unknown }> {
117
+ if (!handler.prepare) return {}
118
+ try {
119
+ return (await handler.prepare(options.input, options.ctx)) || {}
120
+ } catch (err) {
121
+ throw err
122
+ }
123
+ }
124
+
125
+ private async captureAfter<TInput, TResult>(
126
+ handler: CommandHandler<TInput, TResult>,
127
+ options: CommandExecutionOptions<TInput>,
128
+ result: TResult
129
+ ): Promise<unknown> {
130
+ if (!handler.captureAfter) return undefined
131
+ return handler.captureAfter(options.input, result, options.ctx)
132
+ }
133
+
134
+ private async buildLog<TInput, TResult>(
135
+ handler: CommandHandler<TInput, TResult>,
136
+ options: CommandExecutionOptions<TInput>,
137
+ result: TResult,
138
+ snapshots: { before?: unknown; after?: unknown }
139
+ ): Promise<CommandLogMetadata | null> {
140
+ if (!handler.buildLog) return null
141
+ const args: CommandLogBuilderArgs<TInput, TResult> = {
142
+ input: options.input,
143
+ result,
144
+ ctx: options.ctx,
145
+ snapshots,
146
+ }
147
+ return (await handler.buildLog(args)) || null
148
+ }
149
+
150
+ private mergeMetadata(primary?: CommandLogMetadata | null, secondary?: CommandLogMetadata | null): CommandLogMetadata | null {
151
+ if (!primary && !secondary) return null
152
+ return {
153
+ tenantId: primary?.tenantId ?? secondary?.tenantId ?? null,
154
+ organizationId: primary?.organizationId ?? secondary?.organizationId ?? null,
155
+ actorUserId: primary?.actorUserId ?? secondary?.actorUserId ?? null,
156
+ actionLabel: primary?.actionLabel ?? secondary?.actionLabel ?? null,
157
+ resourceKind: primary?.resourceKind ?? secondary?.resourceKind ?? null,
158
+ resourceId: primary?.resourceId ?? secondary?.resourceId ?? null,
159
+ undoToken: primary?.undoToken ?? secondary?.undoToken ?? null,
160
+ payload: primary?.payload ?? secondary?.payload ?? null,
161
+ snapshotBefore: primary?.snapshotBefore ?? secondary?.snapshotBefore ?? null,
162
+ snapshotAfter: primary?.snapshotAfter ?? secondary?.snapshotAfter ?? null,
163
+ changes: primary?.changes ?? secondary?.changes ?? null,
164
+ context: primary?.context ?? secondary?.context ?? null,
165
+ }
166
+ }
167
+
168
+ private async persistLog<TInput>(
169
+ commandId: string,
170
+ options: CommandExecutionOptions<TInput>,
171
+ metadata: CommandLogMetadata | null
172
+ ): Promise<ActionLog | null> {
173
+ if (!metadata) return null
174
+ const resourceKind =
175
+ typeof metadata.resourceKind === 'string' ? metadata.resourceKind : null
176
+ if (resourceKind && SKIPPED_ACTION_LOG_RESOURCE_KINDS.has(resourceKind)) {
177
+ return null
178
+ }
179
+ let service: ActionLogService | null = null
180
+ try {
181
+ service = (options.ctx.container.resolve('actionLogService') as ActionLogService)
182
+ } catch {
183
+ service = null
184
+ }
185
+ if (!service) return null
186
+
187
+ const tenantId = metadata.tenantId ?? options.ctx.auth?.tenantId ?? null
188
+ const organizationId =
189
+ metadata.organizationId ?? options.ctx.selectedOrganizationId ?? options.ctx.auth?.orgId ?? null
190
+ const actorUserId = metadata.actorUserId ?? options.ctx.auth?.sub ?? null
191
+ const payload: Record<string, unknown> = {
192
+ tenantId: tenantId ?? undefined,
193
+ organizationId: organizationId ?? undefined,
194
+ actorUserId: actorUserId ?? undefined,
195
+ commandId,
196
+ }
197
+
198
+ if (metadata) {
199
+ if ('actionLabel' in metadata && metadata.actionLabel != null) payload.actionLabel = metadata.actionLabel
200
+ if ('resourceKind' in metadata && metadata.resourceKind != null) payload.resourceKind = metadata.resourceKind
201
+ if ('resourceId' in metadata && metadata.resourceId != null) payload.resourceId = metadata.resourceId
202
+ if ('undoToken' in metadata && metadata.undoToken != null) payload.undoToken = metadata.undoToken
203
+ if ('payload' in metadata && metadata.payload !== undefined) payload.commandPayload = metadata.payload
204
+ if ('snapshotBefore' in metadata && metadata.snapshotBefore !== undefined) payload.snapshotBefore = metadata.snapshotBefore
205
+ if ('snapshotAfter' in metadata && metadata.snapshotAfter !== undefined) payload.snapshotAfter = metadata.snapshotAfter
206
+ if ('changes' in metadata && metadata.changes !== undefined && metadata.changes !== null) payload.changes = metadata.changes
207
+ if ('context' in metadata && metadata.context !== undefined && metadata.context !== null) payload.context = metadata.context
208
+ }
209
+
210
+ const redoEnvelope = wrapRedoPayload('commandPayload' in payload ? (payload.commandPayload as unknown) : undefined, options.input)
211
+ payload.commandPayload = redoEnvelope
212
+
213
+ return await service.log(payload as ActionLogCreateInput)
214
+ }
215
+
216
+ private isUndoable(handler: CommandHandler<unknown, unknown>): boolean {
217
+ return handler.isUndoable !== false && typeof handler.undo === 'function'
218
+ }
219
+
220
+ private async invalidateCacheAfterExecute<TResult>(
221
+ commandId: string,
222
+ options: CommandExecutionOptions<unknown>,
223
+ result: TResult,
224
+ metadata: CommandLogMetadata | null
225
+ ): Promise<void> {
226
+ const resource = typeof metadata?.resourceKind === 'string' ? metadata.resourceKind : null
227
+ if (!resource) return
228
+ try {
229
+ const ctx = options.ctx
230
+ const resultRecord = asRecord(result)
231
+ const resultEntity = asRecord(resultRecord?.entity)
232
+ const inputRecord = asRecord(options.input)
233
+ const inputEntity = asRecord(inputRecord?.entity)
234
+
235
+ const recordId = pickFirstIdentifier(
236
+ metadata?.resourceId,
237
+ resultRecord?.entityId,
238
+ resultRecord?.id,
239
+ resultRecord?.recordId,
240
+ resultEntity?.id,
241
+ inputRecord?.id,
242
+ inputRecord?.entityId,
243
+ inputRecord?.recordId,
244
+ inputEntity?.id
245
+ )
246
+
247
+ const organizationId = pickFirstIdentifier(
248
+ metadata?.organizationId,
249
+ resultRecord?.organizationId,
250
+ resultEntity?.organizationId,
251
+ inputRecord?.organizationId,
252
+ inputEntity?.organizationId,
253
+ ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
254
+ )
255
+
256
+ const tenantId = pickFirstIdentifier(
257
+ metadata?.tenantId,
258
+ resultRecord?.tenantId,
259
+ resultEntity?.tenantId,
260
+ inputRecord?.tenantId,
261
+ inputEntity?.tenantId,
262
+ ctx.auth?.tenantId ?? null
263
+ )
264
+
265
+ const fallbackTenant = pickFirstIdentifier(metadata?.tenantId, ctx.auth?.tenantId ?? null)
266
+
267
+ const aliasSet = new Set<string>()
268
+ for (const alias of extractAliasList(metadata?.context ?? null)) {
269
+ aliasSet.add(alias)
270
+ }
271
+ const derived = deriveResourceFromCommandId(commandId)
272
+ if (derived) aliasSet.add(derived)
273
+ const aliasExtras = Array.from(aliasSet)
274
+ await invalidateCrudCache(
275
+ ctx.container,
276
+ resource,
277
+ { id: recordId, organizationId, tenantId },
278
+ fallbackTenant,
279
+ `command:${commandId}:execute`,
280
+ aliasExtras
281
+ )
282
+ } catch (err) {
283
+ if (isCrudCacheDebugEnabled()) {
284
+ try {
285
+ console.debug('[crud][cache] execute-invalidation failed', { commandId, err })
286
+ } catch {}
287
+ }
288
+ }
289
+ }
290
+
291
+ private async invalidateCacheAfterUndo(log: ActionLog, ctx: CommandRuntimeContext): Promise<void> {
292
+ const resource = typeof log.resourceKind === 'string' ? log.resourceKind : null
293
+ if (!resource) return
294
+ try {
295
+ const recordId = pickFirstIdentifier(log.resourceId)
296
+ const organizationId = pickFirstIdentifier(log.organizationId, ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null)
297
+ const tenantId = pickFirstIdentifier(log.tenantId, ctx.auth?.tenantId ?? null)
298
+ const fallbackTenant = pickFirstIdentifier(log.tenantId, ctx.auth?.tenantId ?? null)
299
+ const aliasSet = new Set<string>()
300
+ for (const alias of extractAliasList(log.contextJson ?? null)) {
301
+ aliasSet.add(alias)
302
+ }
303
+ const derived = deriveResourceFromCommandId(log.commandId)
304
+ if (derived) aliasSet.add(derived)
305
+ const aliasExtras = Array.from(aliasSet)
306
+ await invalidateCrudCache(
307
+ ctx.container,
308
+ resource,
309
+ { id: recordId, organizationId, tenantId },
310
+ fallbackTenant,
311
+ `command:${log.commandId}:undo`,
312
+ aliasExtras
313
+ )
314
+ } catch (err) {
315
+ if (isCrudCacheDebugEnabled()) {
316
+ try {
317
+ console.debug('[crud][cache] undo-invalidation failed', { commandId: log.commandId, err })
318
+ } catch {}
319
+ }
320
+ }
321
+ }
322
+
323
+ private async flushCrudSideEffects(container: AwilixContainer): Promise<void> {
324
+ try {
325
+ const dataEngine = (container.resolve('dataEngine') as DataEngine)
326
+ await dataEngine.flushOrmEntityChanges()
327
+ } catch {
328
+ // best-effort: failures should not block command execution
329
+ }
330
+ }
331
+ }
332
+
333
+ type RedoEnvelope = {
334
+ __redoInput: unknown
335
+ [key: string]: unknown
336
+ }
337
+
338
+ function wrapRedoPayload(existing: unknown, input: unknown): RedoEnvelope {
339
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
340
+ const envelope: RedoEnvelope = { __redoInput: input }
341
+ if (existing !== undefined) envelope.value = existing
342
+ return envelope
343
+ }
344
+ const current = existing as Record<string, unknown>
345
+ if ('__redoInput' in current && current.__redoInput !== undefined) {
346
+ return current as RedoEnvelope
347
+ }
348
+ return { __redoInput: input, ...current }
349
+ }
@@ -0,0 +1,86 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
3
+
4
+ export type CustomFieldSnapshot = Record<string, unknown>
5
+
6
+ type LoadSnapshotOptions = {
7
+ entityId: string
8
+ recordId: string
9
+ tenantId?: string | null
10
+ organizationId?: string | null
11
+ tenantFallbacks?: Array<string | null | undefined>
12
+ }
13
+
14
+ export async function loadCustomFieldSnapshot(
15
+ em: EntityManager,
16
+ { entityId, recordId, tenantId, organizationId, tenantFallbacks }: LoadSnapshotOptions
17
+ ): Promise<CustomFieldSnapshot> {
18
+ const tenant = tenantId ?? null
19
+ const organization = organizationId ?? undefined
20
+ const records = await loadCustomFieldValues({
21
+ em,
22
+ entityId: entityId as any,
23
+ recordIds: [recordId],
24
+ tenantIdByRecord: { [recordId]: tenant },
25
+ organizationIdByRecord: organization === undefined ? undefined : { [recordId]: organization ?? null },
26
+ tenantFallbacks: tenantFallbacks ?? [tenant],
27
+ })
28
+ const raw = records[recordId] ?? {}
29
+ const custom: Record<string, unknown> = {}
30
+ for (const [key, value] of Object.entries(raw)) {
31
+ if (key.startsWith('cf_')) custom[key.slice(3)] = value
32
+ }
33
+ return custom
34
+ }
35
+
36
+ export function buildCustomFieldResetMap(
37
+ before: CustomFieldSnapshot | undefined,
38
+ after: CustomFieldSnapshot | undefined
39
+ ): Record<string, unknown> {
40
+ const values: Record<string, unknown> = {}
41
+ const keys = new Set<string>()
42
+ if (before) for (const key of Object.keys(before)) keys.add(key)
43
+ if (after) for (const key of Object.keys(after)) keys.add(key)
44
+ for (const key of keys) {
45
+ const hasBefore = Boolean(before && Object.prototype.hasOwnProperty.call(before, key))
46
+ if (hasBefore) {
47
+ const beforeValue = before?.[key]
48
+ if (beforeValue === null && Array.isArray(after?.[key])) {
49
+ values[key] = []
50
+ } else {
51
+ values[key] = beforeValue
52
+ }
53
+ } else {
54
+ values[key] = Array.isArray(after?.[key]) ? [] : null
55
+ }
56
+ }
57
+ return values
58
+ }
59
+
60
+ export type CustomFieldChangeSet = Record<string, { from: unknown; to: unknown }>
61
+
62
+ export function diffCustomFieldChanges(
63
+ before: CustomFieldSnapshot | undefined,
64
+ after: CustomFieldSnapshot | undefined
65
+ ): CustomFieldChangeSet {
66
+ const out: CustomFieldChangeSet = {}
67
+ const keys = new Set<string>()
68
+ if (before) for (const key of Object.keys(before)) keys.add(key)
69
+ if (after) for (const key of Object.keys(after)) keys.add(key)
70
+ for (const key of keys) {
71
+ const prev = before ? before[key] : undefined
72
+ const next = after ? after[key] : undefined
73
+ if (!customFieldValuesEqual(prev, next)) {
74
+ out[key] = { from: prev ?? null, to: next ?? null }
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ function customFieldValuesEqual(a: unknown, b: unknown): boolean {
81
+ if (Array.isArray(a) && Array.isArray(b)) {
82
+ if (a.length !== b.length) return false
83
+ return a.every((value, idx) => customFieldValuesEqual(value, b[idx]))
84
+ }
85
+ return a === b
86
+ }
@@ -0,0 +1,143 @@
1
+ import { splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'
2
+ import type { z } from 'zod'
3
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
5
+ import { normalizeCustomFieldValues } from '../custom-fields/normalize'
6
+ export { normalizeCustomFieldValues } from '../custom-fields/normalize'
7
+ import type { CrudEventsConfig, CrudIndexerConfig, CrudEmitContext } from '@open-mercato/shared/lib/crud/types'
8
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
9
+ import type { CommandLogMetadata } from '@open-mercato/shared/lib/commands'
10
+
11
+ export type ParsedPayload<TSchema extends z.ZodTypeAny> = {
12
+ parsed: z.infer<TSchema>
13
+ custom: Record<string, unknown>
14
+ }
15
+
16
+ export function parseWithCustomFields<TSchema extends z.ZodTypeAny>(
17
+ schema: TSchema,
18
+ raw: unknown
19
+ ): ParsedPayload<TSchema> {
20
+ const { base, custom } = splitCustomFieldPayload(raw)
21
+ const parsed = schema.parse(base)
22
+ return { parsed, custom }
23
+ }
24
+
25
+ export async function setCustomFieldsIfAny(opts: {
26
+ dataEngine: DataEngine
27
+ entityId: string
28
+ recordId: string
29
+ tenantId: string | null
30
+ organizationId: string | null
31
+ values: Record<string, unknown>
32
+ notify?: boolean
33
+ }) {
34
+ const { values } = opts
35
+ if (!values || !Object.keys(values).length) return
36
+ const { dataEngine, entityId, recordId, tenantId, organizationId, notify = false } = opts
37
+ const normalized = normalizeCustomFieldValues(values)
38
+ await dataEngine.setCustomFields({
39
+ entityId,
40
+ recordId,
41
+ tenantId,
42
+ organizationId,
43
+ values: normalized,
44
+ notify,
45
+ })
46
+ }
47
+
48
+ export async function emitCrudSideEffects<TEntity>(opts: {
49
+ dataEngine: DataEngine
50
+ action: 'created' | 'updated' | 'deleted'
51
+ entity: TEntity
52
+ identifiers: CrudEmitContext<TEntity>['identifiers']
53
+ events?: CrudEventsConfig<TEntity>
54
+ indexer?: CrudIndexerConfig<TEntity>
55
+ }) {
56
+ const { dataEngine, action, entity, identifiers, events, indexer } = opts
57
+ dataEngine.markOrmEntityChange({
58
+ action,
59
+ entity,
60
+ identifiers,
61
+ events,
62
+ indexer,
63
+ })
64
+ }
65
+
66
+ export async function emitCrudUndoSideEffects<TEntity>(opts: {
67
+ dataEngine: DataEngine
68
+ action: 'created' | 'updated' | 'deleted'
69
+ entity: TEntity | null | undefined
70
+ identifiers: CrudEmitContext<TEntity>['identifiers']
71
+ events?: CrudEventsConfig<TEntity>
72
+ indexer?: CrudIndexerConfig<TEntity>
73
+ }) {
74
+ const { dataEngine, action, entity, identifiers, events, indexer } = opts
75
+ if (!entity) return
76
+ dataEngine.markOrmEntityChange({
77
+ action,
78
+ entity,
79
+ identifiers,
80
+ events,
81
+ indexer,
82
+ })
83
+ }
84
+
85
+ export async function flushCrudSideEffects(dataEngine: DataEngine): Promise<void> {
86
+ await dataEngine.flushOrmEntityChanges()
87
+ }
88
+
89
+ export function buildChanges(
90
+ before: Record<string, unknown> | null | undefined,
91
+ after: Record<string, unknown>,
92
+ keys: readonly string[]
93
+ ): Record<string, { from: unknown; to: unknown }> {
94
+ if (!before) return {}
95
+ const diff: Record<string, { from: unknown; to: unknown }> = {}
96
+ for (const key of keys) {
97
+ const prev = before[key]
98
+ const next = after[key]
99
+ if (prev !== next) diff[key] = { from: prev, to: next }
100
+ }
101
+ return diff
102
+ }
103
+
104
+ export function requireTenantScope(authTenantId: string | null, requested?: string | null): string {
105
+ if (authTenantId && requested && requested !== authTenantId) {
106
+ throw new CrudHttpError(403, { error: 'Forbidden' })
107
+ }
108
+ const tenantId = requested || authTenantId
109
+ if (!tenantId) throw new CrudHttpError(400, { error: 'Tenant scope required' })
110
+ return tenantId
111
+ }
112
+
113
+ export function requireId(value: unknown, message = 'ID is required'): string {
114
+ if (typeof value === 'string' && value.trim()) return value
115
+ if (typeof value === 'number' || typeof value === 'bigint') return String(value)
116
+ if (value && typeof value === 'object') {
117
+ const source = value as Record<string, unknown>
118
+ const candidates: unknown[] = [
119
+ source.id,
120
+ source.recordId,
121
+ isRecord(source.body) ? source.body.id : undefined,
122
+ isRecord(source.query) ? source.query.id : undefined,
123
+ ]
124
+ for (const candidate of candidates) {
125
+ if (typeof candidate === 'string' && candidate.trim()) return candidate
126
+ if (typeof candidate === 'number' || typeof candidate === 'bigint') return String(candidate)
127
+ }
128
+ }
129
+ throw new CrudHttpError(400, { error: message })
130
+ }
131
+
132
+ function isRecord(input: unknown): input is { [key: string]: unknown } {
133
+ return !!input && typeof input === 'object'
134
+ }
135
+
136
+ export type LogBuilderArgs<TInput, TResult> = {
137
+ input: TInput
138
+ result: TResult
139
+ ctx: CommandRuntimeContext
140
+ snapshots: { before?: unknown; after?: unknown }
141
+ }
142
+
143
+ export type LogBuilder<TInput, TResult> = (args: LogBuilderArgs<TInput, TResult>) => CommandLogMetadata | null | Promise<CommandLogMetadata | null>
@@ -0,0 +1,4 @@
1
+ export * from './types'
2
+ export * from './registry'
3
+ export { CommandBus } from './command-bus'
4
+ export * from './customFieldSnapshots'
@@ -0,0 +1,40 @@
1
+ export type OperationMetadataPayload = {
2
+ id: string
3
+ undoToken: string
4
+ commandId: string
5
+ actionLabel: string | null
6
+ resourceKind: string | null
7
+ resourceId: string | null
8
+ executedAt: string
9
+ }
10
+
11
+ const HEADER_PREFIX = 'omop:'
12
+
13
+ export function serializeOperationMetadata(payload: OperationMetadataPayload): string {
14
+ const encoded = encodeURIComponent(JSON.stringify(payload))
15
+ return `${HEADER_PREFIX}${encoded}`
16
+ }
17
+
18
+ export function deserializeOperationMetadata(value: string | null | undefined): OperationMetadataPayload | null {
19
+ if (!value || typeof value !== 'string') return null
20
+ const trimmed = value.startsWith(HEADER_PREFIX) ? value.slice(HEADER_PREFIX.length) : value
21
+ try {
22
+ const parsed = JSON.parse(decodeURIComponent(trimmed))
23
+ if (!parsed || typeof parsed !== 'object') return null
24
+ if (typeof parsed.id !== 'string' || typeof parsed.commandId !== 'string') return null
25
+ if (typeof parsed.undoToken !== 'string' || !parsed.undoToken) return null
26
+ if (typeof parsed.executedAt !== 'string') return null
27
+ return {
28
+ id: parsed.id,
29
+ undoToken: parsed.undoToken,
30
+ commandId: parsed.commandId,
31
+ actionLabel: parsed.actionLabel ?? null,
32
+ resourceKind: parsed.resourceKind ?? null,
33
+ resourceId: parsed.resourceId ?? null,
34
+ executedAt: parsed.executedAt,
35
+ }
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
@@ -0,0 +1,46 @@
1
+ import type { CommandHandler } from './types'
2
+
3
+ class CommandRegistry {
4
+ private handlers = new Map<string, CommandHandler>()
5
+
6
+ register(handler: CommandHandler) {
7
+ if (!handler?.id) throw new Error('Command handler must define an id')
8
+ if (this.handlers.has(handler.id)) {
9
+ throw new Error(`Duplicate command registration for id ${handler.id}`)
10
+ }
11
+ this.handlers.set(handler.id, handler)
12
+ }
13
+
14
+ unregister(id: string) {
15
+ this.handlers.delete(id)
16
+ }
17
+
18
+ get<TInput = unknown, TResult = unknown>(id: string): CommandHandler<TInput, TResult> | null {
19
+ return (this.handlers.get(id) as CommandHandler<TInput, TResult> | undefined) ?? null
20
+ }
21
+
22
+ has(id: string): boolean {
23
+ return this.handlers.has(id)
24
+ }
25
+
26
+ /**
27
+ * List all registered command handler IDs.
28
+ */
29
+ list(): string[] {
30
+ return Array.from(this.handlers.keys())
31
+ }
32
+
33
+ clear() {
34
+ this.handlers.clear()
35
+ }
36
+ }
37
+
38
+ export const commandRegistry = new CommandRegistry()
39
+
40
+ export function registerCommand(handler: CommandHandler) {
41
+ commandRegistry.register(handler)
42
+ }
43
+
44
+ export function unregisterCommand(id: string) {
45
+ commandRegistry.unregister(id)
46
+ }