s3db.js 13.3.1 → 13.5.1

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.
@@ -133,10 +133,46 @@ export class StateMachinePlugin extends Plugin {
133
133
  this.machines = new Map();
134
134
  this.triggerIntervals = [];
135
135
  this.schedulerPlugin = null;
136
+ this._pendingEventHandlers = new Set();
136
137
 
137
138
  this._validateConfiguration();
138
139
  }
139
140
 
141
+ /**
142
+ * Wait for all pending event handlers to complete
143
+ * Useful when working with async events (asyncEvents: true)
144
+ * @param {number} timeout - Maximum time to wait in milliseconds (default: 5000)
145
+ * @returns {Promise<void>}
146
+ */
147
+ async waitForPendingEvents(timeout = 5000) {
148
+ if (this._pendingEventHandlers.size === 0) {
149
+ return; // No pending events
150
+ }
151
+
152
+ const startTime = Date.now();
153
+
154
+ while (this._pendingEventHandlers.size > 0) {
155
+ if (Date.now() - startTime > timeout) {
156
+ throw new StateMachineError(
157
+ `Timeout waiting for ${this._pendingEventHandlers.size} pending event handlers`,
158
+ {
159
+ operation: 'waitForPendingEvents',
160
+ pendingCount: this._pendingEventHandlers.size,
161
+ timeout
162
+ }
163
+ );
164
+ }
165
+
166
+ // Wait for at least one handler to complete
167
+ if (this._pendingEventHandlers.size > 0) {
168
+ await Promise.race(Array.from(this._pendingEventHandlers));
169
+ }
170
+
171
+ // Small delay before checking again
172
+ await new Promise(resolve => setImmediate(resolve));
173
+ }
174
+ }
175
+
140
176
  _validateConfiguration() {
141
177
  if (!this.config.stateMachines || Object.keys(this.config.stateMachines).length === 0) {
142
178
  throw new StateMachineError('At least one state machine must be defined', {
@@ -1170,10 +1206,22 @@ export class StateMachinePlugin extends Plugin {
1170
1206
 
1171
1207
  /**
1172
1208
  * Setup an event-based trigger
1209
+ * Supports both old API (trigger.event) and new API (trigger.eventName + eventSource)
1173
1210
  * @private
1174
1211
  */
1175
1212
  async _setupEventTrigger(machineId, stateName, trigger, triggerName) {
1176
- const eventName = trigger.event;
1213
+ // Support both old API (event) and new API (eventName)
1214
+ const baseEventName = trigger.eventName || trigger.event;
1215
+ const eventSource = trigger.eventSource;
1216
+
1217
+ if (!baseEventName) {
1218
+ throw new StateMachineError(`Event trigger '${triggerName}' must have either 'event' or 'eventName' property`, {
1219
+ operation: '_setupEventTrigger',
1220
+ machineId,
1221
+ stateName,
1222
+ triggerName
1223
+ });
1224
+ }
1177
1225
 
1178
1226
  // Create event listener
1179
1227
  const eventHandler = async (eventData) => {
@@ -1181,6 +1229,26 @@ export class StateMachinePlugin extends Plugin {
1181
1229
 
1182
1230
  for (const entity of entities) {
1183
1231
  try {
1232
+ // Resolve dynamic event name if it's a function
1233
+ let resolvedEventName;
1234
+ if (typeof baseEventName === 'function') {
1235
+ resolvedEventName = baseEventName(entity.context);
1236
+ } else {
1237
+ resolvedEventName = baseEventName;
1238
+ }
1239
+
1240
+ // Skip if event name doesn't match (for dynamic event names)
1241
+ // This allows filtering events by entity context
1242
+ if (eventSource && typeof baseEventName === 'function') {
1243
+ // For resource-specific events with dynamic names, we need to check
1244
+ // if this specific event matches this entity
1245
+ // The eventData will contain the ID that was part of the event name
1246
+ const eventIdMatch = eventData?.id || eventData?.entityId;
1247
+ if (eventIdMatch && entity.entityId !== eventIdMatch) {
1248
+ continue; // Not for this entity
1249
+ }
1250
+ }
1251
+
1184
1252
  // Check condition if provided
1185
1253
  if (trigger.condition) {
1186
1254
  const shouldTrigger = await trigger.condition(entity.context, entity.entityId, eventData);
@@ -1198,33 +1266,92 @@ export class StateMachinePlugin extends Plugin {
1198
1266
  }
1199
1267
  }
1200
1268
 
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
- );
1269
+ // NEW: Support targetState for automatic transitions
1270
+ if (trigger.targetState) {
1271
+ // Automatic transition to target state
1272
+ await this._transition(
1273
+ machineId,
1274
+ entity.entityId,
1275
+ stateName,
1276
+ trigger.targetState,
1277
+ 'TRIGGER',
1278
+ { ...entity.context, eventData, triggerName }
1279
+ );
1209
1280
 
1210
- await this._incrementTriggerCount(machineId, entity.entityId, triggerName);
1281
+ // Update resource's stateField if configured
1282
+ const machine = this.machines.get(machineId);
1283
+ const resourceConfig = machine.config;
1284
+ if (resourceConfig.resource && resourceConfig.stateField) {
1285
+ // Get the resource instance
1286
+ let resource;
1287
+ if (typeof resourceConfig.resource === 'string') {
1288
+ resource = await this.database.getResource(resourceConfig.resource);
1289
+ } else {
1290
+ resource = resourceConfig.resource;
1291
+ }
1211
1292
 
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
1293
+ // Update the state field in the resource
1294
+ if (resource) {
1295
+ const [ok] = await tryFn(() =>
1296
+ resource.patch(entity.entityId, { [resourceConfig.stateField]: trigger.targetState })
1297
+ );
1298
+ if (!ok && this.config.verbose) {
1299
+ console.warn(`[StateMachinePlugin] Failed to update resource stateField for entity ${entity.entityId}`);
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ // Execute entry action of target state if exists
1305
+ const targetStateConfig = machine.config.states[trigger.targetState];
1306
+ if (targetStateConfig?.entry) {
1307
+ await this._executeAction(
1308
+ targetStateConfig.entry,
1309
+ { ...entity.context, eventData },
1310
+ 'TRIGGER',
1311
+ machineId,
1312
+ entity.entityId
1313
+ );
1314
+ }
1315
+
1316
+ // Emit transition event
1317
+ this.emit('plg:state-machine:transition', {
1318
+ machineId,
1319
+ entityId: entity.entityId,
1320
+ from: stateName,
1321
+ to: trigger.targetState,
1322
+ event: 'TRIGGER',
1323
+ context: { ...entity.context, eventData, triggerName }
1218
1324
  });
1325
+ } else if (trigger.action) {
1326
+ // Execute trigger action with event data in context
1327
+ const result = await this._executeAction(
1328
+ trigger.action,
1329
+ { ...entity.context, eventData },
1330
+ 'TRIGGER',
1331
+ machineId,
1332
+ entity.entityId
1333
+ );
1334
+
1335
+ // Send success event if configured
1336
+ if (trigger.sendEvent) {
1337
+ await this.send(machineId, entity.entityId, trigger.sendEvent, {
1338
+ ...entity.context,
1339
+ triggerResult: result,
1340
+ eventData
1341
+ });
1342
+ }
1219
1343
  }
1220
1344
 
1345
+ await this._incrementTriggerCount(machineId, entity.entityId, triggerName);
1346
+
1221
1347
  this.emit('plg:state-machine:trigger-executed', {
1222
1348
  machineId,
1223
1349
  entityId: entity.entityId,
1224
1350
  state: stateName,
1225
1351
  trigger: triggerName,
1226
1352
  type: 'event',
1227
- eventName
1353
+ eventName: resolvedEventName,
1354
+ targetState: trigger.targetState
1228
1355
  });
1229
1356
  } catch (error) {
1230
1357
  if (this.config.verbose) {
@@ -1234,20 +1361,54 @@ export class StateMachinePlugin extends Plugin {
1234
1361
  }
1235
1362
  };
1236
1363
 
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);
1364
+ // NEW: Support eventSource for resource-specific events
1365
+ if (eventSource) {
1366
+ // Listen to events from a specific resource
1367
+ // Resource events are typically: inserted, updated, deleted
1368
+ const baseEvent = typeof baseEventName === 'function' ? 'updated' : baseEventName;
1369
+
1370
+ // IMPORTANT: For resources with async events, we need to ensure the event handler
1371
+ // completes before returning control. We wrap the handler to track pending operations.
1372
+ const wrappedHandler = async (...args) => {
1373
+ // Track this as a pending operation
1374
+ const handlerPromise = eventHandler(...args);
1375
+
1376
+ // Store promise if state machine has event tracking
1377
+ if (!this._pendingEventHandlers) {
1378
+ this._pendingEventHandlers = new Set();
1379
+ }
1380
+ this._pendingEventHandlers.add(handlerPromise);
1381
+
1382
+ try {
1383
+ await handlerPromise;
1384
+ } finally {
1385
+ this._pendingEventHandlers.delete(handlerPromise);
1386
+ }
1387
+ };
1388
+
1389
+ eventSource.on(baseEvent, wrappedHandler);
1241
1390
 
1242
1391
  if (this.config.verbose) {
1243
- console.log(`[StateMachinePlugin] Listening to database event '${dbEventName}' for trigger '${triggerName}'`);
1392
+ console.log(`[StateMachinePlugin] Listening to resource event '${baseEvent}' from '${eventSource.name}' for trigger '${triggerName}' (async-safe)`);
1244
1393
  }
1245
1394
  } else {
1246
- // Listen to plugin events
1247
- this.on(eventName, eventHandler);
1395
+ // Original behavior: listen to database or plugin events
1396
+ const staticEventName = typeof baseEventName === 'function' ? 'updated' : baseEventName;
1248
1397
 
1249
- if (this.config.verbose) {
1250
- console.log(`[StateMachinePlugin] Listening to plugin event '${eventName}' for trigger '${triggerName}'`);
1398
+ if (staticEventName.startsWith('db:')) {
1399
+ const dbEventName = staticEventName.substring(3); // Remove 'db:' prefix
1400
+ this.database.on(dbEventName, eventHandler);
1401
+
1402
+ if (this.config.verbose) {
1403
+ console.log(`[StateMachinePlugin] Listening to database event '${dbEventName}' for trigger '${triggerName}'`);
1404
+ }
1405
+ } else {
1406
+ // Listen to plugin events
1407
+ this.on(staticEventName, eventHandler);
1408
+
1409
+ if (this.config.verbose) {
1410
+ console.log(`[StateMachinePlugin] Listening to plugin event '${staticEventName}' for trigger '${triggerName}'`);
1411
+ }
1251
1412
  }
1252
1413
  }
1253
1414
  }