s3db.js 11.0.5 → 11.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/dist/s3db.es.js CHANGED
@@ -1,10 +1,10 @@
1
+ import crypto, { createHash } from 'crypto';
1
2
  import { customAlphabet, urlAlphabet } from 'nanoid';
2
3
  import EventEmitter from 'events';
3
4
  import { mkdir, copyFile, unlink, stat, access, readdir, writeFile, readFile, rm } from 'fs/promises';
4
5
  import fs, { createReadStream, createWriteStream } from 'fs';
5
6
  import { pipeline } from 'stream/promises';
6
7
  import path, { join } from 'path';
7
- import crypto, { createHash } from 'crypto';
8
8
  import zlib from 'node:zlib';
9
9
  import os from 'os';
10
10
  import jsonStableStringify from 'json-stable-stringify';
@@ -524,15 +524,7 @@ function tryFnSync(fn) {
524
524
  async function dynamicCrypto() {
525
525
  let lib;
526
526
  if (typeof process !== "undefined") {
527
- const [ok, err, result] = await tryFn(async () => {
528
- const { webcrypto } = await import('crypto');
529
- return webcrypto;
530
- });
531
- if (ok) {
532
- lib = result;
533
- } else {
534
- throw new CryptoError("Crypto API not available", { original: err, context: "dynamicCrypto" });
535
- }
527
+ lib = crypto.webcrypto;
536
528
  } else if (typeof window !== "undefined") {
537
529
  lib = window.crypto;
538
530
  }
@@ -586,8 +578,7 @@ async function md5(data) {
586
578
  throw new CryptoError("MD5 hashing is only available in Node.js environment", { context: "md5" });
587
579
  }
588
580
  const [ok, err, result] = await tryFn(async () => {
589
- const { createHash } = await import('crypto');
590
- return createHash("md5").update(data).digest("base64");
581
+ return crypto.createHash("md5").update(data).digest("base64");
591
582
  });
592
583
  if (!ok) {
593
584
  throw new CryptoError("MD5 hashing failed", { original: err, data });
@@ -5272,10 +5263,121 @@ function createFieldHandler(resourceName, fieldName) {
5272
5263
  deferredSetup: false
5273
5264
  };
5274
5265
  }
5266
+ function validateNestedPath(resource, fieldPath) {
5267
+ const parts = fieldPath.split(".");
5268
+ const rootField = parts[0];
5269
+ if (!resource.attributes || !resource.attributes[rootField]) {
5270
+ return {
5271
+ valid: false,
5272
+ rootField,
5273
+ fullPath: fieldPath,
5274
+ error: `Root field "${rootField}" not found in resource attributes`
5275
+ };
5276
+ }
5277
+ if (parts.length === 1) {
5278
+ return { valid: true, rootField, fullPath: fieldPath };
5279
+ }
5280
+ let current = resource.attributes[rootField];
5281
+ let foundJson = false;
5282
+ let levelsAfterJson = 0;
5283
+ for (let i = 1; i < parts.length; i++) {
5284
+ const part = parts[i];
5285
+ if (foundJson) {
5286
+ levelsAfterJson++;
5287
+ if (levelsAfterJson > 1) {
5288
+ return {
5289
+ valid: false,
5290
+ rootField,
5291
+ fullPath: fieldPath,
5292
+ error: `Path "${fieldPath}" exceeds 1 level after 'json' field. Maximum nesting after 'json' is 1 level.`
5293
+ };
5294
+ }
5295
+ continue;
5296
+ }
5297
+ if (typeof current === "string") {
5298
+ if (current === "json" || current.startsWith("json|")) {
5299
+ foundJson = true;
5300
+ levelsAfterJson++;
5301
+ if (levelsAfterJson > 1) {
5302
+ return {
5303
+ valid: false,
5304
+ rootField,
5305
+ fullPath: fieldPath,
5306
+ error: `Path "${fieldPath}" exceeds 1 level after 'json' field`
5307
+ };
5308
+ }
5309
+ continue;
5310
+ }
5311
+ return {
5312
+ valid: false,
5313
+ rootField,
5314
+ fullPath: fieldPath,
5315
+ error: `Field "${parts.slice(0, i).join(".")}" is type "${current}" and cannot be nested`
5316
+ };
5317
+ }
5318
+ if (typeof current === "object") {
5319
+ if (current.$$type) {
5320
+ const type = current.$$type;
5321
+ if (type === "json" || type.includes("json")) {
5322
+ foundJson = true;
5323
+ levelsAfterJson++;
5324
+ continue;
5325
+ }
5326
+ if (type !== "object" && !type.includes("object")) {
5327
+ return {
5328
+ valid: false,
5329
+ rootField,
5330
+ fullPath: fieldPath,
5331
+ error: `Field "${parts.slice(0, i).join(".")}" is type "${type}" and cannot be nested`
5332
+ };
5333
+ }
5334
+ }
5335
+ if (!current[part]) {
5336
+ return {
5337
+ valid: false,
5338
+ rootField,
5339
+ fullPath: fieldPath,
5340
+ error: `Field "${part}" not found in "${parts.slice(0, i).join(".")}"`
5341
+ };
5342
+ }
5343
+ current = current[part];
5344
+ } else {
5345
+ return {
5346
+ valid: false,
5347
+ rootField,
5348
+ fullPath: fieldPath,
5349
+ error: `Invalid structure at "${parts.slice(0, i).join(".")}"`
5350
+ };
5351
+ }
5352
+ }
5353
+ return { valid: true, rootField, fullPath: fieldPath };
5354
+ }
5275
5355
  function resolveFieldAndPlugin(resource, field, value) {
5276
5356
  if (!resource._eventualConsistencyPlugins) {
5277
5357
  throw new Error(`No eventual consistency plugins configured for this resource`);
5278
5358
  }
5359
+ if (field.includes(".")) {
5360
+ const validation = validateNestedPath(resource, field);
5361
+ if (!validation.valid) {
5362
+ throw new Error(validation.error);
5363
+ }
5364
+ const rootField = validation.rootField;
5365
+ const fieldPlugin2 = resource._eventualConsistencyPlugins[rootField];
5366
+ if (!fieldPlugin2) {
5367
+ const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(", ");
5368
+ throw new Error(
5369
+ `No eventual consistency plugin found for root field "${rootField}". Available fields: ${availableFields}`
5370
+ );
5371
+ }
5372
+ return {
5373
+ field: rootField,
5374
+ // Root field for plugin lookup
5375
+ fieldPath: field,
5376
+ // Full path for nested access
5377
+ value,
5378
+ plugin: fieldPlugin2
5379
+ };
5380
+ }
5279
5381
  const fieldPlugin = resource._eventualConsistencyPlugins[field];
5280
5382
  if (!fieldPlugin) {
5281
5383
  const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(", ");
@@ -5283,7 +5385,7 @@ function resolveFieldAndPlugin(resource, field, value) {
5283
5385
  `No eventual consistency plugin found for field "${field}". Available fields: ${availableFields}`
5284
5386
  );
5285
5387
  }
5286
- return { field, value, plugin: fieldPlugin };
5388
+ return { field, fieldPath: field, value, plugin: fieldPlugin };
5287
5389
  }
5288
5390
  function groupByCohort(transactions, cohortField) {
5289
5391
  const groups = {};
@@ -5297,6 +5399,38 @@ function groupByCohort(transactions, cohortField) {
5297
5399
  }
5298
5400
  return groups;
5299
5401
  }
5402
+ function ensureCohortHour(transaction, timezone = "UTC", verbose = false) {
5403
+ if (transaction.cohortHour) {
5404
+ return transaction;
5405
+ }
5406
+ if (transaction.timestamp) {
5407
+ const date = new Date(transaction.timestamp);
5408
+ const cohortInfo = getCohortInfo(date, timezone, verbose);
5409
+ if (verbose) {
5410
+ console.log(
5411
+ `[EventualConsistency] Transaction ${transaction.id} missing cohortHour, calculated from timestamp: ${cohortInfo.hour}`
5412
+ );
5413
+ }
5414
+ transaction.cohortHour = cohortInfo.hour;
5415
+ if (!transaction.cohortWeek) {
5416
+ transaction.cohortWeek = cohortInfo.week;
5417
+ }
5418
+ if (!transaction.cohortMonth) {
5419
+ transaction.cohortMonth = cohortInfo.month;
5420
+ }
5421
+ } else if (verbose) {
5422
+ console.warn(
5423
+ `[EventualConsistency] Transaction ${transaction.id} missing both cohortHour and timestamp, cannot calculate cohort`
5424
+ );
5425
+ }
5426
+ return transaction;
5427
+ }
5428
+ function ensureCohortHours(transactions, timezone = "UTC", verbose = false) {
5429
+ if (!transactions || !Array.isArray(transactions)) {
5430
+ return transactions;
5431
+ }
5432
+ return transactions.map((txn) => ensureCohortHour(txn, timezone, verbose));
5433
+ }
5300
5434
 
5301
5435
  function createPartitionConfig() {
5302
5436
  const partitions = {
@@ -5528,10 +5662,10 @@ async function consolidateRecord(originalId, transactionResource, targetResource
5528
5662
  })
5529
5663
  );
5530
5664
  if (!ok || !transactions || transactions.length === 0) {
5531
- const [recordOk, recordErr, record] = await tryFn(
5665
+ const [recordOk2, recordErr2, record2] = await tryFn(
5532
5666
  () => targetResource.get(originalId)
5533
5667
  );
5534
- const currentValue2 = recordOk && record ? record[config.field] || 0 : 0;
5668
+ const currentValue2 = recordOk2 && record2 ? record2[config.field] || 0 : 0;
5535
5669
  if (config.verbose) {
5536
5670
  console.log(
5537
5671
  `[EventualConsistency] ${config.resource}.${config.field} - No pending transactions for ${originalId}, skipping`
@@ -5566,6 +5700,7 @@ async function consolidateRecord(originalId, transactionResource, targetResource
5566
5700
  );
5567
5701
  }
5568
5702
  currentValue = 0;
5703
+ appliedTransactions.length = 0;
5569
5704
  } else {
5570
5705
  appliedTransactions.sort(
5571
5706
  (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
@@ -5573,42 +5708,46 @@ async function consolidateRecord(originalId, transactionResource, targetResource
5573
5708
  const hasSetInApplied = appliedTransactions.some((t) => t.operation === "set");
5574
5709
  if (!hasSetInApplied) {
5575
5710
  const recordValue = recordExists[config.field] || 0;
5576
- let appliedDelta = 0;
5577
- for (const t of appliedTransactions) {
5578
- if (t.operation === "add") appliedDelta += t.value;
5579
- else if (t.operation === "sub") appliedDelta -= t.value;
5580
- }
5581
- const baseValue = recordValue - appliedDelta;
5582
- const hasExistingAnchor = appliedTransactions.some((t) => t.source === "anchor");
5583
- if (baseValue !== 0 && !hasExistingAnchor) {
5584
- const firstTransactionDate = new Date(appliedTransactions[0].timestamp);
5585
- const cohortInfo = getCohortInfo(firstTransactionDate, config.cohort.timezone, config.verbose);
5586
- const anchorTransaction = {
5587
- id: idGenerator(),
5588
- originalId,
5589
- field: config.field,
5590
- value: baseValue,
5591
- operation: "set",
5592
- timestamp: new Date(firstTransactionDate.getTime() - 1).toISOString(),
5593
- // 1ms before first txn to ensure it's first
5594
- cohortDate: cohortInfo.date,
5595
- cohortHour: cohortInfo.hour,
5596
- cohortMonth: cohortInfo.month,
5597
- source: "anchor",
5598
- applied: true
5599
- };
5600
- await transactionResource.insert(anchorTransaction);
5601
- appliedTransactions.unshift(anchorTransaction);
5711
+ if (typeof recordValue === "number") {
5712
+ let appliedDelta = 0;
5713
+ for (const t of appliedTransactions) {
5714
+ if (t.operation === "add") appliedDelta += t.value;
5715
+ else if (t.operation === "sub") appliedDelta -= t.value;
5716
+ }
5717
+ const baseValue = recordValue - appliedDelta;
5718
+ const hasExistingAnchor = appliedTransactions.some((t) => t.source === "anchor");
5719
+ if (baseValue !== 0 && typeof baseValue === "number" && !hasExistingAnchor) {
5720
+ const firstTransactionDate = new Date(appliedTransactions[0].timestamp);
5721
+ const cohortInfo = getCohortInfo(firstTransactionDate, config.cohort.timezone, config.verbose);
5722
+ const anchorTransaction = {
5723
+ id: idGenerator(),
5724
+ originalId,
5725
+ field: config.field,
5726
+ fieldPath: config.field,
5727
+ // Add fieldPath for consistency
5728
+ value: baseValue,
5729
+ operation: "set",
5730
+ timestamp: new Date(firstTransactionDate.getTime() - 1).toISOString(),
5731
+ // 1ms before first txn to ensure it's first
5732
+ cohortDate: cohortInfo.date,
5733
+ cohortHour: cohortInfo.hour,
5734
+ cohortMonth: cohortInfo.month,
5735
+ source: "anchor",
5736
+ applied: true
5737
+ };
5738
+ await transactionResource.insert(anchorTransaction);
5739
+ appliedTransactions.unshift(anchorTransaction);
5740
+ }
5602
5741
  }
5603
5742
  }
5604
5743
  currentValue = config.reducer(appliedTransactions);
5605
5744
  }
5606
5745
  } else {
5607
- const [recordOk, recordErr, record] = await tryFn(
5746
+ const [recordOk2, recordErr2, record2] = await tryFn(
5608
5747
  () => targetResource.get(originalId)
5609
5748
  );
5610
- currentValue = recordOk && record ? record[config.field] || 0 : 0;
5611
- if (currentValue !== 0) {
5749
+ currentValue = recordOk2 && record2 ? record2[config.field] || 0 : 0;
5750
+ if (currentValue !== 0 && typeof currentValue === "number") {
5612
5751
  let anchorTimestamp;
5613
5752
  if (transactions && transactions.length > 0) {
5614
5753
  const firstPendingDate = new Date(transactions[0].timestamp);
@@ -5621,6 +5760,8 @@ async function consolidateRecord(originalId, transactionResource, targetResource
5621
5760
  id: idGenerator(),
5622
5761
  originalId,
5623
5762
  field: config.field,
5763
+ fieldPath: config.field,
5764
+ // Add fieldPath for consistency
5624
5765
  value: currentValue,
5625
5766
  operation: "set",
5626
5767
  timestamp: anchorTimestamp,
@@ -5646,38 +5787,101 @@ async function consolidateRecord(originalId, transactionResource, targetResource
5646
5787
  transactions.sort(
5647
5788
  (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
5648
5789
  );
5649
- const hasSetOperation = transactions.some((t) => t.operation === "set");
5650
- if (currentValue !== 0 && !hasSetOperation) {
5651
- transactions.unshift(createSyntheticSetTransaction(currentValue));
5790
+ const transactionsByPath = {};
5791
+ for (const txn of transactions) {
5792
+ const path = txn.fieldPath || txn.field || config.field;
5793
+ if (!transactionsByPath[path]) {
5794
+ transactionsByPath[path] = [];
5795
+ }
5796
+ transactionsByPath[path].push(txn);
5652
5797
  }
5653
- const consolidatedValue = config.reducer(transactions);
5654
- if (config.verbose) {
5655
- console.log(
5656
- `[EventualConsistency] ${config.resource}.${config.field} - ${originalId}: ${currentValue} \u2192 ${consolidatedValue} (${consolidatedValue > currentValue ? "+" : ""}${consolidatedValue - currentValue})`
5657
- );
5798
+ const appliedByPath = {};
5799
+ if (appliedOk && appliedTransactions && appliedTransactions.length > 0) {
5800
+ for (const txn of appliedTransactions) {
5801
+ const path = txn.fieldPath || txn.field || config.field;
5802
+ if (!appliedByPath[path]) {
5803
+ appliedByPath[path] = [];
5804
+ }
5805
+ appliedByPath[path].push(txn);
5806
+ }
5807
+ }
5808
+ const consolidatedValues = {};
5809
+ const lodash = await import('lodash-es');
5810
+ const [currentRecordOk, currentRecordErr, currentRecord] = await tryFn(
5811
+ () => targetResource.get(originalId)
5812
+ );
5813
+ for (const [fieldPath, pathTransactions] of Object.entries(transactionsByPath)) {
5814
+ let pathCurrentValue = 0;
5815
+ if (appliedByPath[fieldPath] && appliedByPath[fieldPath].length > 0) {
5816
+ appliedByPath[fieldPath].sort(
5817
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
5818
+ );
5819
+ pathCurrentValue = config.reducer(appliedByPath[fieldPath]);
5820
+ } else {
5821
+ if (currentRecordOk && currentRecord) {
5822
+ const recordValue = lodash.get(currentRecord, fieldPath, 0);
5823
+ if (typeof recordValue === "number") {
5824
+ pathCurrentValue = recordValue;
5825
+ }
5826
+ }
5827
+ }
5828
+ if (pathCurrentValue !== 0) {
5829
+ pathTransactions.unshift(createSyntheticSetTransaction(pathCurrentValue));
5830
+ }
5831
+ const pathConsolidatedValue = config.reducer(pathTransactions);
5832
+ consolidatedValues[fieldPath] = pathConsolidatedValue;
5833
+ if (config.verbose) {
5834
+ console.log(
5835
+ `[EventualConsistency] ${config.resource}.${fieldPath} - ${originalId}: ${pathCurrentValue} \u2192 ${pathConsolidatedValue} (${pathTransactions.length - (pathCurrentValue !== 0 ? 1 : 0)} pending txns)`
5836
+ );
5837
+ }
5658
5838
  }
5659
5839
  if (config.verbose) {
5660
5840
  console.log(
5661
5841
  `\u{1F525} [DEBUG] BEFORE targetResource.update() {
5662
5842
  originalId: '${originalId}',
5663
- field: '${config.field}',
5664
- consolidatedValue: ${consolidatedValue},
5665
- currentValue: ${currentValue}
5843
+ consolidatedValues: ${JSON.stringify(consolidatedValues, null, 2)}
5666
5844
  }`
5667
5845
  );
5668
5846
  }
5669
- const [updateOk, updateErr, updateResult] = await tryFn(
5670
- () => targetResource.update(originalId, {
5671
- [config.field]: consolidatedValue
5672
- })
5847
+ const [recordOk, recordErr, record] = await tryFn(
5848
+ () => targetResource.get(originalId)
5673
5849
  );
5850
+ let updateOk, updateErr, updateResult;
5851
+ if (!recordOk || !record) {
5852
+ if (config.verbose) {
5853
+ console.log(
5854
+ `[EventualConsistency] ${config.resource}.${config.field} - Record ${originalId} doesn't exist yet. Will attempt update anyway (expected to fail).`
5855
+ );
5856
+ }
5857
+ const minimalRecord = { id: originalId };
5858
+ for (const [fieldPath, value] of Object.entries(consolidatedValues)) {
5859
+ lodash.set(minimalRecord, fieldPath, value);
5860
+ }
5861
+ const result = await tryFn(
5862
+ () => targetResource.update(originalId, minimalRecord)
5863
+ );
5864
+ updateOk = result[0];
5865
+ updateErr = result[1];
5866
+ updateResult = result[2];
5867
+ } else {
5868
+ for (const [fieldPath, value] of Object.entries(consolidatedValues)) {
5869
+ lodash.set(record, fieldPath, value);
5870
+ }
5871
+ const result = await tryFn(
5872
+ () => targetResource.update(originalId, record)
5873
+ );
5874
+ updateOk = result[0];
5875
+ updateErr = result[1];
5876
+ updateResult = result[2];
5877
+ }
5878
+ const consolidatedValue = consolidatedValues[config.field] || (record ? lodash.get(record, config.field, 0) : 0);
5674
5879
  if (config.verbose) {
5675
5880
  console.log(
5676
5881
  `\u{1F525} [DEBUG] AFTER targetResource.update() {
5677
5882
  updateOk: ${updateOk},
5678
5883
  updateErr: ${updateErr?.message || "undefined"},
5679
- updateResult: ${JSON.stringify(updateResult, null, 2)},
5680
- hasField: ${updateResult?.[config.field]}
5884
+ consolidatedValue (main field): ${consolidatedValue}
5681
5885
  }`
5682
5886
  );
5683
5887
  }
@@ -5685,24 +5889,27 @@ async function consolidateRecord(originalId, transactionResource, targetResource
5685
5889
  const [verifyOk, verifyErr, verifiedRecord] = await tryFn(
5686
5890
  () => targetResource.get(originalId, { skipCache: true })
5687
5891
  );
5688
- console.log(
5689
- `\u{1F525} [DEBUG] VERIFICATION (fresh from S3, no cache) {
5690
- verifyOk: ${verifyOk},
5691
- verifiedRecord[${config.field}]: ${verifiedRecord?.[config.field]},
5692
- expectedValue: ${consolidatedValue},
5693
- \u2705 MATCH: ${verifiedRecord?.[config.field] === consolidatedValue}
5892
+ for (const [fieldPath, expectedValue] of Object.entries(consolidatedValues)) {
5893
+ const actualValue = lodash.get(verifiedRecord, fieldPath);
5894
+ const match = actualValue === expectedValue;
5895
+ console.log(
5896
+ `\u{1F525} [DEBUG] VERIFICATION ${fieldPath} {
5897
+ expectedValue: ${expectedValue},
5898
+ actualValue: ${actualValue},
5899
+ ${match ? "\u2705 MATCH" : "\u274C MISMATCH"}
5694
5900
  }`
5695
- );
5696
- if (verifyOk && verifiedRecord?.[config.field] !== consolidatedValue) {
5697
- console.error(
5698
- `\u274C [CRITICAL BUG] Update reported success but value not persisted!
5901
+ );
5902
+ if (!match) {
5903
+ console.error(
5904
+ `\u274C [CRITICAL BUG] Update reported success but value not persisted!
5699
5905
  Resource: ${config.resource}
5700
- Field: ${config.field}
5906
+ FieldPath: ${fieldPath}
5701
5907
  Record ID: ${originalId}
5702
- Expected: ${consolidatedValue}
5703
- Actually got: ${verifiedRecord?.[config.field]}
5908
+ Expected: ${expectedValue}
5909
+ Actually got: ${actualValue}
5704
5910
  This indicates a bug in s3db.js resource.update()`
5705
- );
5911
+ );
5912
+ }
5706
5913
  }
5707
5914
  }
5708
5915
  if (!updateOk) {
@@ -5887,6 +6094,38 @@ async function recalculateRecord(originalId, transactionResource, targetResource
5887
6094
  `[EventualConsistency] ${config.resource}.${config.field} - Found ${allTransactions.length} total transactions for ${originalId}, marking all as pending...`
5888
6095
  );
5889
6096
  }
6097
+ const hasAnchor = allTransactions.some((txn) => txn.source === "anchor");
6098
+ if (!hasAnchor) {
6099
+ const now = /* @__PURE__ */ new Date();
6100
+ const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
6101
+ const oldestTransaction = allTransactions.sort(
6102
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
6103
+ )[0];
6104
+ const anchorTimestamp = oldestTransaction ? new Date(new Date(oldestTransaction.timestamp).getTime() - 1).toISOString() : now.toISOString();
6105
+ const anchorCohortInfo = getCohortInfo(new Date(anchorTimestamp), config.cohort.timezone, config.verbose);
6106
+ const anchorTransaction = {
6107
+ id: idGenerator(),
6108
+ originalId,
6109
+ field: config.field,
6110
+ fieldPath: config.field,
6111
+ value: 0,
6112
+ // Always 0 for recalculate - we start from scratch
6113
+ operation: "set",
6114
+ timestamp: anchorTimestamp,
6115
+ cohortDate: anchorCohortInfo.date,
6116
+ cohortHour: anchorCohortInfo.hour,
6117
+ cohortMonth: anchorCohortInfo.month,
6118
+ source: "anchor",
6119
+ applied: true
6120
+ // Anchor is always applied
6121
+ };
6122
+ await transactionResource.insert(anchorTransaction);
6123
+ if (config.verbose) {
6124
+ console.log(
6125
+ `[EventualConsistency] ${config.resource}.${config.field} - Created anchor transaction for ${originalId} with value 0`
6126
+ );
6127
+ }
6128
+ }
5890
6129
  const transactionsToReset = allTransactions.filter((txn) => txn.source !== "anchor");
5891
6130
  const { results, errors } = await PromisePool.for(transactionsToReset).withConcurrency(10).process(async (txn) => {
5892
6131
  const [ok, err] = await tryFn(
@@ -6316,7 +6555,10 @@ async function getAnalytics(resourceName, field, options, fieldHandlers) {
6316
6555
  if (!handler.analyticsResource) {
6317
6556
  throw new Error("Analytics not enabled for this plugin");
6318
6557
  }
6319
- const { period = "day", date, startDate, endDate, month, year, breakdown = false } = options;
6558
+ const { period = "day", date, startDate, endDate, month, year, breakdown = false, recordId } = options;
6559
+ if (recordId) {
6560
+ return await getAnalyticsForRecord(resourceName, field, recordId, options, handler);
6561
+ }
6320
6562
  const [ok, err, allAnalytics] = await tryFn(
6321
6563
  () => handler.analyticsResource.list()
6322
6564
  );
@@ -6355,6 +6597,105 @@ async function getAnalytics(resourceName, field, options, fieldHandlers) {
6355
6597
  recordCount: a.recordCount
6356
6598
  }));
6357
6599
  }
6600
+ async function getAnalyticsForRecord(resourceName, field, recordId, options, handler) {
6601
+ const { period = "day", date, startDate, endDate, month, year } = options;
6602
+ const [okTrue, errTrue, appliedTransactions] = await tryFn(
6603
+ () => handler.transactionResource.query({
6604
+ originalId: recordId,
6605
+ applied: true
6606
+ })
6607
+ );
6608
+ const [okFalse, errFalse, pendingTransactions] = await tryFn(
6609
+ () => handler.transactionResource.query({
6610
+ originalId: recordId,
6611
+ applied: false
6612
+ })
6613
+ );
6614
+ let allTransactions = [
6615
+ ...okTrue && appliedTransactions ? appliedTransactions : [],
6616
+ ...okFalse && pendingTransactions ? pendingTransactions : []
6617
+ ];
6618
+ if (allTransactions.length === 0) {
6619
+ return [];
6620
+ }
6621
+ allTransactions = ensureCohortHours(allTransactions, handler.config?.cohort?.timezone || "UTC", false);
6622
+ let filtered = allTransactions;
6623
+ if (date) {
6624
+ if (period === "hour") {
6625
+ filtered = filtered.filter((t) => t.cohortHour && t.cohortHour.startsWith(date));
6626
+ } else if (period === "day") {
6627
+ filtered = filtered.filter((t) => t.cohortDate === date);
6628
+ } else if (period === "month") {
6629
+ filtered = filtered.filter((t) => t.cohortMonth && t.cohortMonth.startsWith(date));
6630
+ }
6631
+ } else if (startDate && endDate) {
6632
+ if (period === "hour") {
6633
+ filtered = filtered.filter((t) => t.cohortHour && t.cohortHour >= startDate && t.cohortHour <= endDate);
6634
+ } else if (period === "day") {
6635
+ filtered = filtered.filter((t) => t.cohortDate && t.cohortDate >= startDate && t.cohortDate <= endDate);
6636
+ } else if (period === "month") {
6637
+ filtered = filtered.filter((t) => t.cohortMonth && t.cohortMonth >= startDate && t.cohortMonth <= endDate);
6638
+ }
6639
+ } else if (month) {
6640
+ if (period === "hour") {
6641
+ filtered = filtered.filter((t) => t.cohortHour && t.cohortHour.startsWith(month));
6642
+ } else if (period === "day") {
6643
+ filtered = filtered.filter((t) => t.cohortDate && t.cohortDate.startsWith(month));
6644
+ }
6645
+ } else if (year) {
6646
+ if (period === "hour") {
6647
+ filtered = filtered.filter((t) => t.cohortHour && t.cohortHour.startsWith(String(year)));
6648
+ } else if (period === "day") {
6649
+ filtered = filtered.filter((t) => t.cohortDate && t.cohortDate.startsWith(String(year)));
6650
+ } else if (period === "month") {
6651
+ filtered = filtered.filter((t) => t.cohortMonth && t.cohortMonth.startsWith(String(year)));
6652
+ }
6653
+ }
6654
+ const cohortField = period === "hour" ? "cohortHour" : period === "day" ? "cohortDate" : "cohortMonth";
6655
+ const aggregated = aggregateTransactionsByCohort(filtered, cohortField);
6656
+ return aggregated;
6657
+ }
6658
+ function aggregateTransactionsByCohort(transactions, cohortField) {
6659
+ const groups = {};
6660
+ for (const txn of transactions) {
6661
+ const cohort = txn[cohortField];
6662
+ if (!cohort) continue;
6663
+ if (!groups[cohort]) {
6664
+ groups[cohort] = {
6665
+ cohort,
6666
+ count: 0,
6667
+ sum: 0,
6668
+ min: Infinity,
6669
+ max: -Infinity,
6670
+ recordCount: /* @__PURE__ */ new Set(),
6671
+ operations: {}
6672
+ };
6673
+ }
6674
+ const group = groups[cohort];
6675
+ const signedValue = txn.operation === "sub" ? -txn.value : txn.value;
6676
+ group.count++;
6677
+ group.sum += signedValue;
6678
+ group.min = Math.min(group.min, signedValue);
6679
+ group.max = Math.max(group.max, signedValue);
6680
+ group.recordCount.add(txn.originalId);
6681
+ const op = txn.operation;
6682
+ if (!group.operations[op]) {
6683
+ group.operations[op] = { count: 0, sum: 0 };
6684
+ }
6685
+ group.operations[op].count++;
6686
+ group.operations[op].sum += signedValue;
6687
+ }
6688
+ return Object.values(groups).map((g) => ({
6689
+ cohort: g.cohort,
6690
+ count: g.count,
6691
+ sum: g.sum,
6692
+ avg: g.sum / g.count,
6693
+ min: g.min === Infinity ? 0 : g.min,
6694
+ max: g.max === -Infinity ? 0 : g.max,
6695
+ recordCount: g.recordCount.size,
6696
+ operations: g.operations
6697
+ })).sort((a, b) => a.cohort.localeCompare(b.cohort));
6698
+ }
6358
6699
  async function getMonthByDay(resourceName, field, month, options, fieldHandlers) {
6359
6700
  const year = parseInt(month.substring(0, 4));
6360
6701
  const monthNum = parseInt(month.substring(5, 7));
@@ -6389,6 +6730,8 @@ async function getLastNDays(resourceName, field, days, options, fieldHandlers) {
6389
6730
  return date.toISOString().substring(0, 10);
6390
6731
  }).reverse();
6391
6732
  const data = await getAnalytics(resourceName, field, {
6733
+ ...options,
6734
+ // ✅ Include all options (recordId, etc.)
6392
6735
  period: "day",
6393
6736
  startDate: dates[0],
6394
6737
  endDate: dates[dates.length - 1]
@@ -6582,6 +6925,8 @@ async function getLastNHours(resourceName, field, hours = 24, options, fieldHand
6582
6925
  const startHour = hoursAgo.toISOString().substring(0, 13);
6583
6926
  const endHour = now.toISOString().substring(0, 13);
6584
6927
  const data = await getAnalytics(resourceName, field, {
6928
+ ...options,
6929
+ // ✅ Include all options (recordId, etc.)
6585
6930
  period: "hour",
6586
6931
  startDate: startHour,
6587
6932
  endDate: endHour
@@ -6629,6 +6974,8 @@ async function getLastNMonths(resourceName, field, months = 12, options, fieldHa
6629
6974
  const startDate = monthsAgo.toISOString().substring(0, 7);
6630
6975
  const endDate = now.toISOString().substring(0, 7);
6631
6976
  const data = await getAnalytics(resourceName, field, {
6977
+ ...options,
6978
+ // ✅ Include all options (recordId, etc.)
6632
6979
  period: "month",
6633
6980
  startDate,
6634
6981
  endDate
@@ -6650,13 +6997,15 @@ async function getLastNMonths(resourceName, field, months = 12, options, fieldHa
6650
6997
 
6651
6998
  function addHelperMethods(resource, plugin, config) {
6652
6999
  resource.set = async (id, field, value) => {
6653
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, value);
7000
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, value);
6654
7001
  const now = /* @__PURE__ */ new Date();
6655
7002
  const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
6656
7003
  const transaction = {
6657
7004
  id: idGenerator(),
6658
7005
  originalId: id,
6659
7006
  field: handler.field,
7007
+ fieldPath,
7008
+ // Store full path for nested access
6660
7009
  value,
6661
7010
  operation: "set",
6662
7011
  timestamp: now.toISOString(),
@@ -6668,18 +7017,20 @@ function addHelperMethods(resource, plugin, config) {
6668
7017
  };
6669
7018
  await handler.transactionResource.insert(transaction);
6670
7019
  if (config.mode === "sync") {
6671
- return await plugin._syncModeConsolidate(handler, id, field);
7020
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
6672
7021
  }
6673
7022
  return value;
6674
7023
  };
6675
7024
  resource.add = async (id, field, amount) => {
6676
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
7025
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
6677
7026
  const now = /* @__PURE__ */ new Date();
6678
7027
  const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
6679
7028
  const transaction = {
6680
7029
  id: idGenerator(),
6681
7030
  originalId: id,
6682
7031
  field: handler.field,
7032
+ fieldPath,
7033
+ // Store full path for nested access
6683
7034
  value: amount,
6684
7035
  operation: "add",
6685
7036
  timestamp: now.toISOString(),
@@ -6691,20 +7042,24 @@ function addHelperMethods(resource, plugin, config) {
6691
7042
  };
6692
7043
  await handler.transactionResource.insert(transaction);
6693
7044
  if (config.mode === "sync") {
6694
- return await plugin._syncModeConsolidate(handler, id, field);
7045
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
6695
7046
  }
6696
7047
  const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
6697
- const currentValue = ok && record ? record[field] || 0 : 0;
7048
+ if (!ok || !record) return amount;
7049
+ const lodash = await import('lodash-es');
7050
+ const currentValue = lodash.get(record, fieldPath, 0);
6698
7051
  return currentValue + amount;
6699
7052
  };
6700
7053
  resource.sub = async (id, field, amount) => {
6701
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
7054
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
6702
7055
  const now = /* @__PURE__ */ new Date();
6703
7056
  const cohortInfo = getCohortInfo(now, config.cohort.timezone, config.verbose);
6704
7057
  const transaction = {
6705
7058
  id: idGenerator(),
6706
7059
  originalId: id,
6707
7060
  field: handler.field,
7061
+ fieldPath,
7062
+ // Store full path for nested access
6708
7063
  value: amount,
6709
7064
  operation: "sub",
6710
7065
  timestamp: now.toISOString(),
@@ -6716,10 +7071,12 @@ function addHelperMethods(resource, plugin, config) {
6716
7071
  };
6717
7072
  await handler.transactionResource.insert(transaction);
6718
7073
  if (config.mode === "sync") {
6719
- return await plugin._syncModeConsolidate(handler, id, field);
7074
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
6720
7075
  }
6721
7076
  const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
6722
- const currentValue = ok && record ? record[field] || 0 : 0;
7077
+ if (!ok || !record) return -amount;
7078
+ const lodash = await import('lodash-es');
7079
+ const currentValue = lodash.get(record, fieldPath, 0);
6723
7080
  return currentValue - amount;
6724
7081
  };
6725
7082
  resource.consolidate = async (id, field) => {
@@ -6805,11 +7162,14 @@ async function completeFieldSetup(handler, database, config, plugin) {
6805
7162
  id: "string|required",
6806
7163
  originalId: "string|required",
6807
7164
  field: "string|required",
7165
+ fieldPath: "string|optional",
7166
+ // Support for nested field paths (e.g., 'utmResults.medium')
6808
7167
  value: "number|required",
6809
7168
  operation: "string|required",
6810
7169
  timestamp: "string|required",
6811
7170
  cohortDate: "string|required",
6812
- cohortHour: "string|required",
7171
+ cohortHour: "string|optional",
7172
+ // ✅ FIX BUG #2: Changed from required to optional for migration compatibility
6813
7173
  cohortWeek: "string|optional",
6814
7174
  cohortMonth: "string|optional",
6815
7175
  source: "string|optional",
@@ -13387,7 +13747,7 @@ class Database extends EventEmitter {
13387
13747
  this.id = idGenerator(7);
13388
13748
  this.version = "1";
13389
13749
  this.s3dbVersion = (() => {
13390
- const [ok, err, version] = tryFn(() => true ? "11.0.5" : "latest");
13750
+ const [ok, err, version] = tryFn(() => true ? "11.2.0" : "latest");
13391
13751
  return ok ? version : "latest";
13392
13752
  })();
13393
13753
  this.resources = {};