s3db.js 9.3.0 → 10.0.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.
@@ -79,7 +79,7 @@ import tryFn from "../concerns/try-fn.js";
79
79
  * },
80
80
  *
81
81
  * persistTransitions: true,
82
- * transitionLogResource: 'state_transitions'
82
+ * transitionLogResource: 'plg_state_transitions'
83
83
  * });
84
84
  *
85
85
  * === Usage ===
@@ -91,7 +91,7 @@ import tryFn from "../concerns/try-fn.js";
91
91
  * const state = await stateMachine.getState('order_processing', orderId);
92
92
  *
93
93
  * // Get valid events for current state
94
- * const validEvents = stateMachine.getValidEvents('order_processing', 'pending');
94
+ * const validEvents = await stateMachine.getValidEvents('order_processing', 'pending');
95
95
  *
96
96
  * // Get transition history
97
97
  * const history = await stateMachine.getTransitionHistory('order_processing', orderId);
@@ -105,15 +105,15 @@ export class StateMachinePlugin extends Plugin {
105
105
  actions: options.actions || {},
106
106
  guards: options.guards || {},
107
107
  persistTransitions: options.persistTransitions !== false,
108
- transitionLogResource: options.transitionLogResource || 'state_transitions',
109
- stateResource: options.stateResource || 'entity_states',
110
- verbose: options.verbose || false,
111
- ...options
108
+ transitionLogResource: options.transitionLogResource || 'plg_state_transitions',
109
+ stateResource: options.stateResource || 'plg_entity_states',
110
+ retryAttempts: options.retryAttempts || 3,
111
+ retryDelay: options.retryDelay || 100,
112
+ verbose: options.verbose || false
112
113
  };
113
-
114
+
114
115
  this.database = null;
115
116
  this.machines = new Map();
116
- this.stateStorage = new Map(); // In-memory cache for states
117
117
 
118
118
  this._validateConfiguration();
119
119
  }
@@ -292,49 +292,68 @@ export class StateMachinePlugin extends Plugin {
292
292
  // Persist transition log
293
293
  if (this.config.persistTransitions) {
294
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
-
295
+
296
+ // Retry transition logging (critical for audit trail)
297
+ let logOk = false;
298
+ let lastLogErr;
299
+
300
+ for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) {
301
+ const [ok, err] = await tryFn(() =>
302
+ this.database.resource(this.config.transitionLogResource).insert({
303
+ id: transitionId,
304
+ machineId,
305
+ entityId,
306
+ fromState,
307
+ toState,
308
+ event,
309
+ context,
310
+ timestamp,
311
+ createdAt: now.slice(0, 10) // YYYY-MM-DD for partitioning
312
+ })
313
+ );
314
+
315
+ if (ok) {
316
+ logOk = true;
317
+ break;
318
+ }
319
+
320
+ lastLogErr = err;
321
+
322
+ if (attempt < this.config.retryAttempts - 1) {
323
+ const delay = this.config.retryDelay * Math.pow(2, attempt);
324
+ await new Promise(resolve => setTimeout(resolve, delay));
325
+ }
326
+ }
327
+
310
328
  if (!logOk && this.config.verbose) {
311
- console.warn(`[StateMachinePlugin] Failed to log transition:`, logErr.message);
329
+ console.warn(`[StateMachinePlugin] Failed to log transition after ${this.config.retryAttempts} attempts:`, lastLogErr.message);
312
330
  }
313
-
314
- // Update current state
331
+
332
+ // Update current state with upsert pattern
315
333
  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);
334
+ const stateData = {
335
+ machineId,
336
+ entityId,
337
+ currentState: toState,
338
+ context,
339
+ lastTransition: transitionId,
340
+ updatedAt: now
341
+ };
342
+
343
+ // Try update first (most common case), fallback to insert if doesn't exist
344
+ const [updateOk] = await tryFn(() =>
345
+ this.database.resource(this.config.stateResource).update(stateId, stateData)
346
+ );
347
+
348
+ if (!updateOk) {
349
+ // Record doesn't exist, insert it
350
+ const [insertOk, insertErr] = await tryFn(() =>
351
+ this.database.resource(this.config.stateResource).insert({ id: stateId, ...stateData })
352
+ );
353
+
354
+ if (!insertOk && this.config.verbose) {
355
+ console.warn(`[StateMachinePlugin] Failed to upsert state:`, insertErr.message);
333
356
  }
334
- });
335
-
336
- if (!stateOk && this.config.verbose) {
337
- console.warn(`[StateMachinePlugin] Failed to update state:`, stateErr.message);
338
357
  }
