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.
- package/PLUGINS.md +507 -0
- package/README.md +14 -10
- package/dist/s3db-cli.js +54741 -0
- package/dist/s3db.cjs.js +2125 -5702
- package/dist/s3db.cjs.js.map +1 -0
- package/dist/s3db.es.js +2114 -5697
- package/dist/s3db.es.js.map +1 -0
- package/package.json +45 -29
- package/src/cli/index.js +426 -0
- package/src/client.class.js +8 -33
- package/src/concerns/advanced-metadata-encoding.js +440 -0
- package/src/concerns/calculator.js +36 -0
- package/src/concerns/metadata-encoding.js +244 -0
- package/src/concerns/optimized-encoding.js +130 -0
- package/src/plugins/backup.plugin.js +1018 -0
- package/src/plugins/cache/memory-cache.class.js +112 -3
- package/src/plugins/index.js +3 -0
- package/src/plugins/scheduler.plugin.js +834 -0
- package/src/plugins/state-machine.plugin.js +543 -0
- package/dist/s3db.cjs.min.js +0 -1
- package/dist/s3db.es.min.js +0 -1
- package/dist/s3db.iife.js +0 -15738
- package/dist/s3db.iife.min.js +0 -1
|
@@ -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;
|