@stonecrop/stonecrop 0.4.36 → 0.5.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.
Files changed (81) hide show
  1. package/README.md +92 -3
  2. package/dist/src/composable.d.ts +74 -8
  3. package/dist/src/composable.d.ts.map +1 -1
  4. package/dist/src/composable.js +348 -0
  5. package/dist/src/composables/operation-log.d.ts +136 -0
  6. package/dist/src/composables/operation-log.d.ts.map +1 -0
  7. package/dist/src/composables/operation-log.js +221 -0
  8. package/dist/src/doctype.d.ts +9 -1
  9. package/dist/src/doctype.d.ts.map +1 -1
  10. package/dist/{doctype.js → src/doctype.js} +9 -3
  11. package/dist/src/exceptions.d.ts +2 -3
  12. package/dist/src/exceptions.d.ts.map +1 -1
  13. package/dist/{exceptions.js → src/exceptions.js} +5 -11
  14. package/dist/src/field-triggers.d.ts +178 -0
  15. package/dist/src/field-triggers.d.ts.map +1 -0
  16. package/dist/src/field-triggers.js +564 -0
  17. package/dist/src/index.d.ts +12 -4
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +18 -0
  20. package/dist/src/plugins/index.d.ts +11 -13
  21. package/dist/src/plugins/index.d.ts.map +1 -1
  22. package/dist/src/plugins/index.js +90 -0
  23. package/dist/src/registry.d.ts +9 -3
  24. package/dist/src/registry.d.ts.map +1 -1
  25. package/dist/{registry.js → src/registry.js} +14 -1
  26. package/dist/src/stonecrop.d.ts +350 -114
  27. package/dist/src/stonecrop.d.ts.map +1 -1
  28. package/dist/src/stonecrop.js +251 -0
  29. package/dist/src/stores/hst.d.ts +157 -0
  30. package/dist/src/stores/hst.d.ts.map +1 -0
  31. package/dist/src/stores/hst.js +483 -0
  32. package/dist/src/stores/index.d.ts +5 -1
  33. package/dist/src/stores/index.d.ts.map +1 -1
  34. package/dist/{stores → src/stores}/index.js +4 -1
  35. package/dist/src/stores/operation-log.d.ts +268 -0
  36. package/dist/src/stores/operation-log.d.ts.map +1 -0
  37. package/dist/src/stores/operation-log.js +571 -0
  38. package/dist/src/types/field-triggers.d.ts +186 -0
  39. package/dist/src/types/field-triggers.d.ts.map +1 -0
  40. package/dist/src/types/field-triggers.js +4 -0
  41. package/dist/src/types/index.d.ts +13 -2
  42. package/dist/src/types/index.d.ts.map +1 -1
  43. package/dist/src/types/index.js +4 -0
  44. package/dist/src/types/operation-log.d.ts +165 -0
  45. package/dist/src/types/operation-log.d.ts.map +1 -0
  46. package/dist/src/types/registry.d.ts +11 -0
  47. package/dist/src/types/registry.d.ts.map +1 -0
  48. package/dist/src/types/registry.js +0 -0
  49. package/dist/stonecrop.d.ts +1555 -159
  50. package/dist/stonecrop.js +1974 -7035
  51. package/dist/stonecrop.js.map +1 -1
  52. package/dist/stonecrop.umd.cjs +4 -8
  53. package/dist/stonecrop.umd.cjs.map +1 -1
  54. package/dist/tests/setup.d.ts +5 -0
  55. package/dist/tests/setup.d.ts.map +1 -0
  56. package/dist/tests/setup.js +15 -0
  57. package/package.json +18 -16
  58. package/src/composable.ts +481 -33
  59. package/src/composables/operation-log.ts +254 -0
  60. package/src/doctype.ts +9 -3
  61. package/src/exceptions.ts +5 -12
  62. package/src/field-triggers.ts +671 -0
  63. package/src/index.ts +50 -4
  64. package/src/plugins/index.ts +70 -22
  65. package/src/registry.ts +18 -3
  66. package/src/stonecrop.ts +246 -151
  67. package/src/stores/hst.ts +703 -0
  68. package/src/stores/index.ts +6 -1
  69. package/src/stores/operation-log.ts +671 -0
  70. package/src/types/field-triggers.ts +201 -0
  71. package/src/types/index.ts +17 -6
  72. package/src/types/operation-log.ts +205 -0
  73. package/src/types/registry.ts +10 -0
  74. package/dist/composable.js +0 -51
  75. package/dist/index.js +0 -6
  76. package/dist/plugins/index.js +0 -49
  77. package/dist/src/stores/data.d.ts +0 -11
  78. package/dist/src/stores/data.d.ts.map +0 -1
  79. package/dist/stores/data.js +0 -7
  80. package/src/stores/data.ts +0 -8
  81. /package/dist/{types/index.js → src/types/operation-log.js} +0 -0
