s3db.js 8.2.0 → 9.2.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.
@@ -0,0 +1,543 @@
1
+ import Plugin from "./plugin.class.js";
2
+ import tryFn from "../concerns/try-fn.js";
3
+
4
+ /**
5
+ * StateMachinePlugin - Finite State Machine Management
6
+ *
7
+ * Provides structured state management with controlled transitions,
8
+ * automatic actions, and comprehensive audit trails.
9
+ *
10
+ * === Features ===
11
+ * - Finite state machines with defined states and transitions
12
+ * - Event-driven transitions with validation
13
+ * - Entry/exit actions and guards
14
+ * - Transition history and audit trails
15
+ * - Multiple state machines per plugin instance
16
+ * - Integration with S3DB resources
17
+ *
18
+ * === Configuration Example ===
19
+ *
20
+ * new StateMachinePlugin({
21
+ * stateMachines: {
22
+ * order_processing: {
23
+ * initialState: 'pending',
24
+ * states: {
25
+ * pending: {
26
+ * on: {
27
+ * CONFIRM: 'confirmed',
28
+ * CANCEL: 'cancelled'
29
+ * },
30
+ * meta: { color: 'yellow', description: 'Awaiting payment' }
31
+ * },
32
+ * confirmed: {
33
+ * on: {
34
+ * PREPARE: 'preparing',
35
+ * CANCEL: 'cancelled'
36
+ * },
37
+ * entry: 'onConfirmed',
38
+ * exit: 'onLeftConfirmed'
39
+ * },
40
+ * preparing: {
41
+ * on: {
42
+ * SHIP: 'shipped',
43
+ * CANCEL: 'cancelled'
44
+ * },
45
+ * guards: {
46
+ * SHIP: 'canShip'
47
+ * }
48
+ * },
49
+ * shipped: {
50
+ * on: {
51
+ * DELIVER: 'delivered',
52
+ * RETURN: 'returned'
53
+ * }
54
+ * },
55
+ * delivered: { type: 'final' },
56
+ * cancelled: { type: 'final' },
57
+ * returned: { type: 'final' }
58
+ * }
59
+ * }
60
+ * },
61
+ *
62
+ * actions: {
63
+ * onConfirmed: async (context, event, machine) => {
64
+ * await machine.database.resource('inventory').update(context.productId, {
65
+ * quantity: { $decrement: context.quantity }
66
+ * });
67
+ * await machine.sendNotification(context.customerEmail, 'order_confirmed');
68
+ * },
69
+ * onLeftConfirmed: async (context, event, machine) => {
70
+ * console.log('Left confirmed state');
71
+ * }
72
+ * },
73
+ *
74
+ * guards: {
75
+ * canShip: async (context, event, machine) => {
76
+ * const inventory = await machine.database.resource('inventory').get(context.productId);
77
+ * return inventory.quantity >= context.quantity;
78
+ * }
79
+ * },
80
+ *
81
+ * persistTransitions: true,
82
+ * transitionLogResource: 'state_transitions'
83
+ * });
84
+ *
85
+ * === Usage ===
86
+ *
87
+ * // Send events to trigger transitions
88
+ * await stateMachine.send('order_processing', orderId, 'CONFIRM', { paymentId: 'pay_123' });
89
+ *
90
+ * // Get current state
91
+ * const state = await stateMachine.getState('order_processing', orderId);
92
+ *
93
+ * // Get valid events for current state
94
+ * const validEvents = stateMachine.getValidEvents('order_processing', 'pending');
95
+ *
96
+ * // Get transition history
97
+ * const history = await stateMachine.getTransitionHistory('order_processing', orderId);
98
+ */
99
+ export class StateMachinePlugin extends Plugin {
100
+ constructor(options = {}) {
101
+ super();
102
+
103
+ this.config = {
104
+ stateMachines: options.stateMachines || {},
105
+ actions: options.actions || {},
106
+ guards: options.guards || {},
107
+ persistTransitions: options.persistTransitions !== false,
108
+ transitionLogResource: options.transitionLogResource || 'state_transitions',
109
+ stateResource: options.stateResource || 'entity_states',
110
+ verbose: options.verbose || false,
111
+ ...options
112
+ };
113
+
114
+ this.database = null;
115
+ this.machines = new Map();
116
+ this.stateStorage = new Map(); // In-memory cache for states
117
+
118
+ this._validateConfiguration();
119
+ }
120
+
121
+ _validateConfiguration() {
122
+ if (!this.config.stateMachines || Object.keys(this.config.stateMachines).length === 0) {
123
+ throw new Error('StateMachinePlugin: At least one state machine must be defined');
124
+ }
125
+
126
+ for (const [machineName, machine] of Object.entries(this.config.stateMachines)) {
127
+ if (!machine.states || Object.keys(machine.states).length === 0) {
128
+ throw new Error(`StateMachinePlugin: Machine '${machineName}' must have states defined`);
129
+ }
130
+
131
+ if (!machine.initialState) {
132
+ throw new Error(`StateMachinePlugin: Machine '${machineName}' must have an initialState`);
133
+ }
134
+
135
+ if (!machine.states[machine.initialState]) {
136
+ throw new Error(`StateMachinePlugin: Initial state '${machine.initialState}' not found in machine '${machineName}'`);
137
+ }
138
+ }
139
+ }
140
+
141
+ async setup(database) {
142
+ this.database = database;
143
+
144
+ // Create state storage resource if persistence is enabled
145
+ if (this.config.persistTransitions) {
146
+ await this._createStateResources();
147
+ }
148
+
149
+ // Initialize state machines
150
+ for (const [machineName, machineConfig] of Object.entries(this.config.stateMachines)) {
151
+ this.machines.set(machineName, {
152
+ config: machineConfig,
153
+ currentStates: new Map() // entityId -> currentState
154
+ });
155
+ }
156
+
157
+ this.emit('initialized', { machines: Array.from(this.machines.keys()) });
158
+ }
159
+
160
+ async _createStateResources() {
161
+ // Create transition log resource
162
+ const [logOk] = await tryFn(() => this.database.createResource({
163
+ name: this.config.transitionLogResource,
164
+ attributes: {
165
+ id: 'string|required',
166
+ machineId: 'string|required',
167
+ entityId: 'string|required',
168
+ fromState: 'string',
169
+ toState: 'string|required',
170
+ event: 'string|required',
171
+ context: 'json',
172
+ timestamp: 'number|required',
173
+ createdAt: 'string|required'
174
+ },
175
+ behavior: 'body-overflow',
176
+ partitions: {
177
+ byMachine: { fields: { machineId: 'string' } },
178
+ byDate: { fields: { createdAt: 'string|maxlength:10' } }
179
+ }
180
+ }));
181
+
182
+ // Create current state resource
183
+ const [stateOk] = await tryFn(() => this.database.createResource({
184
+ name: this.config.stateResource,
185
+ attributes: {
186
+ id: 'string|required',
187
+ machineId: 'string|required',
188
+ entityId: 'string|required',
189
+ currentState: 'string|required',
190
+ context: 'json|default:{}',
191
+ lastTransition: 'string|default:null',
192
+ updatedAt: 'string|required'
193
+ },
194
+ behavior: 'body-overflow'
195
+ }));
196
+ }
197
+
198
+ /**
199
+ * Send an event to trigger a state transition
200
+ */
201
+ async send(machineId, entityId, event, context = {}) {
202
+ const machine = this.machines.get(machineId);
203
+ if (!machine) {
204
+ throw new Error(`State machine '${machineId}' not found`);
205
+ }
206
+
207
+ const currentState = await this.getState(machineId, entityId);
208
+ const stateConfig = machine.config.states[currentState];
209
+
210
+ if (!stateConfig || !stateConfig.on || !stateConfig.on[event]) {
211
+ throw new Error(`Event '${event}' not valid for state '${currentState}' in machine '${machineId}'`);
212
+ }
213
+
214
+ const targetState = stateConfig.on[event];
215
+
216
+ // Check guards
217
+ if (stateConfig.guards && stateConfig.guards[event]) {
218
+ const guardName = stateConfig.guards[event];
219
+ const guard = this.config.guards[guardName];
220
+
221
+ if (guard) {
222
+ const [guardOk, guardErr, guardResult] = await tryFn(() =>
223
+ guard(context, event, { database: this.database, machineId, entityId })
224
+ );
225
+
226
+ if (!guardOk || !guardResult) {
227
+ throw new Error(`Transition blocked by guard '${guardName}': ${guardErr?.message || 'Guard returned false'}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ // Execute exit action for current state
233
+ if (stateConfig.exit) {
234
+ await this._executeAction(stateConfig.exit, context, event, machineId, entityId);
235
+ }
236
+
237
+ // Execute the transition
238
+ await this._transition(machineId, entityId, currentState, targetState, event, context);
239
+
240
+ // Execute entry action for target state
241
+ const targetStateConfig = machine.config.states[targetState];
242
+ if (targetStateConfig && targetStateConfig.entry) {
243
+ await this._executeAction(targetStateConfig.entry, context, event, machineId, entityId);
244
+ }
245
+
246
+ this.emit('transition', {
247
+ machineId,
248
+ entityId,
249
+ from: currentState,
250
+ to: targetState,
251
+ event,
252
+ context
253
+ });
254
+
255
+ return {
256
+ from: currentState,
257
+ to: targetState,
258
+ event,
259
+ timestamp: new Date().toISOString()
260
+ };
261
+ }
262
+
263
+ async _executeAction(actionName, context, event, machineId, entityId) {
264
+ const action = this.config.actions[actionName];
265
+ if (!action) {
266
+ if (this.config.verbose) {
267
+ console.warn(`[StateMachinePlugin] Action '${actionName}' not found`);
268
+ }
269
+ return;
270
+ }
271
+
272
+ const [ok, error] = await tryFn(() =>
273
+ action(context, event, { database: this.database, machineId, entityId })
274
+ );
275
+
276
+ if (!ok) {
277
+ if (this.config.verbose) {
278
+ console.error(`[StateMachinePlugin] Action '${actionName}' failed:`, error.message);
279
+ }
280
+ this.emit('action_error', { actionName, error: error.message, machineId, entityId });
281
+ }
282
+ }
283
+
284
+ async _transition(machineId, entityId, fromState, toState, event, context) {
285
+ const timestamp = Date.now();
286
+ const now = new Date().toISOString();
287
+
288
+ // Update in-memory cache
289
+ const machine = this.machines.get(machineId);
290
+ machine.currentStates.set(entityId, toState);
291
+
292
+ // Persist transition log
293
+ if (this.config.persistTransitions) {
294
+ const transitionId = `${machineId}_${entityId}_${timestamp}`;
295
+
296
+ const [logOk, logErr] = await tryFn(() =>
297
+ this.database.resource(this.config.transitionLogResource).insert({
298
+ id: transitionId,
299
+ machineId,
300
+ entityId,
301
+ fromState,
302
+ toState,
303
+ event,
304
+ context,
305
+ timestamp,
306
+ createdAt: now.slice(0, 10) // YYYY-MM-DD for partitioning
307
+ })
308
+ );
309
+
310
+ if (!logOk && this.config.verbose) {
311
+ console.warn(`[StateMachinePlugin] Failed to log transition:`, logErr.message);
312
+ }
313
+
314
+ // Update current state
315
+ const stateId = `${machineId}_${entityId}`;
316
+ const [stateOk, stateErr] = await tryFn(async () => {
317
+ const exists = await this.database.resource(this.config.stateResource).exists(stateId);
318
+
319
+ const stateData = {
320
+ id: stateId,
321
+ machineId,
322
+ entityId,
323
+ currentState: toState,
324
+ context,
325
+ lastTransition: transitionId,
326
+ updatedAt: now
327
+ };
328
+
329
+ if (exists) {
330
+ await this.database.resource(this.config.stateResource).update(stateId, stateData);
331
+ } else {
332
+ await this.database.resource(this.config.stateResource).insert(stateData);
333
+ }
334
+ });
335
+
336
+ if (!stateOk && this.config.verbose) {
337
+ console.warn(`[StateMachinePlugin] Failed to update state:`, stateErr.message);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Get current state for an entity
344
+ */
345
+ async getState(machineId, entityId) {
346
+ const machine = this.machines.get(machineId);
347
+ if (!machine) {
348
+ throw new Error(`State machine '${machineId}' not found`);
349
+ }
350
+
351
+ // Check in-memory cache first
352
+ if (machine.currentStates.has(entityId)) {
353
+ return machine.currentStates.get(entityId);
354
+ }
355
+
356
+ // Check persistent storage
357
+ if (this.config.persistTransitions) {
358
+ const stateId = `${machineId}_${entityId}`;
359
+ const [ok, err, stateRecord] = await tryFn(() =>
360
+ this.database.resource(this.config.stateResource).get(stateId)
361
+ );
362
+
363
+ if (ok && stateRecord) {
364
+ machine.currentStates.set(entityId, stateRecord.currentState);
365
+ return stateRecord.currentState;
366
+ }
367
+ }
368
+
369
+ // Default to initial state
370
+ const initialState = machine.config.initialState;
371
+ machine.currentStates.set(entityId, initialState);
372
+ return initialState;
373
+ }
374
+
375
+ /**
376
+ * Get valid events for current state
377
+ */
378
+ getValidEvents(machineId, stateOrEntityId) {
379
+ const machine = this.machines.get(machineId);
380
+ if (!machine) {
381
+ throw new Error(`State machine '${machineId}' not found`);
382
+ }
383
+
384
+ let state;
385
+ if (machine.config.states[stateOrEntityId]) {
386
+ // stateOrEntityId is a state name
387
+ state = stateOrEntityId;
388
+ } else {
389
+ // stateOrEntityId is an entityId, get current state
390
+ state = machine.currentStates.get(stateOrEntityId) || machine.config.initialState;
391
+ }
392
+
393
+ const stateConfig = machine.config.states[state];
394
+ return stateConfig && stateConfig.on ? Object.keys(stateConfig.on) : [];
395
+ }
396
+
397
+ /**
398
+ * Get transition history for an entity
399
+ */
400
+ async getTransitionHistory(machineId, entityId, options = {}) {
401
+ if (!this.config.persistTransitions) {
402
+ return [];
403
+ }
404
+
405
+ const { limit = 50, offset = 0 } = options;
406
+
407
+ const [ok, err, transitions] = await tryFn(() =>
408
+ this.database.resource(this.config.transitionLogResource).list({
409
+ where: { machineId, entityId },
410
+ orderBy: { timestamp: 'desc' },
411
+ limit,
412
+ offset
413
+ })
414
+ );
415
+
416
+ if (!ok) {
417
+ if (this.config.verbose) {
418
+ console.warn(`[StateMachinePlugin] Failed to get transition history:`, err.message);
419
+ }
420
+ return [];
421
+ }
422
+
423
+ // Sort by timestamp descending to ensure newest first
424
+ const sortedTransitions = transitions.sort((a, b) => b.timestamp - a.timestamp);
425
+
426
+ return sortedTransitions.map(t => ({
427
+ from: t.fromState,
428
+ to: t.toState,
429
+ event: t.event,
430
+ context: t.context,
431
+ timestamp: new Date(t.timestamp).toISOString()
432
+ }));
433
+ }
434
+
435
+ /**
436
+ * Initialize entity state (useful for new entities)
437
+ */
438
+ async initializeEntity(machineId, entityId, context = {}) {
439
+ const machine = this.machines.get(machineId);
440
+ if (!machine) {
441
+ throw new Error(`State machine '${machineId}' not found`);
442
+ }
443
+
444
+ const initialState = machine.config.initialState;
445
+ machine.currentStates.set(entityId, initialState);
446
+
447
+ if (this.config.persistTransitions) {
448
+ const now = new Date().toISOString();
449
+ const stateId = `${machineId}_${entityId}`;
450
+
451
+ await this.database.resource(this.config.stateResource).insert({
452
+ id: stateId,
453
+ machineId,
454
+ entityId,
455
+ currentState: initialState,
456
+ context,
457
+ lastTransition: null,
458
+ updatedAt: now
459
+ });
460
+ }
461
+
462
+ // Execute entry action for initial state
463
+ const initialStateConfig = machine.config.states[initialState];
464
+ if (initialStateConfig && initialStateConfig.entry) {
465
+ await this._executeAction(initialStateConfig.entry, context, 'INIT', machineId, entityId);
466
+ }
467
+
468
+ this.emit('entity_initialized', { machineId, entityId, initialState });
469
+
470
+ return initialState;
471
+ }
472
+
473
+ /**
474
+ * Get machine definition
475
+ */
476
+ getMachineDefinition(machineId) {
477
+ const machine = this.machines.get(machineId);
478
+ return machine ? machine.config : null;
479
+ }
480
+
481
+ /**
482
+ * Get all available machines
483
+ */
484
+ getMachines() {
485
+ return Array.from(this.machines.keys());
486
+ }
487
+
488
+ /**
489
+ * Visualize state machine (returns DOT format for graphviz)
490
+ */
491
+ visualize(machineId) {
492
+ const machine = this.machines.get(machineId);
493
+ if (!machine) {
494
+ throw new Error(`State machine '${machineId}' not found`);
495
+ }
496
+
497
+ let dot = `digraph ${machineId} {\n`;
498
+ dot += ` rankdir=LR;\n`;
499
+ dot += ` node [shape=circle];\n`;
500
+
501
+ // Add states
502
+ for (const [stateName, stateConfig] of Object.entries(machine.config.states)) {
503
+ const shape = stateConfig.type === 'final' ? 'doublecircle' : 'circle';
504
+ const color = stateConfig.meta?.color || 'lightblue';
505
+ dot += ` ${stateName} [shape=${shape}, fillcolor=${color}, style=filled];\n`;
506
+ }
507
+
508
+ // Add transitions
509
+ for (const [stateName, stateConfig] of Object.entries(machine.config.states)) {
510
+ if (stateConfig.on) {
511
+ for (const [event, targetState] of Object.entries(stateConfig.on)) {
512
+ dot += ` ${stateName} -> ${targetState} [label="${event}"];\n`;
513
+ }
514
+ }
515
+ }
516
+
517
+ // Mark initial state
518
+ dot += ` start [shape=point];\n`;
519
+ dot += ` start -> ${machine.config.initialState};\n`;
520
+
521
+ dot += `}\n`;
522
+
523
+ return dot;
524
+ }
525
+
526
+ async start() {
527
+ if (this.config.verbose) {
528
+ console.log(`[StateMachinePlugin] Started with ${this.machines.size} state machines`);
529
+ }
530
+ }
531
+
532
+ async stop() {
533
+ this.machines.clear();
534
+ this.stateStorage.clear();
535
+ }
536
+
537
+ async cleanup() {
538
+ await this.stop();
539
+ this.removeAllListeners();
540
+ }
541
+ }
542
+
543
+ export default StateMachinePlugin;