s3db.js 13.1.0 → 13.2.2

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,6 +1,7 @@
1
1
  import { Plugin } from "./plugin.class.js";
2
2
  import tryFn from "../concerns/try-fn.js";
3
3
  import { StateMachineError } from "./state-machine.errors.js";
4
+ import { ErrorClassifier } from "../concerns/error-classifier.js";
4
5
 
5
6
  /**
6
7
  * StateMachinePlugin - Finite State Machine Management
@@ -114,11 +115,24 @@ export class StateMachinePlugin extends Plugin {
114
115
  // Distributed lock configuration (prevents concurrent transitions)
115
116
  workerId: options.workerId || 'default',
116
117
  lockTimeout: options.lockTimeout || 1000, // Wait up to 1s for lock
117
- lockTTL: options.lockTTL || 5 // Lock expires after 5s (prevent deadlock)
118
+ lockTTL: options.lockTTL || 5, // Lock expires after 5s (prevent deadlock)
119
+
120
+ // Global retry configuration for action execution
121
+ retryConfig: options.retryConfig || null,
122
+
123
+ // Trigger system configuration
124
+ enableScheduler: options.enableScheduler || false,
125
+ schedulerConfig: options.schedulerConfig || {},
126
+ enableDateTriggers: options.enableDateTriggers !== false,
127
+ enableFunctionTriggers: options.enableFunctionTriggers !== false,
128
+ enableEventTriggers: options.enableEventTriggers !== false,
129
+ triggerCheckInterval: options.triggerCheckInterval || 60000 // Check triggers every 60s by default
118
130
  };
119
131
 
120
132
  this.database = null;
121
133
  this.machines = new Map();
134
+ this.triggerIntervals = [];
135
+ this.schedulerPlugin = null;
122
136
 
123
137
  this._validateConfiguration();
124
138
  }
@@ -163,12 +177,12 @@ export class StateMachinePlugin extends Plugin {
163
177
  }
164
178
 
165
179
  async onInstall() {
166
-
180
+
167
181
  // Create state storage resource if persistence is enabled
168
182
  if (this.config.persistTransitions) {
169
183
  await this._createStateResources();
170
184
  }
171
-
185
+
172
186
  // Initialize state machines
173
187
  for (const [machineName, machineConfig] of Object.entries(this.config.stateMachines)) {
174
188
  this.machines.set(machineName, {
@@ -176,8 +190,14 @@ export class StateMachinePlugin extends Plugin {
176
190
  currentStates: new Map() // entityId -> currentState
177
191
  });
178
192
  }
179
-
180
- this.emit('initialized', { machines: Array.from(this.machines.keys()) });
193
+
194
+ // Attach state machines to resources for direct API access
195
+ await this._attachStateMachinesToResources();
196
+
197
+ // Setup trigger system if enabled
198
+ await this._setupTriggers();
199
+
200
+ this.emit('db:plugin:initialized', { machines: Array.from(this.machines.keys()) });
181
201
  }
182
202
 
183
203
  async _createStateResources() {
@@ -212,6 +232,7 @@ export class StateMachinePlugin extends Plugin {
212
232
  currentState: 'string|required',
213
233
  context: 'json|default:{}',
214
234
  lastTransition: 'string|default:null',
235
+ triggerCounts: 'json|default:{}', // Track trigger execution counts
215
236
  updatedAt: 'string|required'
216
237
  },
217
238
  behavior: 'body-overflow'
@@ -292,7 +313,7 @@ export class StateMachinePlugin extends Plugin {
292
313
  await this._executeAction(targetStateConfig.entry, context, event, machineId, entityId);
293
314
  }
294
315
 
295
- this.emit('transition', {
316
+ this.emit('plg:state-machine:transition', {
296
317
  machineId,
297
318
  entityId,
298
319
  from: currentState,
@@ -321,16 +342,132 @@ export class StateMachinePlugin extends Plugin {
321
342
  }
322
343
  return;
323
344
  }
324
-
325
- const [ok, error] = await tryFn(() =>
326
- action(context, event, { database: this.database, machineId, entityId })
327
- );
328
-
329
- if (!ok) {
330
- if (this.config.verbose) {
331
- console.error(`[StateMachinePlugin] Action '${actionName}' failed:`, error.message);
345
+
346
+ // Get retry configuration (state-specific overrides global)
347
+ const machine = this.machines.get(machineId);
348
+ const currentState = await this.getState(machineId, entityId);
349
+ const stateConfig = machine?.config?.states?.[currentState];
350
+
351
+ // Merge retry configs: global < machine < state
352
+ const retryConfig = {
353
+ ...(this.config.retryConfig || {}),
354
+ ...(machine?.config?.retryConfig || {}),
355
+ ...(stateConfig?.retryConfig || {})
356
+ };
357
+
358
+ const maxAttempts = retryConfig.maxAttempts ?? 0;
359
+ const retryEnabled = maxAttempts > 0;
360
+ let attempt = 0;
361
+ let lastError = null;
362
+
363
+ while (attempt <= maxAttempts) {
364
+ try {
365
+ const result = await action(context, event, { database: this.database, machineId, entityId });
366
+
367
+ // Success - log retry statistics if retried
368
+ if (attempt > 0) {
369
+ this.emit('plg:state-machine:action-retry-success', {
370
+ machineId,
371
+ entityId,
372
+ action: actionName,
373
+ attempts: attempt + 1,
374
+ state: currentState
375
+ });
376
+
377
+ if (this.config.verbose) {
378
+ console.log(`[StateMachinePlugin] Action '${actionName}' succeeded after ${attempt + 1} attempts`);
379
+ }
380
+ }
381
+
382
+ return result;
383
+
384
+ } catch (error) {
385
+ lastError = error;
386
+
387
+ // If retries are disabled, use old behavior (emit error but don't throw)
388
+ if (!retryEnabled) {
389
+ if (this.config.verbose) {
390
+ console.error(`[StateMachinePlugin] Action '${actionName}' failed:`, error.message);
391
+ }
392
+ this.emit('plg:state-machine:action-error', { actionName, error: error.message, machineId, entityId });
393
+ return; // Don't throw, continue execution
394
+ }
395
+
396
+ // Classify error
397
+ const classification = ErrorClassifier.classify(error, {
398
+ retryableErrors: retryConfig.retryableErrors,
399
+ nonRetriableErrors: retryConfig.nonRetriableErrors
400
+ });
401
+
402
+ // Non-retriable error - fail immediately
403
+ if (classification === 'NON_RETRIABLE') {
404
+ this.emit('plg:state-machine:action-error-non-retriable', {
405
+ machineId,
406
+ entityId,
407
+ action: actionName,
408
+ error: error.message,
409
+ state: currentState
410
+ });
411
+
412
+ if (this.config.verbose) {
413
+ console.error(`[StateMachinePlugin] Action '${actionName}' failed with non-retriable error:`, error.message);
414
+ }
415
+
416
+ throw error;
417
+ }
418
+
419
+ // Max attempts reached
420
+ if (attempt >= maxAttempts) {
421
+ this.emit('plg:state-machine:action-retry-exhausted', {
422
+ machineId,
423
+ entityId,
424
+ action: actionName,
425
+ attempts: attempt + 1,
426
+ error: error.message,
427
+ state: currentState
428
+ });
429
+
430
+ if (this.config.verbose) {
431
+ console.error(`[StateMachinePlugin] Action '${actionName}' failed after ${attempt + 1} attempts:`, error.message);
432
+ }
433
+
434
+ throw error;
435
+ }
436
+
437
+ // Retriable error - retry
438
+ attempt++;
439
+
440
+ // Calculate backoff delay
441
+ const delay = this._calculateBackoff(attempt, retryConfig);
442
+
443
+ // Call retry hook if configured
444
+ if (retryConfig.onRetry) {
445
+ try {
446
+ await retryConfig.onRetry(attempt, error, context);
447
+ } catch (hookError) {
448
+ if (this.config.verbose) {
449
+ console.warn(`[StateMachinePlugin] onRetry hook failed:`, hookError.message);
450
+ }
451
+ }
452
+ }
453
+
454
+ this.emit('plg:state-machine:action-retry-attempt', {
455
+ machineId,
456
+ entityId,
457
+ action: actionName,
458
+ attempt,
459
+ delay,
460
+ error: error.message,
461
+ state: currentState
462
+ });
463
+
464
+ if (this.config.verbose) {
465
+ console.warn(`[StateMachinePlugin] Action '${actionName}' failed (attempt ${attempt + 1}/${maxAttempts + 1}), retrying in ${delay}ms:`, error.message);
466
+ }
467
+
468
+ // Wait before retry
469
+ await new Promise(resolve => setTimeout(resolve, delay));
332
470
  }
333
- this.emit('action_error', { actionName, error: error.message, machineId, entityId });
334
471
  }
335
472
  }
336
473
 
@@ -453,6 +590,35 @@ export class StateMachinePlugin extends Plugin {
453
590
  }
454
591
  }
455
592
 
593
+ /**
594
+ * Calculate backoff delay for retry attempts
595
+ * @private
596
+ */
597
+ _calculateBackoff(attempt, retryConfig) {
598
+ const {
599
+ backoffStrategy = 'exponential',
600
+ baseDelay = 1000,
601
+ maxDelay = 30000
602
+ } = retryConfig || {};
603
+
604
+ let delay;
605
+
606
+ if (backoffStrategy === 'exponential') {
607
+ // Exponential backoff: baseDelay * 2^(attempt-1)
608
+ delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
609
+ } else if (backoffStrategy === 'linear') {
610
+ // Linear backoff: baseDelay * attempt
611
+ delay = Math.min(baseDelay * attempt, maxDelay);
612
+ } else {
613
+ // Fixed backoff: always use baseDelay
614
+ delay = baseDelay;
615
+ }
616
+
617
+ // Add jitter (±20%) to prevent thundering herd
618
+ const jitter = delay * 0.2 * (Math.random() - 0.5);
619
+ return Math.round(delay + jitter);
620
+ }
621
+
456
622
  /**
457
623
  * Get current state for an entity
458
624
  */