@@ -0,0 +1,671 @@
1
+ /* eslint-disable no-new-func, no-eval */
2
+ import type { Map as ImmutableMap } from 'immutable'
3
+ import { useOperationLogStore } from './stores/operation-log'
4
+ import type {
5
+ FieldActionFunction,
6
+ FieldChangeContext,
7
+ FieldTriggerExecutionResult,
8
+ FieldTriggerOptions,
9
+ ActionExecutionResult,
10
+ TransitionChangeContext,
11
+ TransitionActionFunction,
12
+ TransitionExecutionResult,
13
+ } from './types/field-triggers'
14
+
15
+ /**
16
+ * Field trigger execution engine integrated with Registry
17
+ * Singleton pattern following Registry implementation
18
+ * @public
19
+ */
20
+ export class FieldTriggerEngine {
21
+ /**
22
+ * The root FieldTriggerEngine instance
23
+ */
24
+ static _root: FieldTriggerEngine
25
+
26
+ private options: FieldTriggerOptions & { defaultTimeout: number; debug: boolean; enableRollback: boolean }
27
+ private doctypeActions = new Map<string, Map<string, string[]>>() // doctype -> action/field -> functions
28
+ private doctypeTransitions = new Map<string, Map<string, string[]>>() // doctype -> transition -> functions
29
+ private fieldRollbackConfig = new Map<string, Map<string, boolean>>() // doctype -> field -> rollback enabled
30
+ private globalActions = new Map<string, FieldActionFunction>() // action name -> function
31
+ private globalTransitionActions = new Map<string, TransitionActionFunction>() // transition action name -> function
32
+
33
+ /**
34
+ * Creates a new FieldTriggerEngine instance (singleton pattern)
35
+ * @param options - Configuration options for the field trigger engine
36
+ */
37
+ constructor(options: FieldTriggerOptions = {}) {
38
+ if (FieldTriggerEngine._root) {
39
+ return FieldTriggerEngine._root
40
+ }
41
+ FieldTriggerEngine._root = this
42
+ this.options = {
43
+ defaultTimeout: options.defaultTimeout ?? 5000,
44
+ debug: options.debug ?? false,
45
+ enableRollback: options.enableRollback ?? true,
46
+ errorHandler: options.errorHandler,
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Register a global action function
52
+ * @param name - The name of the action
53
+ * @param fn - The action function
54
+ */
55
+ registerAction(name: string, fn: FieldActionFunction): void {
56
+ this.globalActions.set(name, fn)
57
+ }
58
+
59
+ /**
60
+ * Register a global XState transition action function
61
+ * @param name - The name of the transition action
62
+ * @param fn - The transition action function
63
+ */
64
+ registerTransitionAction(name: string, fn: TransitionActionFunction): void {
65
+ this.globalTransitionActions.set(name, fn)
66
+ }
67
+
68
+ /**
69
+ * Configure rollback behavior for a specific field trigger
70
+ * @param doctype - The doctype name
71
+ * @param fieldname - The field name
72
+ * @param enableRollback - Whether to enable rollback
73
+ */
74
+ setFieldRollback(doctype: string, fieldname: string, enableRollback: boolean): void {
75
+ if (!this.fieldRollbackConfig.has(doctype)) {
76
+ this.fieldRollbackConfig.set(doctype, new Map())
77
+ }
78
+ this.fieldRollbackConfig.get(doctype)!.set(fieldname, enableRollback)
79
+ }
80
+
81
+ /**
82
+ * Get rollback configuration for a specific field trigger
83
+ */
84
+ private getFieldRollback(doctype: string, fieldname: string): boolean | undefined {
85
+ return this.fieldRollbackConfig.get(doctype)?.get(fieldname)
86
+ }
87
+
88
+ /**
89
+ * Register actions from a doctype - both regular actions and field triggers
90
+ * Separates XState transitions (uppercase) from field triggers (lowercase)
91
+ * @param doctype - The doctype name
92
+ * @param actions - The actions to register (supports Immutable Map, Map, or plain object)
93
+ */
94
+ registerDoctypeActions(
95
+ doctype: string,
96
+ actions: ImmutableMap<string, string[]> | Map<string, string[]> | Record<string, string[]> | undefined
97
+ ): void {
98
+ if (!actions) return
99
+
100
+ const actionMap = new Map<string, string[]>()
101
+ const transitionMap = new Map<string, string[]>()
102
+
103
+ // Convert from different Map types to regular Map
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ if (typeof (actions as any).entrySeq === 'function') {
106
+ // Immutable Map
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ ;(actions as any).entrySeq().forEach(([key, value]: [string, string[]]) => {
109
+ this.categorizeAction(key, value, actionMap, transitionMap)
110
+ })
111
+ } else if (actions instanceof Map) {
112
+ // Regular Map
113
+ for (const [key, value] of actions) {
114
+ this.categorizeAction(key, value, actionMap, transitionMap)
115
+ }
116
+ } else if (actions && typeof actions === 'object') {
117
+ // Plain object
118
+ Object.entries(actions).forEach(([key, value]) => {
119
+ this.categorizeAction(key, value as string[], actionMap, transitionMap)
120
+ })
121
+ }
122
+
123
+ // Always set the maps, even if empty
124
+ this.doctypeActions.set(doctype, actionMap)
125
+ this.doctypeTransitions.set(doctype, transitionMap)
126
+ }
127
+
128
+ /**
129
+ * Categorize an action as either a field trigger or XState transition
130
+ * Uses uppercase convention: UPPERCASE = transition, lowercase/mixed = field trigger
131
+ */
132
+ private categorizeAction(
133
+ key: string,
134
+ value: string[],
135
+ actionMap: Map<string, string[]>,
136
+ transitionMap: Map<string, string[]>
137
+ ): void {
138
+ // Check if the key is all uppercase (XState transition convention)
139
+ if (this.isTransitionKey(key)) {
140
+ transitionMap.set(key, value)
141
+ } else {
142
+ actionMap.set(key, value)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Determine if a key represents an XState transition
148
+ * Transitions are identified by being all uppercase
149
+ */
150
+ private isTransitionKey(key: string): boolean {
151
+ // Must be all uppercase letters/numbers/underscores
152
+ return /^[A-Z0-9_]+$/.test(key) && key.length > 0
153
+ }
154
+
155
+ /**
156
+ * Execute field triggers for a changed field
157
+ * @param context - The field change context
158
+ * @param options - Execution options (timeout and enableRollback)
159
+ */
160
+ async executeFieldTriggers(
161
+ context: FieldChangeContext,
162
+ options: { timeout?: number; enableRollback?: boolean } = {}
163
+ ): Promise<FieldTriggerExecutionResult> {
164
+ const { doctype, fieldname } = context
165
+ const triggers = this.findFieldTriggers(doctype, fieldname)
166
+
167
+ if (triggers.length === 0) {
168
+ return {
169
+ path: context.path,
170
+ actionResults: [],
171
+ totalExecutionTime: 0,
172
+ allSucceeded: true,
173
+ stoppedOnError: false,
174
+ rolledBack: false,
175
+ }
176
+ }
177
+
178
+ const startTime = performance.now()
179
+ const actionResults: ActionExecutionResult[] = []
180
+ let stoppedOnError = false
181
+ let rolledBack = false
182
+ let snapshot: any = undefined
183
+
184
+ // Determine if rollback is enabled (priority: execution option > field config > global setting)
185
+ const fieldRollbackConfig = this.getFieldRollback(doctype, fieldname)
186
+ const rollbackEnabled = options.enableRollback ?? fieldRollbackConfig ?? this.options.enableRollback
187
+
188
+ // Capture snapshot before executing actions if rollback is enabled
189
+ if (rollbackEnabled && context.store) {
190
+ snapshot = this.captureSnapshot(context)
191
+ }
192
+
193
+ // Execute actions sequentially
194
+ for (const actionName of triggers) {
195
+ try {
196
+ const actionResult = await this.executeAction(actionName, context, options.timeout)
197
+ actionResults.push(actionResult)
198
+
199
+ if (!actionResult.success) {
200
+ stoppedOnError = true
201
+ break
202
+ }
203
+ } catch (error) {
204
+ const actionError = error instanceof Error ? error : new Error(String(error))
205
+ const errorResult: ActionExecutionResult = {
206
+ success: false,
207
+ error: actionError,
208
+ executionTime: 0,
209
+ action: actionName,
210
+ }
211
+ actionResults.push(errorResult)
212
+ stoppedOnError = true
213
+ break
214
+ }
215
+ }
216
+
217
+ // Perform rollback if enabled, errors occurred, and we have a snapshot
218
+ if (rollbackEnabled && stoppedOnError && snapshot && context.store) {
219
+ try {
220
+ this.restoreSnapshot(context, snapshot)
221
+ rolledBack = true
222
+ } catch (rollbackError) {
223
+ // eslint-disable-next-line no-console
224
+ console.error('[FieldTriggers] Rollback failed:', rollbackError)
225
+ }
226
+ }
227
+
228
+ const totalExecutionTime = performance.now() - startTime
229
+
230
+ // Call global error handler if configured and errors occurred
231
+ const failedResults = actionResults.filter(r => !r.success)
232
+ if (failedResults.length > 0 && this.options.errorHandler) {
233
+ for (const failedResult of failedResults) {
234
+ try {
235
+ this.options.errorHandler(failedResult.error!, context, failedResult.action)
236
+ } catch (handlerError) {
237
+ // eslint-disable-next-line no-console
238
+ console.error('[FieldTriggers] Error in global error handler:', handlerError)
239
+ }
240
+ }
241
+ }
242
+
243
+ const result: FieldTriggerExecutionResult = {
244
+ path: context.path,
245
+ actionResults,
246
+ totalExecutionTime,
247
+ allSucceeded: actionResults.every(r => r.success),
248
+ stoppedOnError,
249
+ rolledBack,
250
+ snapshot: this.options.debug && rollbackEnabled ? snapshot : undefined, // Only include snapshot in debug mode if rollback is enabled
251
+ }
252
+
253
+ return result
254
+ }
255
+
256
+ /**
257
+ * Execute XState transition actions
258
+ * Similar to field triggers but specifically for FSM state transitions
259
+ * @param context - The transition change context
260
+ * @param options - Execution options (timeout)
261
+ */
262
+ async executeTransitionActions(
263
+ context: TransitionChangeContext,
264
+ options: { timeout?: number } = {}
265
+ ): Promise<TransitionExecutionResult[]> {
266
+ const { doctype, transition } = context
267
+ const transitionActions = this.findTransitionActions(doctype, transition)
268
+
269
+ if (transitionActions.length === 0) {
270
+ return []
271
+ }
272
+
273
+ const results: TransitionExecutionResult[] = []
274
+
275
+ // Execute transition actions sequentially
276
+ for (const actionName of transitionActions) {
277
+ try {
278
+ const actionResult = await this.executeTransitionAction(actionName, context, options.timeout)
279
+ results.push(actionResult)
280
+
281
+ if (!actionResult.success) {
282
+ // Stop on first error for transitions
283
+ break
284
+ }
285
+ } catch (error) {
286
+ const actionError = error instanceof Error ? error : new Error(String(error))
287
+ const errorResult: TransitionExecutionResult = {
288
+ success: false,
289
+ error: actionError,
290
+ executionTime: 0,
291
+ action: actionName,
292
+ transition,
293
+ }
294
+ results.push(errorResult)
295
+ break
296
+ }
297
+ }
298
+
299
+ // Call global error handler if configured and errors occurred
300
+ const failedResults = results.filter(r => !r.success)
301
+ if (failedResults.length > 0 && this.options.errorHandler) {
302
+ for (const failedResult of failedResults) {
303
+ try {
304
+ // Call with FieldChangeContext (base context type)
305
+ this.options.errorHandler(failedResult.error!, context, failedResult.action)
306
+ } catch (handlerError) {
307
+ // eslint-disable-next-line no-console
308
+ console.error('[FieldTriggers] Error in global error handler:', handlerError)
309
+ }
310
+ }
311
+ }
312
+
313
+ return results
314
+ }
315
+
316
+ /**
317
+ * Find transition actions for a specific doctype and transition
318
+ */
319
+ private findTransitionActions(doctype: string, transition: string): string[] {
320
+ const doctypeTransitions = this.doctypeTransitions.get(doctype)
321
+ if (!doctypeTransitions) return []
322
+
323
+ return doctypeTransitions.get(transition) || []
324
+ }
325
+
326
+ /**
327
+ * Execute a single transition action by name
328
+ */
329
+ private async executeTransitionAction(
330
+ actionName: string,
331
+ context: TransitionChangeContext,
332
+ timeout?: number
333
+ ): Promise<TransitionExecutionResult> {
334
+ const startTime = performance.now()
335
+ const actionTimeout = timeout ?? this.options.defaultTimeout
336
+
337
+ try {
338
+ // Look up action in transition-specific registry first, then fall back to global
339
+ let actionFn = this.globalTransitionActions.get(actionName)
340
+
341
+ // If not found in transition registry, try regular action registry
342
+ // This allows sharing actions between field triggers and transitions
343
+ if (!actionFn) {
344
+ const regularActionFn = this.globalActions.get(actionName)
345
+ if (regularActionFn) {
346
+ // Wrap regular action to accept TransitionChangeContext
347
+ actionFn = regularActionFn as unknown as TransitionActionFunction
348
+ }
349
+ }
350
+
351
+ if (!actionFn) {
352
+ throw new Error(`Transition action "${actionName}" not found in registry`)
353
+ }
354
+
355
+ await this.executeWithTimeout(actionFn as FieldActionFunction, context, actionTimeout)
356
+ const executionTime = performance.now() - startTime
357
+
358
+ return {
359
+ success: true,
360
+ executionTime,
361
+ action: actionName,
362
+ transition: context.transition,
363
+ }
364
+ } catch (error) {
365
+ const executionTime = performance.now() - startTime
366
+ const actionError = error instanceof Error ? error : new Error(String(error))
367
+
368
+ return {
369
+ success: false,
370
+ error: actionError,
371
+ executionTime,
372
+ action: actionName,
373
+ transition: context.transition,
374
+ }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Find field triggers for a specific doctype and field
380
+ * Field triggers are identified by keys that look like field paths (contain dots or match field names)
381
+ */
382
+ private findFieldTriggers(doctype: string, fieldname: string): string[] {
383
+ const doctypeActions = this.doctypeActions.get(doctype)
384
+ if (!doctypeActions) return []
385
+
386
+ const triggers: string[] = []
387
+
388
+ for (const [key, actionNames] of doctypeActions) {
389
+ // Check if this key is a field trigger pattern
390
+ if (this.isFieldTriggerKey(key, fieldname)) {
391
+ triggers.push(...actionNames)
392
+ }
393
+ }
394
+
395
+ return triggers
396
+ }
397
+
398
+ /**
399
+ * Determine if an action key represents a field trigger
400
+ * Field triggers can be:
401
+ * - Exact field name match: "emailAddress"
402
+ * - Wildcard patterns: "emailAddress.*", "*.is_primary"
403
+ * - Nested field paths: "address.street", "contact.email"
404
+ */
405
+ private isFieldTriggerKey(key: string, fieldname: string): boolean {
406
+ // Exact match
407
+ if (key === fieldname) return true
408
+
409
+ // Contains dots - likely a field path pattern
410
+ if (key.includes('.')) {
411
+ return this.matchFieldPattern(key, fieldname)
412
+ }
413
+
414
+ // Contains wildcards
415
+ if (key.includes('*')) {
416
+ return this.matchFieldPattern(key, fieldname)
417
+ }
418
+
419
+ return false
420
+ }
421
+
422
+ /**
423
+ * Match a field pattern against a field name
424
+ * Supports wildcards (*) for dynamic segments
425
+ */
426
+ private matchFieldPattern(pattern: string, fieldname: string): boolean {
427
+ const patternParts = pattern.split('.')
428
+ const fieldParts = fieldname.split('.')
429
+
430
+ if (patternParts.length !== fieldParts.length) {
431
+ return false
432
+ }
433
+
434
+ for (let i = 0; i < patternParts.length; i++) {
435
+ const patternPart = patternParts[i]
436
+ const fieldPart = fieldParts[i]
437
+
438
+ if (patternPart === '*') {
439
+ // Wildcard matches any segment
440
+ continue
441
+ } else if (patternPart !== fieldPart) {
442
+ // Exact match required
443
+ return false
444
+ }
445
+ }
446
+
447
+ return true
448
+ }
449
+
450
+ /**
451
+ * Execute a single action by name
452
+ */
453
+ private async executeAction(
454
+ actionName: string,
455
+ context: FieldChangeContext,
456
+ timeout?: number
457
+ ): Promise<ActionExecutionResult> {
458
+ const startTime = performance.now()
459
+ const actionTimeout = timeout ?? this.options.defaultTimeout
460
+
461
+ try {
462
+ // Look up action in global registry
463
+ const actionFn = this.globalActions.get(actionName)
464
+ if (!actionFn) {
465
+ throw new Error(`Action "${actionName}" not found in registry`)
466
+ }
467
+
468
+ await this.executeWithTimeout(actionFn, context, actionTimeout)
469
+ const executionTime = performance.now() - startTime
470
+
471
+ return {
472
+ success: true,
473
+ executionTime,
474
+ action: actionName,
475
+ }
476
+ } catch (error) {
477
+ const executionTime = performance.now() - startTime
478
+ const actionError = error instanceof Error ? error : new Error(String(error))
479
+
480
+ return {
481
+ success: false,
482
+ error: actionError,
483
+ executionTime,
484
+ action: actionName,
485
+ }
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Execute a function with timeout
491
+ */
492
+ private async executeWithTimeout(
493
+ fn: FieldActionFunction,
494
+ context: FieldChangeContext,
495
+ timeout: number
496
+ ): Promise<unknown> {
497
+ return new Promise((resolve, reject) => {
498
+ const timeoutId = setTimeout(() => {
499
+ reject(new Error(`Action timeout after ${timeout}ms`))
500
+ }, timeout)
501
+
502
+ Promise.resolve(fn(context))
503
+ .then(result => {
504
+ clearTimeout(timeoutId)
505
+ resolve(result)
506
+ })
507
+ .catch(error => {
508
+ clearTimeout(timeoutId)
509
+ reject(error)
510
+ })
511
+ })
512
+ }
513
+
514
+ /**
515
+ * Capture a snapshot of the record state before executing actions
516
+ * This creates a deep copy of the record data for potential rollback
517
+ */
518
+ private captureSnapshot(context: FieldChangeContext): any {
519
+ if (!context.store || !context.doctype || !context.recordId) {
520
+ return undefined
521
+ }
522
+
523
+ try {
524
+ // Get the record path (doctype.recordId)
525
+ const recordPath = `${context.doctype}.${context.recordId}`
526
+
527
+ // Get the current record data
528
+ const recordData = context.store.get(recordPath)
529
+
530
+ if (!recordData || typeof recordData !== 'object') {
531
+ return undefined
532
+ }
533
+
534
+ // Create a deep copy to avoid reference issues
535
+ return JSON.parse(JSON.stringify(recordData))
536
+ } catch (error) {
537
+ if (this.options.debug) {
538
+ // eslint-disable-next-line no-console
539
+ console.warn('[FieldTriggers] Failed to capture snapshot:', error)
540
+ }
541
+ return undefined
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Restore a previously captured snapshot
547
+ * This reverts the record to its state before actions were executed
548
+ */
549
+ private restoreSnapshot(context: FieldChangeContext, snapshot: any): void {
550
+ if (!context.store || !context.doctype || !context.recordId || !snapshot) {
551
+ return
552
+ }
553
+
554
+ try {
555
+ // Get the record path (doctype.recordId)
556
+ const recordPath = `${context.doctype}.${context.recordId}`
557
+
558
+ // Restore the entire record from snapshot
559
+ context.store.set(recordPath, snapshot)
560
+
561
+ if (this.options.debug) {
562
+ // eslint-disable-next-line no-console
563
+ console.log(`[FieldTriggers] Rolled back ${recordPath} to previous state`)
564
+ }
565
+ } catch (error) {
566
+ // eslint-disable-next-line no-console
567
+ console.error('[FieldTriggers] Failed to restore snapshot:', error)
568
+ throw error
569
+ }
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Get or create the global field trigger engine singleton
575
+ * @param options - Optional configuration for the field trigger engine
576
+ * @public
577
+ */
578
+ export function getGlobalTriggerEngine(options?: FieldTriggerOptions): FieldTriggerEngine {
579
+ return new FieldTriggerEngine(options)
580
+ }
581
+
582
+ /**
583
+ * Register a global action function that can be used in field triggers
584
+ * @param name - The name of the action to register
585
+ * @param fn - The action function to execute when the trigger fires
586
+ * @public
587
+ */
588
+ export function registerGlobalAction(name: string, fn: FieldActionFunction): void {
589
+ const engine = getGlobalTriggerEngine()
590
+ engine.registerAction(name, fn)
591
+ }
592
+
593
+ /**
594
+ * Register a global XState transition action function
595
+ * @param name - The name of the transition action to register
596
+ * @param fn - The transition action function to execute
597
+ * @public
598
+ */
599
+ export function registerTransitionAction(name: string, fn: TransitionActionFunction): void {
600
+ const engine = getGlobalTriggerEngine()
601
+ engine.registerTransitionAction(name, fn)
602
+ }
603
+
604
+ /**
605
+ * Configure rollback behavior for a specific field trigger
606
+ * @param doctype - The doctype name
607
+ * @param fieldname - The field name
608
+ * @param enableRollback - Whether to enable automatic rollback for this field
609
+ * @public
610
+ */
611
+ export function setFieldRollback(doctype: string, fieldname: string, enableRollback: boolean): void {
612
+ const engine = getGlobalTriggerEngine()
613
+ engine.setFieldRollback(doctype, fieldname, enableRollback)
614
+ }
615
+
616
+ /**
617
+ * Manually trigger an XState transition for a specific doctype/record
618
+ * This can be called directly when you need to execute transition actions programmatically
619
+ * @param doctype - The doctype name
620
+ * @param transition - The XState transition name to trigger
621
+ * @param options - Optional configuration for the transition
622
+ * @public
623
+ */
624
+ export async function triggerTransition(
625
+ doctype: string,
626
+ transition: string,
627
+ options?: {
628
+ recordId?: string
629
+ currentState?: string
630
+ targetState?: string
631
+ fsmContext?: Record<string, any>
632
+ path?: string
633
+ }
634
+ ): Promise<any> {
635
+ const engine = getGlobalTriggerEngine()
636
+
637
+ const context: TransitionChangeContext = {
638
+ path: options?.path || (options?.recordId ? `${doctype}.${options.recordId}` : doctype),
639
+ fieldname: '',
640
+ beforeValue: undefined,
641
+ afterValue: undefined,
642
+ operation: 'set',
643
+ doctype,
644
+ recordId: options?.recordId,
645
+ timestamp: new Date(),
646
+ transition,
647
+ currentState: options?.currentState,
648
+ targetState: options?.targetState,
649
+ fsmContext: options?.fsmContext,
650
+ }
651
+
652
+ return await engine.executeTransitionActions(context)
653
+ }
654
+
655
+ /**
656
+ * Mark a specific operation as irreversible.
657
+ * Used to prevent undo of critical operations like publishing or deletion.
658
+ * @param operationId - The ID of the operation to mark as irreversible
659
+ * @param reason - Human-readable reason why the operation cannot be undone
660
+ * @public
661
+ */
662
+ export function markOperationIrreversible(operationId: string | undefined, reason: string): void {
663
+ if (!operationId) return
664
+
665
+ try {
666
+ const store = useOperationLogStore()
667
+ store.markIrreversible(operationId, reason)
668
+ } catch {
669
+ // Operation log is optional
670
+ }
671
+ }