339
358
  }
340
359
  }
@@ -374,22 +393,23 @@ export class StateMachinePlugin extends Plugin {
374
393
 
375
394
  /**
376
395
  * Get valid events for current state
396
+ * Can accept either a state name (sync) or entityId (async to fetch latest state)
377
397
  */
378
- getValidEvents(machineId, stateOrEntityId) {
398
+ async getValidEvents(machineId, stateOrEntityId) {
379
399
  const machine = this.machines.get(machineId);
380
400
  if (!machine) {
381
401
  throw new Error(`State machine '${machineId}' not found`);
382
402
  }
383
-
403
+
384
404
  let state;
385
405
  if (machine.config.states[stateOrEntityId]) {
386
- // stateOrEntityId is a state name
406
+ // stateOrEntityId is a state name - direct lookup
387
407
  state = stateOrEntityId;
388
408
  } else {
389
- // stateOrEntityId is an entityId, get current state
390
- state = machine.currentStates.get(stateOrEntityId) || machine.config.initialState;
409
+ // stateOrEntityId is an entityId - fetch latest state from storage
410
+ state = await this.getState(machineId, stateOrEntityId);
391
411
  }
392
-
412
+
393
413
  const stateConfig = machine.config.states[state];
394
414
  return stateConfig && stateConfig.on ? Object.keys(stateConfig.on) : [];
395
415
  }
@@ -401,29 +421,30 @@ export class StateMachinePlugin extends Plugin {
401
421
  if (!this.config.persistTransitions) {
402
422
  return [];
403
423
  }
404
-
424
+
405
425
  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' },
426
+
427
+ const [ok, err, transitions] = await tryFn(() =>
428
+ this.database.resource(this.config.transitionLogResource).query({
429
+ machineId,
430
+ entityId
431
+ }, {
411
432
  limit,
412
433
  offset
413
434
  })
414
435
  );
415
-
436
+
416
437
  if (!ok) {
417
438
  if (this.config.verbose) {
418
439
  console.warn(`[StateMachinePlugin] Failed to get transition history:`, err.message);
419
440
  }
420
441
  return [];
421
442
  }
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 => ({
443
+
444
+ // Sort by timestamp descending (newest first)
445
+ const sorted = (transitions || []).sort((a, b) => b.timestamp - a.timestamp);
446
+
447
+ return sorted.map(t => ({
427
448
  from: t.fromState,
428
449
  to: t.toState,
429
450
  event: t.event,
@@ -440,33 +461,41 @@ export class StateMachinePlugin extends Plugin {
440
461
  if (!machine) {
441
462
  throw new Error(`State machine '${machineId}' not found`);
442
463
  }
443
-
464
+
444
465
  const initialState = machine.config.initialState;
445
466
  machine.currentStates.set(entityId, initialState);
446
-
467
+
447
468
  if (this.config.persistTransitions) {
448
469
  const now = new Date().toISOString();
449
470
  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
- });
471
+
472
+ // Try to insert, ignore if already exists (idempotent)
473
+ const [ok, err] = await tryFn(() =>
474
+ this.database.resource(this.config.stateResource).insert({
475
+ id: stateId,
476
+ machineId,
477
+ entityId,
478
+ currentState: initialState,
479
+ context,
480
+ lastTransition: null,
481
+ updatedAt: now
482
+ })
483
+ );
484
+
485
+ // Only throw if error is NOT "already exists"
486
+ if (!ok && err && !err.message?.includes('already exists')) {
487
+ throw new Error(`Failed to initialize entity state: ${err.message}`);
488
+ }
460
489
  }
461
-
490
+
462
491
  // Execute entry action for initial state
463
492
  const initialStateConfig = machine.config.states[initialState];
464
493
  if (initialStateConfig && initialStateConfig.entry) {
465
494
  await this._executeAction(initialStateConfig.entry, context, 'INIT', machineId, entityId);
466
495
  }
467
-
496
+
468
497
  this.emit('entity_initialized', { machineId, entityId, initialState });
469
-
498
+
470
499
  return initialState;
471
500
  }
472
501
 
@@ -531,7 +560,6 @@ export class StateMachinePlugin extends Plugin {
531
560
 
532
561
  async stop() {
533
562
  this.machines.clear();
534
- this.stateStorage.clear();
535
563
  }
536
564
 
537
565
  async cleanup() {
@@ -926,6 +926,7 @@ export class Resource extends AsyncEventEmitter {
926
926
  data._lastModified = request.LastModified;
927
927
  data._hasContent = request.ContentLength > 0;
928
928
  data._mimeType = request.ContentType || null;
929
+ data._etag = request.ETag;
929
930
  data._v = objectVersion;
930
931
 
931
932
  // Add version info to returned data
@@ -1159,6 +1160,210 @@ export class Resource extends AsyncEventEmitter {
1159
1160
  }
1160
1161
  }
1161
1162
 
1163
+ /**
1164
+ * Update with conditional check (If-Match ETag)
1165
+ * @param {string} id - Resource ID
1166
+ * @param {Object} attributes - Attributes to update
1167
+ * @param {Object} options - Options including ifMatch (ETag)
1168
+ * @returns {Promise<Object>} { success: boolean, data?: Object, etag?: string, error?: string }
1169
+ * @example
1170
+ * const msg = await resource.get('msg-123');
1171
+ * const result = await resource.updateConditional('msg-123', { status: 'processing' }, { ifMatch: msg._etag });
1172
+ * if (!result.success) {
1173
+ * console.log('Update failed - object was modified by another process');
1174
+ * }
1175
+ */
1176
+ async updateConditional(id, attributes, options = {}) {
1177
+ if (isEmpty(id)) {
1178
+ throw new Error('id cannot be empty');
1179
+ }
1180
+
1181
+ const { ifMatch } = options;
1182
+ if (!ifMatch) {
1183
+ throw new Error('updateConditional requires ifMatch option with ETag value');
1184
+ }
1185
+
1186
+ // Check if resource exists
1187
+ const exists = await this.exists(id);
1188
+ if (!exists) {
1189
+ return {
1190
+ success: false,
1191
+ error: `Resource with id '${id}' does not exist`
1192
+ };
1193
+ }
1194
+
1195
+ // Get original data
1196
+ const originalData = await this.get(id);
1197
+ const attributesClone = cloneDeep(attributes);
1198
+ let mergedData = cloneDeep(originalData);
1199
+
1200
+ // Merge attributes (same logic as update)
1201
+ for (const [key, value] of Object.entries(attributesClone)) {
1202
+ if (key.includes('.')) {
1203
+ let ref = mergedData;
1204
+ const parts = key.split('.');
1205
+ for (let i = 0; i < parts.length - 1; i++) {
1206
+ if (typeof ref[parts[i]] !== 'object' || ref[parts[i]] === null) {
1207
+ ref[parts[i]] = {};
1208
+ }
1209
+ ref = ref[parts[i]];
1210
+ }
1211
+ ref[parts[parts.length - 1]] = cloneDeep(value);
1212
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1213
+ mergedData[key] = merge({}, mergedData[key], value);
1214
+ } else {
1215
+ mergedData[key] = cloneDeep(value);
1216
+ }
1217
+ }
1218
+
1219
+ // Update timestamps if enabled
1220
+ if (this.config.timestamps) {
1221
+ const now = new Date().toISOString();
1222
+ mergedData.updatedAt = now;
1223
+ if (!mergedData.metadata) mergedData.metadata = {};
1224
+ mergedData.metadata.updatedAt = now;
1225
+ }
1226
+
1227
+ // Execute beforeUpdate hooks
1228
+ const preProcessedData = await this.executeHooks('beforeUpdate', cloneDeep(mergedData));
1229
+ const completeData = { ...originalData, ...preProcessedData, id };
1230
+
1231
+ // Validate
1232
+ const { isValid, errors, data } = await this.validate(cloneDeep(completeData));
1233
+ if (!isValid) {
1234
+ return {
1235
+ success: false,
1236
+ error: 'Validation failed: ' + ((errors && errors.length) ? JSON.stringify(errors) : 'unknown'),
1237
+ validationErrors: errors
1238
+ };
1239
+ }
1240
+
1241
+ // Prepare data for storage
1242
+ const { id: validatedId, ...validatedAttributes } = data;
1243
+ const mappedData = await this.schema.mapper(validatedAttributes);
1244
+ mappedData._v = String(this.version);
1245
+
1246
+ const behaviorImpl = getBehavior(this.behavior);
1247
+ const { mappedData: processedMetadata, body } = await behaviorImpl.handleUpdate({
1248
+ resource: this,
1249
+ id,
1250
+ data: validatedAttributes,
1251
+ mappedData,
1252
+ originalData: { ...attributesClone, id }
1253
+ });
1254
+
1255
+ const key = this.getResourceKey(id);
1256
+ let existingContentType = undefined;
1257
+ let finalBody = body;
1258
+
1259
+ if (body === "" && this.behavior !== 'body-overflow') {
1260
+ const [ok, err, existingObject] = await tryFn(() => this.client.getObject(key));
1261
+ if (ok && existingObject.ContentLength > 0) {
1262
+ const existingBodyBuffer = Buffer.from(await existingObject.Body.transformToByteArray());
1263
+ const existingBodyString = existingBodyBuffer.toString();
1264
+ const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(existingBodyString)));
1265
+ if (!okParse) {
1266
+ finalBody = existingBodyBuffer;
1267
+ existingContentType = existingObject.ContentType;
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ let finalContentType = existingContentType;
1273
+ if (finalBody && finalBody !== "" && !finalContentType) {
1274
+ const [okParse, errParse] = await tryFn(() => Promise.resolve(JSON.parse(finalBody)));
1275
+ if (okParse) finalContentType = 'application/json';
1276
+ }
1277
+
1278
+ // Attempt conditional write with IfMatch
1279
+ const [ok, err, response] = await tryFn(() => this.client.putObject({
1280
+ key,
1281
+ body: finalBody,
1282
+ contentType: finalContentType,
1283
+ metadata: processedMetadata,
1284
+ ifMatch // ← Conditional write with ETag
1285
+ }));
1286
+
1287
+ if (!ok) {
1288
+ // Check if it's a PreconditionFailed error (412)
1289
+ if (err.name === 'PreconditionFailed' || err.$metadata?.httpStatusCode === 412) {
1290
+ return {
1291
+ success: false,
1292
+ error: 'ETag mismatch - object was modified by another process'
1293
+ };
1294
+ }
1295
+
1296
+ // Other errors
1297
+ return {
1298
+ success: false,
1299
+ error: err.message || 'Update failed'
1300
+ };
1301
+ }
1302
+
1303
+ // Success - compose updated data
1304
+ const updatedData = await this.composeFullObjectFromWrite({
1305
+ id,
1306
+ metadata: processedMetadata,
1307
+ body: finalBody,
1308
+ behavior: this.behavior
1309
+ });
1310
+
1311
+ // Handle partition updates (async if configured)
1312
+ const oldData = { ...originalData, id };
1313
+ const newData = { ...validatedAttributes, id };
1314
+
1315
+ if (this.config.asyncPartitions && this.config.partitions && Object.keys(this.config.partitions).length > 0) {
1316
+ // Async mode
1317
+ setImmediate(() => {
1318
+ this.handlePartitionReferenceUpdates(oldData, newData).catch(err => {
1319
+ this.emit('partitionIndexError', {
1320
+ operation: 'updateConditional',
1321
+ id,
1322
+ error: err,
1323
+ message: err.message
1324
+ });
1325
+ });
1326
+ });
1327
+
1328
+ // Execute non-partition hooks
1329
+ const nonPartitionHooks = this.hooks.afterUpdate.filter(hook =>
1330
+ !hook.toString().includes('handlePartitionReferenceUpdates')
1331
+ );
1332
+ let finalResult = updatedData;
1333
+ for (const hook of nonPartitionHooks) {
1334
+ finalResult = await hook(finalResult);
1335
+ }
1336
+
1337
+ this.emit('update', {
1338
+ ...updatedData,
1339
+ $before: { ...originalData },
1340
+ $after: { ...finalResult }
1341
+ });
1342
+
1343
+ return {
1344
+ success: true,
1345
+ data: finalResult,
1346
+ etag: response.ETag
1347
+ };
1348
+ } else {
1349
+ // Sync mode
1350
+ await this.handlePartitionReferenceUpdates(oldData, newData);
1351
+ const finalResult = await this.executeHooks('afterUpdate', updatedData);
1352
+
1353
+ this.emit('update', {
1354
+ ...updatedData,
1355
+ $before: { ...originalData },
1356
+ $after: { ...finalResult }
1357
+ });
1358
+
1359
+ return {
1360
+ success: true,
1361
+ data: finalResult,
1362
+ etag: response.ETag
1363
+ };
1364
+ }
1365
+ }
1366
+
1162
1367
  /**
1163
1368
  * Delete a resource object by ID
1164
1369
  * @param {string} id - Resource ID