s3db.js 11.1.0 → 11.2.2
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/SECURITY.md +76 -0
- package/dist/s3db-cli.js +55029 -0
- package/dist/s3db.cjs.js +195 -7
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +195 -7
- package/dist/s3db.es.js.map +1 -1
- package/package.json +2 -4
- package/src/plugins/eventual-consistency/analytics.js +164 -2
- package/src/plugins/eventual-consistency/config.js +4 -1
- package/src/plugins/eventual-consistency/consolidation.js +38 -4
- package/src/plugins/eventual-consistency/install.js +23 -1
- package/src/plugins/eventual-consistency/utils.js +64 -0
package/dist/s3db.es.js
CHANGED
|
@@ -5101,8 +5101,10 @@ function createConfig(options, detectedTimezone) {
|
|
|
5101
5101
|
consolidationWindow: consolidation.window ?? 24,
|
|
5102
5102
|
autoConsolidate: consolidation.auto !== false,
|
|
5103
5103
|
mode: consolidation.mode || "async",
|
|
5104
|
-
// ✅
|
|
5104
|
+
// ✅ Performance tuning - Mark applied concurrency (default 50, up from 10)
|
|
5105
5105
|
markAppliedConcurrency: consolidation.markAppliedConcurrency ?? 50,
|
|
5106
|
+
// ✅ Performance tuning - Recalculate concurrency (default 50, up from 10)
|
|
5107
|
+
recalculateConcurrency: consolidation.recalculateConcurrency ?? 50,
|
|
5106
5108
|
// Late arrivals
|
|
5107
5109
|
lateArrivalStrategy: lateArrivals.strategy || "warn",
|
|
5108
5110
|
// Batch transactions
|
|
@@ -5399,6 +5401,38 @@ function groupByCohort(transactions, cohortField) {
|
|
|
5399
5401
|
}
|
|
5400
5402
|
return groups;
|
|
5401
5403
|
}
|
|
5404
|
+
function ensureCohortHour(transaction, timezone = "UTC", verbose = false) {
|
|
5405
|
+
if (transaction.cohortHour) {
|
|
5406
|
+
return transaction;
|
|
5407
|
+
}
|
|
5408
|
+
if (transaction.timestamp) {
|
|
5409
|
+
const date = new Date(transaction.timestamp);
|
|
5410
|
+
const cohortInfo = getCohortInfo(date, timezone, verbose);
|
|
5411
|
+
if (verbose) {
|
|
5412
|
+
console.log(
|
|
5413
|
+
`[EventualConsistency] Transaction ${transaction.id} missing cohortHour, calculated from timestamp: ${cohortInfo.hour}`
|
|
5414
|
+
);
|
|
5415
|
+
}
|
|
5416
|
+
transaction.cohortHour = cohortInfo.hour;
|
|
5417
|
+
if (!transaction.cohortWeek) {
|
|
5418
|
+
transaction.cohortWeek = cohortInfo.week;
|
|
5419
|
+
}
|
|
5420
|
+
if (!transaction.cohortMonth) {
|
|
5421
|
+
transaction.cohortMonth = cohortInfo.month;
|
|
5422
|
+
}
|
|
5423
|
+
} else if (verbose) {
|
|
5424
|
+
console.warn(
|
|
5425
|
+
`[EventualConsistency] Transaction ${transaction.id} missing both cohortHour and timestamp, cannot calculate cohort`
|
|
5426
|
+
);
|
|
5427
|
+
}
|
|
5428
|
+
return transaction;
|
|
5429
|
+
}
|
|
5430
|
+
function ensureCohortHours(transactions, timezone = "UTC", verbose = false) {
|
|
5431
|
+
if (!transactions || !Array.isArray(transactions)) {
|
|
5432
|
+
return transactions;
|
|
5433
|
+
}
|
|
5434
|
+
return transactions.map((txn) => ensureCohortHour(txn, timezone, verbose));
|
|
5435
|
+
}
|
|
5402
5436
|
|
|
5403
5437
|
function createPartitionConfig() {
|
|
5404
5438
|
const partitions = {
|
|
@@ -5899,11 +5933,33 @@ async function consolidateRecord(originalId, transactionResource, targetResource
|
|
|
5899
5933
|
const transactionsToUpdate = transactions.filter((txn) => txn.id !== "__synthetic__");
|
|
5900
5934
|
const markAppliedConcurrency = config.markAppliedConcurrency || 50;
|
|
5901
5935
|
const { results, errors } = await PromisePool.for(transactionsToUpdate).withConcurrency(markAppliedConcurrency).process(async (txn) => {
|
|
5936
|
+
const txnWithCohorts = ensureCohortHour(txn, config.cohort.timezone, false);
|
|
5937
|
+
const updateData = { applied: true };
|
|
5938
|
+
if (txnWithCohorts.cohortHour && !txn.cohortHour) {
|
|
5939
|
+
updateData.cohortHour = txnWithCohorts.cohortHour;
|
|
5940
|
+
}
|
|
5941
|
+
if (txnWithCohorts.cohortDate && !txn.cohortDate) {
|
|
5942
|
+
updateData.cohortDate = txnWithCohorts.cohortDate;
|
|
5943
|
+
}
|
|
5944
|
+
if (txnWithCohorts.cohortWeek && !txn.cohortWeek) {
|
|
5945
|
+
updateData.cohortWeek = txnWithCohorts.cohortWeek;
|
|
5946
|
+
}
|
|
5947
|
+
if (txnWithCohorts.cohortMonth && !txn.cohortMonth) {
|
|
5948
|
+
updateData.cohortMonth = txnWithCohorts.cohortMonth;
|
|
5949
|
+
}
|
|
5950
|
+
if (txn.value === null || txn.value === void 0) {
|
|
5951
|
+
updateData.value = 1;
|
|
5952
|
+
}
|
|
5902
5953
|
const [ok2, err2] = await tryFn(
|
|
5903
|
-
() => transactionResource.update(txn.id,
|
|
5954
|
+
() => transactionResource.update(txn.id, updateData)
|
|
5904
5955
|
);
|
|
5905
5956
|
if (!ok2 && config.verbose) {
|
|
5906
|
-
console.warn(
|
|
5957
|
+
console.warn(
|
|
5958
|
+
`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`,
|
|
5959
|
+
err2?.message,
|
|
5960
|
+
"Update data:",
|
|
5961
|
+
updateData
|
|
5962
|
+
);
|
|
5907
5963
|
}
|
|
5908
5964
|
return ok2;
|
|
5909
5965
|
});
|
|
@@ -6095,7 +6151,8 @@ async function recalculateRecord(originalId, transactionResource, targetResource
|
|
|
6095
6151
|
}
|
|
6096
6152
|
}
|
|
6097
6153
|
const transactionsToReset = allTransactions.filter((txn) => txn.source !== "anchor");
|
|
6098
|
-
const
|
|
6154
|
+
const recalculateConcurrency = config.recalculateConcurrency || 50;
|
|
6155
|
+
const { results, errors } = await PromisePool.for(transactionsToReset).withConcurrency(recalculateConcurrency).process(async (txn) => {
|
|
6099
6156
|
const [ok, err] = await tryFn(
|
|
6100
6157
|
() => transactionResource.update(txn.id, { applied: false })
|
|
6101
6158
|
);
|
|
@@ -6523,7 +6580,10 @@ async function getAnalytics(resourceName, field, options, fieldHandlers) {
|
|
|
6523
6580
|
if (!handler.analyticsResource) {
|
|
6524
6581
|
throw new Error("Analytics not enabled for this plugin");
|
|
6525
6582
|
}
|
|
6526
|
-
const { period = "day", date, startDate, endDate, month, year, breakdown = false } = options;
|
|
6583
|
+
const { period = "day", date, startDate, endDate, month, year, breakdown = false, recordId } = options;
|
|
6584
|
+
if (recordId) {
|
|
6585
|
+
return await getAnalyticsForRecord(resourceName, field, recordId, options, handler);
|
|
6586
|
+
}
|
|
6527
6587
|
const [ok, err, allAnalytics] = await tryFn(
|
|
6528
6588
|
() => handler.analyticsResource.list()
|
|
6529
6589
|
);
|
|
@@ -6562,6 +6622,105 @@ async function getAnalytics(resourceName, field, options, fieldHandlers) {
|
|
|
6562
6622
|
recordCount: a.recordCount
|
|
6563
6623
|
}));
|
|
6564
6624
|
}
|
|
6625
|
+
async function getAnalyticsForRecord(resourceName, field, recordId, options, handler) {
|
|
6626
|
+
const { period = "day", date, startDate, endDate, month, year } = options;
|
|
6627
|
+
const [okTrue, errTrue, appliedTransactions] = await tryFn(
|
|
6628
|
+
() => handler.transactionResource.query({
|
|
6629
|
+
originalId: recordId,
|
|
6630
|
+
applied: true
|
|
6631
|
+
})
|
|
6632
|
+
);
|
|
6633
|
+
const [okFalse, errFalse, pendingTransactions] = await tryFn(
|
|
6634
|
+
() => handler.transactionResource.query({
|
|
6635
|
+
originalId: recordId,
|
|
6636
|
+
applied: false
|
|
6637
|
+
})
|
|
6638
|
+
);
|
|
6639
|
+
let allTransactions = [
|
|
6640
|
+
...okTrue && appliedTransactions ? appliedTransactions : [],
|
|
6641
|
+
...okFalse && pendingTransactions ? pendingTransactions : []
|
|
6642
|
+
];
|
|
6643
|
+
if (allTransactions.length === 0) {
|
|
6644
|
+
return [];
|
|
6645
|
+
}
|
|
6646
|
+
allTransactions = ensureCohortHours(allTransactions, handler.config?.cohort?.timezone || "UTC", false);
|
|
6647
|
+
let filtered = allTransactions;
|
|
6648
|
+
if (date) {
|
|
6649
|
+
if (period === "hour") {
|
|
6650
|
+
filtered = filtered.filter((t) => t.cohortHour && t.cohortHour.startsWith(date));
|
|
6651
|
+
} else if (period === "day") {
|
|
6652
|
+
filtered = filtered.filter((t) => t.cohortDate === date);
|
|
6653
|
+
} else if (period === "month") {
|
|
6654
|
+
filtered = filtered.filter((t) => t.cohortMonth && t.cohortMonth.startsWith(date));
|
|
6655
|
+
}
|
|
6656
|
+
} else if (startDate && endDate) {
|
|
6657
|
+
if (period === "hour") {
|
|
6658
|
+
filtered = filtered.filter((t) => t.cohortHour && t.cohortHour >= startDate && t.cohortHour <= endDate);
|
|
6659
|
+
} else if (period === "day") {
|
|
6660
|
+
filtered = filtered.filter((t) => t.cohortDate && t.cohortDate >= startDate && t.cohortDate <= endDate);
|
|
6661
|
+
} else if (period === "month") {
|
|
6662
|
+
filtered = filtered.filter((t) => t.cohortMonth && t.cohortMonth >= startDate && t.cohortMonth <= endDate);
|
|
6663
|
+
}
|
|
6664
|
+
} else if (month) {
|
|
6665
|
+
if (period === "hour") {
|
|
6666
|
+
filtered = filtered.filter((t) => t.cohortHour && t.cohortHour.startsWith(month));
|
|
6667
|
+
} else if (period === "day") {
|
|
6668
|
+
filtered = filtered.filter((t) => t.cohortDate && t.cohortDate.startsWith(month));
|
|
6669
|
+
}
|
|
6670
|
+
} else if (year) {
|
|
6671
|
+
if (period === "hour") {
|
|
6672
|
+
filtered = filtered.filter((t) => t.cohortHour && t.cohortHour.startsWith(String(year)));
|
|
6673
|
+
} else if (period === "day") {
|
|
6674
|
+
filtered = filtered.filter((t) => t.cohortDate && t.cohortDate.startsWith(String(year)));
|
|
6675
|
+
} else if (period === "month") {
|
|
6676
|
+
filtered = filtered.filter((t) => t.cohortMonth && t.cohortMonth.startsWith(String(year)));
|
|
6677
|
+
}
|
|
6678
|
+
}
|
|
6679
|
+
const cohortField = period === "hour" ? "cohortHour" : period === "day" ? "cohortDate" : "cohortMonth";
|
|
6680
|
+
const aggregated = aggregateTransactionsByCohort(filtered, cohortField);
|
|
6681
|
+
return aggregated;
|
|
6682
|
+
}
|
|
6683
|
+
function aggregateTransactionsByCohort(transactions, cohortField) {
|
|
6684
|
+
const groups = {};
|
|
6685
|
+
for (const txn of transactions) {
|
|
6686
|
+
const cohort = txn[cohortField];
|
|
6687
|
+
if (!cohort) continue;
|
|
6688
|
+
if (!groups[cohort]) {
|
|
6689
|
+
groups[cohort] = {
|
|
6690
|
+
cohort,
|
|
6691
|
+
count: 0,
|
|
6692
|
+
sum: 0,
|
|
6693
|
+
min: Infinity,
|
|
6694
|
+
max: -Infinity,
|
|
6695
|
+
recordCount: /* @__PURE__ */ new Set(),
|
|
6696
|
+
operations: {}
|
|
6697
|
+
};
|
|
6698
|
+
}
|
|
6699
|
+
const group = groups[cohort];
|
|
6700
|
+
const signedValue = txn.operation === "sub" ? -txn.value : txn.value;
|
|
6701
|
+
group.count++;
|
|
6702
|
+
group.sum += signedValue;
|
|
6703
|
+
group.min = Math.min(group.min, signedValue);
|
|
6704
|
+
group.max = Math.max(group.max, signedValue);
|
|
6705
|
+
group.recordCount.add(txn.originalId);
|
|
6706
|
+
const op = txn.operation;
|
|
6707
|
+
if (!group.operations[op]) {
|
|
6708
|
+
group.operations[op] = { count: 0, sum: 0 };
|
|
6709
|
+
}
|
|
6710
|
+
group.operations[op].count++;
|
|
6711
|
+
group.operations[op].sum += signedValue;
|
|
6712
|
+
}
|
|
6713
|
+
return Object.values(groups).map((g) => ({
|
|
6714
|
+
cohort: g.cohort,
|
|
6715
|
+
count: g.count,
|
|
6716
|
+
sum: g.sum,
|
|
6717
|
+
avg: g.sum / g.count,
|
|
6718
|
+
min: g.min === Infinity ? 0 : g.min,
|
|
6719
|
+
max: g.max === -Infinity ? 0 : g.max,
|
|
6720
|
+
recordCount: g.recordCount.size,
|
|
6721
|
+
operations: g.operations
|
|
6722
|
+
})).sort((a, b) => a.cohort.localeCompare(b.cohort));
|
|
6723
|
+
}
|
|
6565
6724
|
async function getMonthByDay(resourceName, field, month, options, fieldHandlers) {
|
|
6566
6725
|
const year = parseInt(month.substring(0, 4));
|
|
6567
6726
|
const monthNum = parseInt(month.substring(5, 7));
|
|
@@ -6596,6 +6755,8 @@ async function getLastNDays(resourceName, field, days, options, fieldHandlers) {
|
|
|
6596
6755
|
return date.toISOString().substring(0, 10);
|
|
6597
6756
|
}).reverse();
|
|
6598
6757
|
const data = await getAnalytics(resourceName, field, {
|
|
6758
|
+
...options,
|
|
6759
|
+
// ✅ Include all options (recordId, etc.)
|
|
6599
6760
|
period: "day",
|
|
6600
6761
|
startDate: dates[0],
|
|
6601
6762
|
endDate: dates[dates.length - 1]
|
|
@@ -6789,6 +6950,8 @@ async function getLastNHours(resourceName, field, hours = 24, options, fieldHand
|
|
|
6789
6950
|
const startHour = hoursAgo.toISOString().substring(0, 13);
|
|
6790
6951
|
const endHour = now.toISOString().substring(0, 13);
|
|
6791
6952
|
const data = await getAnalytics(resourceName, field, {
|
|
6953
|
+
...options,
|
|
6954
|
+
// ✅ Include all options (recordId, etc.)
|
|
6792
6955
|
period: "hour",
|
|
6793
6956
|
startDate: startHour,
|
|
6794
6957
|
endDate: endHour
|
|
@@ -6836,6 +6999,8 @@ async function getLastNMonths(resourceName, field, months = 12, options, fieldHa
|
|
|
6836
6999
|
const startDate = monthsAgo.toISOString().substring(0, 7);
|
|
6837
7000
|
const endDate = now.toISOString().substring(0, 7);
|
|
6838
7001
|
const data = await getAnalytics(resourceName, field, {
|
|
7002
|
+
...options,
|
|
7003
|
+
// ✅ Include all options (recordId, etc.)
|
|
6839
7004
|
period: "month",
|
|
6840
7005
|
startDate,
|
|
6841
7006
|
endDate
|
|
@@ -7028,7 +7193,8 @@ async function completeFieldSetup(handler, database, config, plugin) {
|
|
|
7028
7193
|
operation: "string|required",
|
|
7029
7194
|
timestamp: "string|required",
|
|
7030
7195
|
cohortDate: "string|required",
|
|
7031
|
-
cohortHour: "string|
|
|
7196
|
+
cohortHour: "string|optional",
|
|
7197
|
+
// ✅ FIX BUG #2: Changed from required to optional for migration compatibility
|
|
7032
7198
|
cohortWeek: "string|optional",
|
|
7033
7199
|
cohortMonth: "string|optional",
|
|
7034
7200
|
source: "string|optional",
|
|
@@ -7077,6 +7243,28 @@ async function createAnalyticsResource(handler, database, resourceName, fieldNam
|
|
|
7077
7243
|
},
|
|
7078
7244
|
behavior: "body-overflow",
|
|
7079
7245
|
timestamps: false,
|
|
7246
|
+
asyncPartitions: true,
|
|
7247
|
+
// ✅ Multi-attribute partitions for optimal analytics query performance
|
|
7248
|
+
partitions: {
|
|
7249
|
+
// Query by period (hour/day/week/month)
|
|
7250
|
+
byPeriod: {
|
|
7251
|
+
fields: { period: "string" }
|
|
7252
|
+
},
|
|
7253
|
+
// Query by period + cohort (e.g., all hour records for specific hours)
|
|
7254
|
+
byPeriodCohort: {
|
|
7255
|
+
fields: {
|
|
7256
|
+
period: "string",
|
|
7257
|
+
cohort: "string"
|
|
7258
|
+
}
|
|
7259
|
+
},
|
|
7260
|
+
// Query by field + period (e.g., all daily analytics for clicks field)
|
|
7261
|
+
byFieldPeriod: {
|
|
7262
|
+
fields: {
|
|
7263
|
+
field: "string",
|
|
7264
|
+
period: "string"
|
|
7265
|
+
}
|
|
7266
|
+
}
|
|
7267
|
+
},
|
|
7080
7268
|
createdBy: "EventualConsistencyPlugin"
|
|
7081
7269
|
})
|
|
7082
7270
|
);
|
|
@@ -13606,7 +13794,7 @@ class Database extends EventEmitter {
|
|
|
13606
13794
|
this.id = idGenerator(7);
|
|
13607
13795
|
this.version = "1";
|
|
13608
13796
|
this.s3dbVersion = (() => {
|
|
13609
|
-
const [ok, err, version] = tryFn(() => true ? "11.
|
|
13797
|
+
const [ok, err, version] = tryFn(() => true ? "11.2.2" : "latest");
|
|
13610
13798
|
return ok ? version : "latest";
|
|
13611
13799
|
})();
|
|
13612
13800
|
this.resources = {};
|