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