@@ -611,7 +777,7 @@ export class StateMachinePlugin extends Plugin {
611
777
  await this._executeAction(initialStateConfig.entry, context, 'INIT', machineId, entityId);
612
778
  }
613
779
 
614
- this.emit('entity_initialized', { machineId, entityId, initialState });
780
+ this.emit('plg:state-machine:entity-initialized', { machineId, entityId, initialState });
615
781
 
616
782
  return initialState;
617
783
  }
@@ -674,6 +840,483 @@ export class StateMachinePlugin extends Plugin {
674
840
  return dot;
675
841
  }
676
842
 
843
+ /**
844
+ * Get all entities currently in a specific state
845
+ * @private
846
+ */
847
+ async _getEntitiesInState(machineId, stateName) {
848
+ if (!this.config.persistTransitions) {
849
+ // Memory-only - check in-memory map
850
+ const machine = this.machines.get(machineId);
851
+ if (!machine) return [];
852
+
853
+ const entities = [];
854
+ for (const [entityId, currentState] of machine.currentStates) {
855
+ if (currentState === stateName) {
856
+ entities.push({ entityId, currentState, context: {}, triggerCounts: {} });
857
+ }
858
+ }
859
+ return entities;
860
+ }
861
+
862
+ // Query state resource for entities in this state
863
+ const [ok, err, records] = await tryFn(() =>
864
+ this.database.resources[this.config.stateResource].query({
865
+ machineId,
866
+ currentState: stateName
867
+ })
868
+ );
869
+
870
+ if (!ok) {
871
+ if (this.config.verbose) {
872
+ console.warn(`[StateMachinePlugin] Failed to query entities in state '${stateName}':`, err.message);
873
+ }
874
+ return [];
875
+ }
876
+
877
+ return records || [];
878
+ }
879
+
880
+ /**
881
+ * Increment trigger execution count for an entity
882
+ * @private
883
+ */
884
+ async _incrementTriggerCount(machineId, entityId, triggerName) {
885
+ if (!this.config.persistTransitions) {
886
+ // No persistence - skip tracking
887
+ return;
888
+ }
889
+
890
+ const stateId = `${machineId}_${entityId}`;
891
+
892
+ const [ok, err, stateRecord] = await tryFn(() =>
893
+ this.database.resources[this.config.stateResource].get(stateId)
894
+ );
895
+
896
+ if (ok && stateRecord) {
897
+ const triggerCounts = stateRecord.triggerCounts || {};
898
+ triggerCounts[triggerName] = (triggerCounts[triggerName] || 0) + 1;
899
+
900
+ await tryFn(() =>
901
+ this.database.resources[this.config.stateResource].patch(stateId, { triggerCounts })
902
+ );
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Setup trigger system for all state machines
908
+ * @private
909
+ */
910
+ async _setupTriggers() {
911
+ if (!this.config.enableScheduler && !this.config.enableDateTriggers && !this.config.enableFunctionTriggers && !this.config.enableEventTriggers) {
912
+ // All triggers disabled
913
+ return;
914
+ }
915
+
916
+ const cronJobs = {};
917
+
918
+ for (const [machineId, machineData] of this.machines) {
919
+ const machineConfig = machineData.config;
920
+
921
+ for (const [stateName, stateConfig] of Object.entries(machineConfig.states)) {
922
+ const triggers = stateConfig.triggers || [];
923
+
924
+ for (let i = 0; i < triggers.length; i++) {
925
+ const trigger = triggers[i];
926
+ const triggerName = `${trigger.action}_${i}`;
927
+
928
+ if (trigger.type === 'cron' && this.config.enableScheduler) {
929
+ // Collect cron triggers for SchedulerPlugin
930
+ const jobName = `${machineId}_${stateName}_${triggerName}`;
931
+ cronJobs[jobName] = await this._createCronJob(machineId, stateName, trigger, triggerName);
932
+ } else if (trigger.type === 'date' && this.config.enableDateTriggers) {
933
+ // Setup date-based trigger
934
+ await this._setupDateTrigger(machineId, stateName, trigger, triggerName);
935
+ } else if (trigger.type === 'function' && this.config.enableFunctionTriggers) {
936
+ // Setup function-based trigger
937
+ await this._setupFunctionTrigger(machineId, stateName, trigger, triggerName);
938
+ } else if (trigger.type === 'event' && this.config.enableEventTriggers) {
939
+ // Setup event-based trigger
940
+ await this._setupEventTrigger(machineId, stateName, trigger, triggerName);
941
+ }
942
+ }
943
+ }
944
+ }
945
+
946
+ // Install SchedulerPlugin if there are cron jobs
947
+ if (Object.keys(cronJobs).length > 0 && this.config.enableScheduler) {
948
+ const { SchedulerPlugin } = await import('./scheduler.plugin.js');
949
+ this.schedulerPlugin = new SchedulerPlugin({
950
+ jobs: cronJobs,
951
+ persistJobs: false, // Don't persist trigger jobs
952
+ verbose: this.config.verbose,
953
+ ...this.config.schedulerConfig
954
+ });
955
+
956
+ await this.database.usePlugin(this.schedulerPlugin);
957
+
958
+ if (this.config.verbose) {
959
+ console.log(`[StateMachinePlugin] Installed SchedulerPlugin with ${Object.keys(cronJobs).length} cron triggers`);
960
+ }
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Create a SchedulerPlugin job for a cron trigger
966
+ * @private
967
+ */
968
+ async _createCronJob(machineId, stateName, trigger, triggerName) {
969
+ return {
970
+ schedule: trigger.schedule,
971
+ description: `Trigger '${triggerName}' for ${machineId}.${stateName}`,
972
+ action: async (database, context) => {
973
+ // Find all entities in this state
974
+ const entities = await this._getEntitiesInState(machineId, stateName);
975
+
976
+ let executedCount = 0;
977
+
978
+ for (const entity of entities) {
979
+ try {
980
+ // Check condition if provided
981
+ if (trigger.condition) {
982
+ const shouldTrigger = await trigger.condition(entity.context, entity.entityId);
983
+ if (!shouldTrigger) continue;
984
+ }
985
+
986
+ // Check max triggers
987
+ if (trigger.maxTriggers !== undefined) {
988
+ const triggerCount = entity.triggerCounts?.[triggerName] || 0;
989
+ if (triggerCount >= trigger.maxTriggers) {
990
+ // Send max triggers event if configured
991
+ if (trigger.onMaxTriggersReached) {
992
+ await this.send(machineId, entity.entityId, trigger.onMaxTriggersReached, entity.context);
993
+ }
994
+ continue;
995
+ }
996
+ }
997
+
998
+ // Execute trigger action
999
+ const result = await this._executeAction(
1000
+ trigger.action,
1001
+ entity.context,
1002
+ 'TRIGGER',
1003
+ machineId,
1004
+ entity.entityId
1005
+ );
1006
+
1007
+ // Increment trigger count
1008
+ await this._incrementTriggerCount(machineId, entity.entityId, triggerName);
1009
+ executedCount++;
1010
+
1011
+ // Send success event if configured
1012
+ if (trigger.eventOnSuccess) {
1013
+ await this.send(machineId, entity.entityId, trigger.eventOnSuccess, {
1014
+ ...entity.context,
1015
+ triggerResult: result
1016
+ });
1017
+ } else if (trigger.event) {
1018
+ await this.send(machineId, entity.entityId, trigger.event, {
1019
+ ...entity.context,
1020
+ triggerResult: result
1021
+ });
1022
+ }
1023
+
1024
+ this.emit('plg:state-machine:trigger-executed', {
1025
+ machineId,
1026
+ entityId: entity.entityId,
1027
+ state: stateName,
1028
+ trigger: triggerName,
1029
+ type: 'cron'
1030
+ });
1031
+
1032
+ } catch (error) {
1033
+ // Send failure event if configured
1034
+ if (trigger.event) {
1035
+ await tryFn(() => this.send(machineId, entity.entityId, trigger.event, {
1036
+ ...entity.context,
1037
+ triggerError: error.message
1038
+ }));
1039
+ }
1040
+
1041
+ if (this.config.verbose) {
1042
+ console.error(`[StateMachinePlugin] Trigger '${triggerName}' failed for entity ${entity.entityId}:`, error.message);
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ return { processed: entities.length, executed: executedCount };
1048
+ }
1049
+ };
1050
+ }
1051
+
1052
+ /**
1053
+ * Setup a date-based trigger
1054
+ * @private
1055
+ */
1056
+ async _setupDateTrigger(machineId, stateName, trigger, triggerName) {
1057
+ // Poll for entities approaching trigger date
1058
+ const checkInterval = setInterval(async () => {
1059
+ const entities = await this._getEntitiesInState(machineId, stateName);
1060
+
1061
+ for (const entity of entities) {
1062
+ try {
1063
+ // Get trigger date from context field
1064
+ const triggerDateValue = entity.context?.[trigger.field];
1065
+ if (!triggerDateValue) continue;
1066
+
1067
+ const triggerDate = new Date(triggerDateValue);
1068
+ const now = new Date();
1069
+
1070
+ // Check if trigger date reached
1071
+ if (now >= triggerDate) {
1072
+ // Check max triggers
1073
+ if (trigger.maxTriggers !== undefined) {
1074
+ const triggerCount = entity.triggerCounts?.[triggerName] || 0;
1075
+ if (triggerCount >= trigger.maxTriggers) {
1076
+ if (trigger.onMaxTriggersReached) {
1077
+ await this.send(machineId, entity.entityId, trigger.onMaxTriggersReached, entity.context);
1078
+ }
1079
+ continue;
1080
+ }
1081
+ }
1082
+
1083
+ // Execute action
1084
+ const result = await this._executeAction(trigger.action, entity.context, 'TRIGGER', machineId, entity.entityId);
1085
+ await this._incrementTriggerCount(machineId, entity.entityId, triggerName);
1086
+
1087
+ // Send event
1088
+ if (trigger.event) {
1089
+ await this.send(machineId, entity.entityId, trigger.event, {
1090
+ ...entity.context,
1091
+ triggerResult: result
1092
+ });
1093
+ }
1094
+
1095
+ this.emit('plg:state-machine:trigger-executed', {
1096
+ machineId,
1097
+ entityId: entity.entityId,
1098
+ state: stateName,
1099
+ trigger: triggerName,
1100
+ type: 'date'
1101
+ });
1102
+ }
1103
+ } catch (error) {
1104
+ if (this.config.verbose) {
1105
+ console.error(`[StateMachinePlugin] Date trigger '${triggerName}' failed:`, error.message);
1106
+ }
1107
+ }
1108
+ }
1109
+ }, this.config.triggerCheckInterval);
1110
+
1111
+ this.triggerIntervals.push(checkInterval);
1112
+ }
1113
+
1114
+ /**
1115
+ * Setup a function-based trigger
1116
+ * @private
1117
+ */
1118
+ async _setupFunctionTrigger(machineId, stateName, trigger, triggerName) {
1119
+ const interval = trigger.interval || this.config.triggerCheckInterval;
1120
+
1121
+ const checkInterval = setInterval(async () => {
1122
+ const entities = await this._getEntitiesInState(machineId, stateName);
1123
+
1124
+ for (const entity of entities) {
1125
+ try {
1126
+ // Check max triggers
1127
+ if (trigger.maxTriggers !== undefined) {
1128
+ const triggerCount = entity.triggerCounts?.[triggerName] || 0;
1129
+ if (triggerCount >= trigger.maxTriggers) {
1130
+ if (trigger.onMaxTriggersReached) {
1131
+ await this.send(machineId, entity.entityId, trigger.onMaxTriggersReached, entity.context);
1132
+ }
1133
+ continue;
1134
+ }
1135
+ }
1136
+
1137
+ // Evaluate condition
1138
+ const shouldTrigger = await trigger.condition(entity.context, entity.entityId);
1139
+
1140
+ if (shouldTrigger) {
1141
+ const result = await this._executeAction(trigger.action, entity.context, 'TRIGGER', machineId, entity.entityId);
1142
+ await this._incrementTriggerCount(machineId, entity.entityId, triggerName);
1143
+
1144
+ // Send event if configured
1145
+ if (trigger.event) {
1146
+ await this.send(machineId, entity.entityId, trigger.event, {
1147
+ ...entity.context,
1148
+ triggerResult: result
1149
+ });
1150
+ }
1151
+
1152
+ this.emit('plg:state-machine:trigger-executed', {
1153
+ machineId,
1154
+ entityId: entity.entityId,
1155
+ state: stateName,
1156
+ trigger: triggerName,
1157
+ type: 'function'
1158
+ });
1159
+ }
1160
+ } catch (error) {
1161
+ if (this.config.verbose) {
1162
+ console.error(`[StateMachinePlugin] Function trigger '${triggerName}' failed:`, error.message);
1163
+ }
1164
+ }
1165
+ }
1166
+ }, interval);
1167
+
1168
+ this.triggerIntervals.push(checkInterval);
1169
+ }
1170
+
1171
+ /**
1172
+ * Setup an event-based trigger
1173
+ * @private
1174
+ */
1175
+ async _setupEventTrigger(machineId, stateName, trigger, triggerName) {
1176
+ const eventName = trigger.event;
1177
+
1178
+ // Create event listener
1179
+ const eventHandler = async (eventData) => {
1180
+ const entities = await this._getEntitiesInState(machineId, stateName);
1181
+
1182
+ for (const entity of entities) {
1183
+ try {
1184
+ // Check condition if provided
1185
+ if (trigger.condition) {
1186
+ const shouldTrigger = await trigger.condition(entity.context, entity.entityId, eventData);
1187
+ if (!shouldTrigger) continue;
1188
+ }
1189
+
1190
+ // Check max triggers
1191
+ if (trigger.maxTriggers !== undefined) {
1192
+ const triggerCount = entity.triggerCounts?.[triggerName] || 0;
1193
+ if (triggerCount >= trigger.maxTriggers) {
1194
+ if (trigger.onMaxTriggersReached) {
1195
+ await this.send(machineId, entity.entityId, trigger.onMaxTriggersReached, entity.context);
1196
+ }
1197
+ continue;
1198
+ }
1199
+ }
1200
+
1201
+ // Execute trigger action with event data in context
1202
+ const result = await this._executeAction(
1203
+ trigger.action,
1204
+ { ...entity.context, eventData },
1205
+ 'TRIGGER',
1206
+ machineId,
1207
+ entity.entityId
1208
+ );
1209
+
1210
+ await this._incrementTriggerCount(machineId, entity.entityId, triggerName);
1211
+
1212
+ // Send success event if configured
1213
+ if (trigger.sendEvent) {
1214
+ await this.send(machineId, entity.entityId, trigger.sendEvent, {
1215
+ ...entity.context,
1216
+ triggerResult: result,
1217
+ eventData
1218
+ });
1219
+ }
1220
+
1221
+ this.emit('plg:state-machine:trigger-executed', {
1222
+ machineId,
1223
+ entityId: entity.entityId,
1224
+ state: stateName,
1225
+ trigger: triggerName,
1226
+ type: 'event',
1227
+ eventName
1228
+ });
1229
+ } catch (error) {
1230
+ if (this.config.verbose) {
1231
+ console.error(`[StateMachinePlugin] Event trigger '${triggerName}' failed:`, error.message);
1232
+ }
1233
+ }
1234
+ }
1235
+ };
1236
+
1237
+ // Listen to database events if eventName starts with 'db:'
1238
+ if (eventName.startsWith('db:')) {
1239
+ const dbEventName = eventName.substring(3); // Remove 'db:' prefix
1240
+ this.database.on(dbEventName, eventHandler);
1241
+
1242
+ if (this.config.verbose) {
1243
+ console.log(`[StateMachinePlugin] Listening to database event '${dbEventName}' for trigger '${triggerName}'`);
1244
+ }
1245
+ } else {
1246
+ // Listen to plugin events
1247
+ this.on(eventName, eventHandler);
1248
+
1249
+ if (this.config.verbose) {
1250
+ console.log(`[StateMachinePlugin] Listening to plugin event '${eventName}' for trigger '${triggerName}'`);
1251
+ }
1252
+ }
1253
+ }
1254
+
1255
+ /**
1256
+ * Attach state machine instances to their associated resources
1257
+ * This enables the resource API: resource.state(id, event)
1258
+ * @private
1259
+ */
1260
+ async _attachStateMachinesToResources() {
1261
+ for (const [machineName, machineConfig] of Object.entries(this.config.stateMachines)) {
1262
+ const resourceConfig = machineConfig.config || machineConfig;
1263
+
1264
+ // Skip if no resource is specified
1265
+ if (!resourceConfig.resource) {
1266
+ if (this.config.verbose) {
1267
+ console.log(`[StateMachinePlugin] Machine '${machineName}' has no resource configured, skipping attachment`);
1268
+ }
1269
+ continue;
1270
+ }
1271
+
1272
+ // Get the resource instance
1273
+ let resource;
1274
+ if (typeof resourceConfig.resource === 'string') {
1275
+ // Resource specified as name
1276
+ resource = this.database.resources[resourceConfig.resource];
1277
+ if (!resource) {
1278
+ console.warn(
1279
+ `[StateMachinePlugin] Resource '${resourceConfig.resource}' not found for machine '${machineName}'. ` +
1280
+ `Resource API will not be available.`
1281
+ );
1282
+ continue;
1283
+ }
1284
+ } else {
1285
+ // Resource specified as instance
1286
+ resource = resourceConfig.resource;
1287
+ }
1288
+
1289
+ // Create a machine proxy that delegates to this plugin
1290
+ const machineProxy = {
1291
+ send: async (id, event, eventData) => {
1292
+ return this.send(machineName, id, event, eventData);
1293
+ },
1294
+ getState: async (id) => {
1295
+ return this.getState(machineName, id);
1296
+ },
1297
+ canTransition: async (id, event) => {
1298
+ return this.canTransition(machineName, id, event);
1299
+ },
1300
+ getValidEvents: async (id) => {
1301
+ return this.getValidEvents(machineName, id);
1302
+ },
1303
+ initializeEntity: async (id, context) => {
1304
+ return this.initializeEntity(machineName, id, context);
1305
+ },
1306
+ getTransitionHistory: async (id, options) => {
1307
+ return this.getTransitionHistory(machineName, id, options);
1308
+ }
1309
+ };
1310
+
1311
+ // Attach the proxy to the resource
1312
+ resource._attachStateMachine(machineProxy);
1313
+
1314
+ if (this.config.verbose) {
1315
+ console.log(`[StateMachinePlugin] Attached machine '${machineName}' to resource '${resource.name}'`);
1316
+ }
1317
+ }
1318
+ }
1319
+
677
1320
  async start() {
678
1321
  if (this.config.verbose) {
679
1322
  console.log(`[StateMachinePlugin] Started with ${this.machines.size} state machines`);
@@ -681,6 +1324,18 @@ export class StateMachinePlugin extends Plugin {
681
1324
  }
682
1325
 
683
1326
  async stop() {
1327
+ // Clear trigger intervals
1328
+ for (const interval of this.triggerIntervals) {
1329
+ clearInterval(interval);
1330
+ }
1331
+ this.triggerIntervals = [];
1332
+
1333
+ // Stop scheduler plugin if installed
1334
+ if (this.schedulerPlugin) {
1335
+ await this.schedulerPlugin.stop();
1336
+ this.schedulerPlugin = null;
1337
+ }
1338
+
684
1339
  this.machines.clear();
685
1340
  this.removeAllListeners();
686
1341
  }