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/README.md +59 -2
- package/SECURITY.md +76 -0
- package/dist/s3db.cjs.js +446 -86
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +446 -86
- package/dist/s3db.es.js.map +1 -1
- package/package.json +3 -1
- package/src/concerns/crypto.js +7 -14
- package/src/plugins/eventual-consistency/analytics.js +164 -2
- package/src/plugins/eventual-consistency/consolidation.js +228 -80
- package/src/plugins/eventual-consistency/helpers.js +24 -8
- package/src/plugins/eventual-consistency/install.js +2 -1
- package/src/plugins/eventual-consistency/utils.js +218 -4
- package/src/concerns/advanced-metadata-encoding.js +0 -440
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
|
-
|
|
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
|
-
|
|
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 [
|
|
5665
|
+
const [recordOk2, recordErr2, record2] = await tryFn(
|
|
5532
5666
|
() => targetResource.get(originalId)
|
|
5533
5667
|
);
|
|
5534
|
-
const currentValue2 =
|
|
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
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
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 [
|
|
5746
|
+
const [recordOk2, recordErr2, record2] = await tryFn(
|
|
5608
5747
|
() => targetResource.get(originalId)
|
|
5609
5748
|
);
|
|
5610
|
-
currentValue =
|
|
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
|
|
5650
|
-
|
|
5651
|
-
|
|
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
|
|
5654
|
-
if (
|
|
5655
|
-
|
|
5656
|
-
|
|
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
|
-
|
|
5664
|
-
consolidatedValue: ${consolidatedValue},
|
|
5665
|
-
currentValue: ${currentValue}
|
|
5843
|
+
consolidatedValues: ${JSON.stringify(consolidatedValues, null, 2)}
|
|
5666
5844
|
}`
|
|
5667
5845
|
);
|
|
5668
5846
|
}
|
|
5669
|
-
const [
|
|
5670
|
-
() => targetResource.
|
|
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
|
-
|
|
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
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
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
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5901
|
+
);
|
|
5902
|
+
if (!match) {
|
|
5903
|
+
console.error(
|
|
5904
|
+
`\u274C [CRITICAL BUG] Update reported success but value not persisted!
|
|
5699
5905
|
Resource: ${config.resource}
|
|
5700
|
-
|
|
5906
|
+
FieldPath: ${fieldPath}
|
|
5701
5907
|
Record ID: ${originalId}
|
|
5702
|
-
Expected: ${
|
|
5703
|
-
Actually got: ${
|
|
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,
|
|
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,
|
|
7045
|
+
return await plugin._syncModeConsolidate(handler, id, fieldPath);
|
|
6695
7046
|
}
|
|
6696
7047
|
const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
|
|
6697
|
-
|
|
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,
|
|
7074
|
+
return await plugin._syncModeConsolidate(handler, id, fieldPath);
|
|
6720
7075
|
}
|
|
6721
7076
|
const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
|
|
6722
|
-
|
|
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|
|
|
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
|
|
13750
|
+
const [ok, err, version] = tryFn(() => true ? "11.2.0" : "latest");
|
|
13391
13751
|
return ok ? version : "latest";
|
|
13392
13752
|
})();
|
|
13393
13753
|
this.resources = {};
|