s3db.js 11.0.5 → 11.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "11.0.5",
3
+ "version": "11.1.0",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -140,6 +140,7 @@
140
140
  "test:plugins": "node --no-warnings --experimental-vm-modules node_modules/jest/bin/jest.js tests/plugins/ --testTimeout=60000",
141
141
  "test:full": "pnpm run test:js && pnpm run test:ts",
142
142
  "benchmark": "node benchmark-compression.js",
143
+ "benchmark:partitions": "node docs/benchmarks/partitions-matrix.js",
143
144
  "version": "echo 'Use pnpm run release v<version> instead of npm version'",
144
145
  "release:check": "./scripts/pre-release-check.sh",
145
146
  "release:prepare": "pnpm run build:binaries && echo 'Binaries ready for GitHub release'",
@@ -1,19 +1,13 @@
1
1
  import { CryptoError } from "../errors.js";
2
2
  import tryFn, { tryFnSync } from "./try-fn.js";
3
+ import crypto from 'crypto';
3
4
 
4
5
  async function dynamicCrypto() {
5
6
  let lib;
6
7
 
7
8
  if (typeof process !== 'undefined') {
8
- const [ok, err, result] = await tryFn(async () => {
9
- const { webcrypto } = await import('crypto');
10
- return webcrypto;
11
- });
12
- if (ok) {
13
- lib = result;
14
- } else {
15
- throw new CryptoError('Crypto API not available', { original: err, context: 'dynamicCrypto' });
16
- }
9
+ // Use the static import instead of dynamic import
10
+ lib = crypto.webcrypto;
17
11
  } else if (typeof window !== 'undefined') {
18
12
  lib = window.crypto;
19
13
  }
@@ -86,16 +80,15 @@ export async function md5(data) {
86
80
  if (typeof process === 'undefined') {
87
81
  throw new CryptoError('MD5 hashing is only available in Node.js environment', { context: 'md5' });
88
82
  }
89
-
83
+
90
84
  const [ok, err, result] = await tryFn(async () => {
91
- const { createHash } = await import('crypto');
92
- return createHash('md5').update(data).digest('base64');
85
+ return crypto.createHash('md5').update(data).digest('base64');
93
86
  });
94
-
87
+
95
88
  if (!ok) {
96
89
  throw new CryptoError('MD5 hashing failed', { original: err, data });
97
90
  }
98
-
91
+
99
92
  return result;
100
93
  }
101
94
 
@@ -272,6 +272,8 @@ export async function consolidateRecord(
272
272
  }
273
273
 
274
274
  currentValue = 0;
275
+ // Clear the applied transactions array since we deleted them
276
+ appliedTransactions.length = 0;
275
277
  } else {
276
278
  // Record exists - use applied transactions to calculate current value
277
279
  // Sort by timestamp to get chronological order
@@ -290,40 +292,44 @@ export async function consolidateRecord(
290
292
  // Solution: Get the current record value and create an anchor transaction now
291
293
  const recordValue = recordExists[config.field] || 0;
292
294
 
293
- // Calculate what the base value was by subtracting all applied deltas
294
- let appliedDelta = 0;
295
- for (const t of appliedTransactions) {
296
- if (t.operation === 'add') appliedDelta += t.value;
297
- else if (t.operation === 'sub') appliedDelta -= t.value;
298
- }
295
+ // Only create anchor if recordValue is a number (not object/array for nested fields)
296
+ if (typeof recordValue === 'number') {
297
+ // Calculate what the base value was by subtracting all applied deltas
298
+ let appliedDelta = 0;
299
+ for (const t of appliedTransactions) {
300
+ if (t.operation === 'add') appliedDelta += t.value;
301
+ else if (t.operation === 'sub') appliedDelta -= t.value;
302
+ }
299
303
 
300
- const baseValue = recordValue - appliedDelta;
301
-
302
- // Create and save anchor transaction with the base value
303
- // Only create if baseValue is non-zero AND we don't already have an anchor transaction
304
- const hasExistingAnchor = appliedTransactions.some(t => t.source === 'anchor');
305
- if (baseValue !== 0 && !hasExistingAnchor) {
306
- // Use the timestamp of the first applied transaction for cohort info
307
- const firstTransactionDate = new Date(appliedTransactions[0].timestamp);
308
- const cohortInfo = getCohortInfo(firstTransactionDate, config.cohort.timezone, config.verbose);
309
- const anchorTransaction = {
310
- id: idGenerator(),
311
- originalId: originalId,
312
- field: config.field,
313
- value: baseValue,
314
- operation: 'set',
315
- timestamp: new Date(firstTransactionDate.getTime() - 1).toISOString(), // 1ms before first txn to ensure it's first
316
- cohortDate: cohortInfo.date,
317
- cohortHour: cohortInfo.hour,
318
- cohortMonth: cohortInfo.month,
319
- source: 'anchor',
320
- applied: true
321
- };
322
-
323
- await transactionResource.insert(anchorTransaction);
324
-
325
- // Prepend to applied transactions for this consolidation
326
- appliedTransactions.unshift(anchorTransaction);
304
+ const baseValue = recordValue - appliedDelta;
305
+
306
+ // Create and save anchor transaction with the base value
307
+ // Only create if baseValue is non-zero AND we don't already have an anchor transaction
308
+ const hasExistingAnchor = appliedTransactions.some(t => t.source === 'anchor');
309
+ if (baseValue !== 0 && typeof baseValue === 'number' && !hasExistingAnchor) {
310
+ // Use the timestamp of the first applied transaction for cohort info
311
+ const firstTransactionDate = new Date(appliedTransactions[0].timestamp);
312
+ const cohortInfo = getCohortInfo(firstTransactionDate, config.cohort.timezone, config.verbose);
313
+ const anchorTransaction = {
314
+ id: idGenerator(),
315
+ originalId: originalId,
316
+ field: config.field,
317
+ fieldPath: config.field, // Add fieldPath for consistency
318
+ value: baseValue,
319
+ operation: 'set',
320
+ timestamp: new Date(firstTransactionDate.getTime() - 1).toISOString(), // 1ms before first txn to ensure it's first
321
+ cohortDate: cohortInfo.date,
322
+ cohortHour: cohortInfo.hour,
323
+ cohortMonth: cohortInfo.month,
324
+ source: 'anchor',
325
+ applied: true
326
+ };
327
+
328
+ await transactionResource.insert(anchorTransaction);
329
+
330
+ // Prepend to applied transactions for this consolidation
331
+ appliedTransactions.unshift(anchorTransaction);
332
+ }
327
333
  }
328
334
  }
329
335
 
@@ -340,7 +346,8 @@ export async function consolidateRecord(
340
346
 
341
347
  // If there's an initial value, create and save an anchor transaction
342
348
  // This ensures all future consolidations have a reliable base value
343
- if (currentValue !== 0) {
349
+ // IMPORTANT: Only create anchor if currentValue is a number (not object/array for nested fields)
350
+ if (currentValue !== 0 && typeof currentValue === 'number') {
344
351
  // Use timestamp of the first pending transaction (or current time if none)
345
352
  let anchorTimestamp;
346
353
  if (transactions && transactions.length > 0) {
@@ -355,6 +362,7 @@ export async function consolidateRecord(
355
362
  id: idGenerator(),
356
363
  originalId: originalId,
357
364
  field: config.field,
365
+ fieldPath: config.field, // Add fieldPath for consistency
358
366
  value: currentValue,
359
367
  operation: 'set',
360
368
  timestamp: anchorTimestamp,
@@ -389,22 +397,75 @@ export async function consolidateRecord(
389
397
  new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
390
398
  );
391
399
 
392
- // If there's a current value and no 'set' operations in pending transactions,
393
- // prepend a synthetic set transaction to preserve the current value
394
- const hasSetOperation = transactions.some(t => t.operation === 'set');
395
- if (currentValue !== 0 && !hasSetOperation) {
396
- transactions.unshift(createSyntheticSetTransaction(currentValue));
400
+ // Group PENDING transactions by fieldPath to support nested fields
401
+ const transactionsByPath = {};
402
+ for (const txn of transactions) {
403
+ const path = txn.fieldPath || txn.field || config.field;
404
+ if (!transactionsByPath[path]) {
405
+ transactionsByPath[path] = [];
406
+ }
407
+ transactionsByPath[path].push(txn);
397
408
  }
398
409
 
399
- // Apply reducer to get consolidated value
400
- const consolidatedValue = config.reducer(transactions);
410
+ // For each fieldPath, we need the currentValue from applied transactions
411
+ // Group APPLIED transactions by fieldPath
412
+ const appliedByPath = {};
413
+ if (appliedOk && appliedTransactions && appliedTransactions.length > 0) {
414
+ for (const txn of appliedTransactions) {
415
+ const path = txn.fieldPath || txn.field || config.field;
416
+ if (!appliedByPath[path]) {
417
+ appliedByPath[path] = [];
418
+ }
419
+ appliedByPath[path].push(txn);
420
+ }
421
+ }
401
422
 
402
- if (config.verbose) {
403
- console.log(
404
- `[EventualConsistency] ${config.resource}.${config.field} - ` +
405
- `${originalId}: ${currentValue} → ${consolidatedValue} ` +
406
- `(${consolidatedValue > currentValue ? '+' : ''}${consolidatedValue - currentValue})`
407
- );
423
+ // Consolidate each fieldPath group separately
424
+ const consolidatedValues = {};
425
+ const lodash = await import('lodash-es');
426
+
427
+ // Get current record to extract existing values for nested paths
428
+ const [currentRecordOk, currentRecordErr, currentRecord] = await tryFn(() =>
429
+ targetResource.get(originalId)
430
+ );
431
+
432
+ for (const [fieldPath, pathTransactions] of Object.entries(transactionsByPath)) {
433
+ // Calculate current value for this path from applied transactions
434
+ let pathCurrentValue = 0;
435
+ if (appliedByPath[fieldPath] && appliedByPath[fieldPath].length > 0) {
436
+ // Sort applied transactions by timestamp
437
+ appliedByPath[fieldPath].sort((a, b) =>
438
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
439
+ );
440
+ // Apply reducer to get current value from applied transactions
441
+ pathCurrentValue = config.reducer(appliedByPath[fieldPath]);
442
+ } else {
443
+ // No applied transactions yet - use value from record (first consolidation)
444
+ // This happens when there's an initial value in the record before any consolidation
445
+ if (currentRecordOk && currentRecord) {
446
+ const recordValue = lodash.get(currentRecord, fieldPath, 0);
447
+ if (typeof recordValue === 'number') {
448
+ pathCurrentValue = recordValue;
449
+ }
450
+ }
451
+ }
452
+
453
+ // Prepend synthetic set transaction with current value
454
+ if (pathCurrentValue !== 0) {
455
+ pathTransactions.unshift(createSyntheticSetTransaction(pathCurrentValue));
456
+ }
457
+
458
+ // Apply reducer to get consolidated value for this path
459
+ const pathConsolidatedValue = config.reducer(pathTransactions);
460
+ consolidatedValues[fieldPath] = pathConsolidatedValue;
461
+
462
+ if (config.verbose) {
463
+ console.log(
464
+ `[EventualConsistency] ${config.resource}.${fieldPath} - ` +
465
+ `${originalId}: ${pathCurrentValue} → ${pathConsolidatedValue} ` +
466
+ `(${pathTransactions.length - (pathCurrentValue !== 0 ? 1 : 0)} pending txns)`
467
+ );
468
+ }
408
469
  }
409
470
 
410
471
  // 🔥 DEBUG: Log BEFORE update
@@ -412,63 +473,105 @@ export async function consolidateRecord(
412
473
  console.log(
413
474
  `🔥 [DEBUG] BEFORE targetResource.update() {` +
414
475
  `\n originalId: '${originalId}',` +
415
- `\n field: '${config.field}',` +
416
- `\n consolidatedValue: ${consolidatedValue},` +
417
- `\n currentValue: ${currentValue}` +
476
+ `\n consolidatedValues: ${JSON.stringify(consolidatedValues, null, 2)}` +
418
477
  `\n}`
419
478
  );
420
479
  }
421
480
 
422
- // Update the original record
423
- // NOTE: We do NOT attempt to insert non-existent records because:
424
- // 1. Target resources typically have required fields we don't know about
425
- // 2. Record creation should be the application's responsibility
426
- // 3. Transactions will remain pending until the record is created
427
- const [updateOk, updateErr, updateResult] = await tryFn(() =>
428
- targetResource.update(originalId, {
429
- [config.field]: consolidatedValue
430
- })
481
+ // Build update object using lodash.set for nested paths
482
+ // Get fresh record to avoid overwriting other fields
483
+ const [recordOk, recordErr, record] = await tryFn(() =>
484
+ targetResource.get(originalId)
431
485
  );
432
486
 
487
+ let updateOk, updateErr, updateResult;
488
+
489
+ if (!recordOk || !record) {
490
+ // Record doesn't exist - we'll let the update fail and handle it below
491
+ // This ensures transactions remain pending until record is created
492
+ if (config.verbose) {
493
+ console.log(
494
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
495
+ `Record ${originalId} doesn't exist yet. Will attempt update anyway (expected to fail).`
496
+ );
497
+ }
498
+
499
+ // Create a minimal record object with just our field
500
+ const minimalRecord = { id: originalId };
501
+ for (const [fieldPath, value] of Object.entries(consolidatedValues)) {
502
+ lodash.set(minimalRecord, fieldPath, value);
503
+ }
504
+
505
+ // Try to update (will fail, handled below)
506
+ const result = await tryFn(() =>
507
+ targetResource.update(originalId, minimalRecord)
508
+ );
509
+ updateOk = result[0];
510
+ updateErr = result[1];
511
+ updateResult = result[2];
512
+ } else {
513
+ // Record exists - apply all consolidated values using lodash.set
514
+ for (const [fieldPath, value] of Object.entries(consolidatedValues)) {
515
+ lodash.set(record, fieldPath, value);
516
+ }
517
+
518
+ // Update the original record with all changes
519
+ // NOTE: We update the entire record to preserve nested structures
520
+ const result = await tryFn(() =>
521
+ targetResource.update(originalId, record)
522
+ );
523
+ updateOk = result[0];
524
+ updateErr = result[1];
525
+ updateResult = result[2];
526
+ }
527
+
528
+ // For backward compatibility, return the value of the main field
529
+ const consolidatedValue = consolidatedValues[config.field] ||
530
+ (record ? lodash.get(record, config.field, 0) : 0);
531
+
433
532
  // 🔥 DEBUG: Log AFTER update
434
533
  if (config.verbose) {
435
534
  console.log(
436
535
  `🔥 [DEBUG] AFTER targetResource.update() {` +
437
536
  `\n updateOk: ${updateOk},` +
438
537
  `\n updateErr: ${updateErr?.message || 'undefined'},` +
439
- `\n updateResult: ${JSON.stringify(updateResult, null, 2)},` +
440
- `\n hasField: ${updateResult?.[config.field]}` +
538
+ `\n consolidatedValue (main field): ${consolidatedValue}` +
441
539
  `\n}`
442
540
  );
443
541
  }
444
542
 
445
- // 🔥 VERIFY: Check if update actually persisted
543
+ // 🔥 VERIFY: Check if update actually persisted for all fieldPaths
446
544
  if (updateOk && config.verbose) {
447
545
  // Bypass cache to get fresh data
448
546
  const [verifyOk, verifyErr, verifiedRecord] = await tryFn(() =>
449
547
  targetResource.get(originalId, { skipCache: true })
450
548
  );
451
549
 
452
- console.log(
453
- `🔥 [DEBUG] VERIFICATION (fresh from S3, no cache) {` +
454
- `\n verifyOk: ${verifyOk},` +
455
- `\n verifiedRecord[${config.field}]: ${verifiedRecord?.[config.field]},` +
456
- `\n expectedValue: ${consolidatedValue},` +
457
- `\n ✅ MATCH: ${verifiedRecord?.[config.field] === consolidatedValue}` +
458
- `\n}`
459
- );
550
+ // Verify each fieldPath
551
+ for (const [fieldPath, expectedValue] of Object.entries(consolidatedValues)) {
552
+ const actualValue = lodash.get(verifiedRecord, fieldPath);
553
+ const match = actualValue === expectedValue;
460
554
 
461
- // If verification fails, this is a critical bug
462
- if (verifyOk && verifiedRecord?.[config.field] !== consolidatedValue) {
463
- console.error(
464
- `❌ [CRITICAL BUG] Update reported success but value not persisted!` +
465
- `\n Resource: ${config.resource}` +
466
- `\n Field: ${config.field}` +
467
- `\n Record ID: ${originalId}` +
468
- `\n Expected: ${consolidatedValue}` +
469
- `\n Actually got: ${verifiedRecord?.[config.field]}` +
470
- `\n This indicates a bug in s3db.js resource.update()`
555
+ console.log(
556
+ `🔥 [DEBUG] VERIFICATION ${fieldPath} {` +
557
+ `\n expectedValue: ${expectedValue},` +
558
+ `\n actualValue: ${actualValue},` +
559
+ `\n ${match ? '✅ MATCH' : '❌ MISMATCH'}` +
560
+ `\n}`
471
561
  );
562
+
563
+ // If verification fails, this is a critical bug
564
+ if (!match) {
565
+ console.error(
566
+ `❌ [CRITICAL BUG] Update reported success but value not persisted!` +
567
+ `\n Resource: ${config.resource}` +
568
+ `\n FieldPath: ${fieldPath}` +
569
+ `\n Record ID: ${originalId}` +
570
+ `\n Expected: ${expectedValue}` +
571
+ `\n Actually got: ${actualValue}` +
572
+ `\n This indicates a bug in s3db.js resource.update()`
573
+ );
574
+ }
472
575
  }
473
576
  }
474
577
 
@@ -765,6 +868,51 @@ export async function recalculateRecord(
765
868
  );
766
869
  }
767
870
 
871
+ // Check if there's an anchor transaction
872
+ const hasAnchor = allTransactions.some(txn => txn.source === 'anchor');
873
+
874
+ // If no anchor exists, create one with value 0 to serve as the baseline
875
+ // This ensures recalculate is idempotent - running it multiple times produces same result
876
+ if (!hasAnchor) {
877
+ const now = new Date();
878
+ const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
879
+
880
+ // Create anchor transaction with timestamp before all other transactions
881
+ const oldestTransaction = allTransactions.sort((a, b) =>
882
+ new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
883
+ )[0];
884
+
885
+ const anchorTimestamp = oldestTransaction
886
+ ? new Date(new Date(oldestTransaction.timestamp).getTime() - 1).toISOString()
887
+ : now.toISOString();
888
+
889
+ const anchorCohortInfo = getCohortInfo(new Date(anchorTimestamp), config.cohort.timezone, config.verbose);
890
+
891
+ const anchorTransaction = {
892
+ id: idGenerator(),
893
+ originalId: originalId,
894
+ field: config.field,
895
+ fieldPath: config.field,
896
+ value: 0, // Always 0 for recalculate - we start from scratch
897
+ operation: 'set',
898
+ timestamp: anchorTimestamp,
899
+ cohortDate: anchorCohortInfo.date,
900
+ cohortHour: anchorCohortInfo.hour,
901
+ cohortMonth: anchorCohortInfo.month,
902
+ source: 'anchor',
903
+ applied: true // Anchor is always applied
904
+ };
905
+
906
+ await transactionResource.insert(anchorTransaction);
907
+
908
+ if (config.verbose) {
909
+ console.log(
910
+ `[EventualConsistency] ${config.resource}.${config.field} - ` +
911
+ `Created anchor transaction for ${originalId} with value 0`
912
+ );
913
+ }
914
+ }
915
+
768
916
  // Mark ALL transactions as pending (applied: false)
769
917
  // Exclude anchor transactions (they should always be applied)
770
918
  const transactionsToReset = allTransactions.filter(txn => txn.source !== 'anchor');
@@ -18,8 +18,9 @@ import { getCohortInfo, resolveFieldAndPlugin } from "./utils.js";
18
18
  export function addHelperMethods(resource, plugin, config) {
19
19
  // Add method to set value (replaces current value)
20
20
  // Signature: set(id, field, value)
21
+ // Supports dot notation: set(id, 'utmResults.medium', 10)
21
22
  resource.set = async (id, field, value) => {
22
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, value);
23
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, value);
23
24
 
24
25
  // Create transaction inline
25
26
  const now = new Date();
@@ -29,6 +30,7 @@ export function addHelperMethods(resource, plugin, config) {
29
30
  id: idGenerator(),
30
31
  originalId: id,
31
32
  field: handler.field,
33
+ fieldPath: fieldPath, // Store full path for nested access
32
34
  value: value,
33
35
  operation: 'set',
34
36
  timestamp: now.toISOString(),
@@ -43,7 +45,7 @@ export function addHelperMethods(resource, plugin, config) {
43
45
 
44
46
  // In sync mode, immediately consolidate
45
47
  if (config.mode === 'sync') {
46
- return await plugin._syncModeConsolidate(handler, id, field);
48
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
47
49
  }
48
50
 
49
51
  return value;
@@ -51,8 +53,9 @@ export function addHelperMethods(resource, plugin, config) {
51
53
 
52
54
  // Add method to increment value
53
55
  // Signature: add(id, field, amount)
56
+ // Supports dot notation: add(id, 'utmResults.medium', 5)
54
57
  resource.add = async (id, field, amount) => {
55
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
58
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
56
59
 
57
60
  // Create transaction inline
58
61
  const now = new Date();
@@ -62,6 +65,7 @@ export function addHelperMethods(resource, plugin, config) {
62
65
  id: idGenerator(),
63
66
  originalId: id,
64
67
  field: handler.field,
68
+ fieldPath: fieldPath, // Store full path for nested access
65
69
  value: amount,
66
70
  operation: 'add',
67
71
  timestamp: now.toISOString(),
@@ -76,19 +80,25 @@ export function addHelperMethods(resource, plugin, config) {
76
80
 
77
81
  // In sync mode, immediately consolidate
78
82
  if (config.mode === 'sync') {
79
- return await plugin._syncModeConsolidate(handler, id, field);
83
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
80
84
  }
81
85
 
82
86
  // Async mode - return current value (optimistic)
87
+ // Note: For nested paths, we need to use lodash get
83
88
  const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
84
- const currentValue = (ok && record) ? (record[field] || 0) : 0;
89
+ if (!ok || !record) return amount;
90
+
91
+ // Get current value from nested path
92
+ const lodash = await import('lodash-es');
93
+ const currentValue = lodash.get(record, fieldPath, 0);
85
94
  return currentValue + amount;
86
95
  };
87
96
 
88
97
  // Add method to decrement value
89
98
  // Signature: sub(id, field, amount)
99
+ // Supports dot notation: sub(id, 'utmResults.medium', 3)
90
100
  resource.sub = async (id, field, amount) => {
91
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
101
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
92
102
 
93
103
  // Create transaction inline
94
104
  const now = new Date();
@@ -98,6 +108,7 @@ export function addHelperMethods(resource, plugin, config) {
98
108
  id: idGenerator(),
99
109
  originalId: id,
100
110
  field: handler.field,
111
+ fieldPath: fieldPath, // Store full path for nested access
101
112
  value: amount,
102
113
  operation: 'sub',
103
114
  timestamp: now.toISOString(),
@@ -112,12 +123,17 @@ export function addHelperMethods(resource, plugin, config) {
112
123
 
113
124
  // In sync mode, immediately consolidate
114
125
  if (config.mode === 'sync') {
115
- return await plugin._syncModeConsolidate(handler, id, field);
126
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
116
127
  }
117
128
 
118
129
  // Async mode - return current value (optimistic)
130
+ // Note: For nested paths, we need to use lodash get
119
131
  const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
120
- const currentValue = (ok && record) ? (record[field] || 0) : 0;
132
+ if (!ok || !record) return -amount;
133
+
134
+ // Get current value from nested path
135
+ const lodash = await import('lodash-es');
136
+ const currentValue = lodash.get(record, fieldPath, 0);
121
137
  return currentValue - amount;
122
138
  };
123
139
 
@@ -95,6 +95,7 @@ export async function completeFieldSetup(handler, database, config, plugin) {
95
95
  id: 'string|required',
96
96
  originalId: 'string|required',
97
97
  field: 'string|required',
98
+ fieldPath: 'string|optional', // Support for nested field paths (e.g., 'utmResults.medium')
98
99
  value: 'number|required',
99
100
  operation: 'string|required',
100
101
  timestamp: 'string|required',