s3db.js 9.1.0 → 9.2.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.
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import { join } from "path";
2
- import EventEmitter from "events";
3
2
  import { createHash } from "crypto";
3
+ import AsyncEventEmitter from "./concerns/async-event-emitter.js";
4
4
  import { customAlphabet, urlAlphabet } from 'nanoid';
5
5
  import jsonStableStringify from "json-stable-stringify";
6
6
  import { PromisePool } from "@supercharge/promise-pool";
@@ -16,7 +16,7 @@ import { calculateTotalSize, calculateEffectiveLimit } from "./concerns/calculat
16
16
  import { mapAwsError, InvalidResourceItem, ResourceError, PartitionError } from "./errors.js";
17
17
 
18
18
 
19
- export class Resource extends EventEmitter {
19
+ export class Resource extends AsyncEventEmitter {
20
20
  /**
21
21
  * Create a new Resource instance
22
22
  * @param {Object} config - Resource configuration
@@ -40,6 +40,7 @@ export class Resource extends EventEmitter {
40
40
  * @param {number} [config.idSize=22] - Size for auto-generated IDs
41
41
  * @param {boolean} [config.versioningEnabled=false] - Enable versioning for this resource
42
42
  * @param {Object} [config.events={}] - Event listeners to automatically add
43
+ * @param {boolean} [config.asyncEvents=true] - Whether events should be emitted asynchronously
43
44
  * @example
44
45
  * const users = new Resource({
45
46
  * name: 'users',
@@ -133,7 +134,8 @@ export class Resource extends EventEmitter {
133
134
  idGenerator: customIdGenerator,
134
135
  idSize = 22,
135
136
  versioningEnabled = false,
136
- events = {}
137
+ events = {},
138
+ asyncEvents = true
137
139
  } = config;
138
140
 
139
141
  // Set instance properties
@@ -145,6 +147,9 @@ export class Resource extends EventEmitter {
145
147
  this.parallelism = parallelism;
146
148
  this.passphrase = passphrase ?? 'secret';
147
149
  this.versioningEnabled = versioningEnabled;
150
+
151
+ // Configure async events mode
152
+ this.setAsyncMode(asyncEvents);
148
153
 
149
154
  // Configure ID generator
150
155
  this.idGenerator = this.configureIdGenerator(customIdGenerator, idSize);
@@ -171,6 +176,7 @@ export class Resource extends EventEmitter {
171
176
  partitions,
172
177
  autoDecrypt,
173
178
  allNestedObjectsOptional,
179
+ asyncEvents,
174
180
  };
175
181
 
176
182
  // Initialize hooks system
@@ -2452,9 +2458,6 @@ export class Resource extends EventEmitter {
2452
2458
  return filtered;
2453
2459
  }
2454
2460
 
2455
- emit(event, ...args) {
2456
- return super.emit(event, ...args);
2457
- }
2458
2461
 
2459
2462
  async replace(id, attributes) {
2460
2463
  await this.delete(id);