s3db.js 10.0.3 → 10.0.5

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-cli.js CHANGED
File without changes
package/dist/s3db.cjs.js CHANGED
@@ -4343,10 +4343,20 @@ class EventualConsistencyPlugin extends Plugin {
4343
4343
  // Days to keep applied transactions
4344
4344
  gcInterval: options.gcInterval || 86400,
4345
4345
  // 24 hours (in seconds)
4346
- verbose: options.verbose || false
4346
+ verbose: options.verbose || false,
4347
+ // Analytics configuration
4348
+ enableAnalytics: options.enableAnalytics || false,
4349
+ analyticsConfig: {
4350
+ periods: options.analyticsConfig?.periods || ["hour", "day", "month"],
4351
+ metrics: options.analyticsConfig?.metrics || ["count", "sum", "avg", "min", "max"],
4352
+ rollupStrategy: options.analyticsConfig?.rollupStrategy || "incremental",
4353
+ // 'incremental' or 'batch'
4354
+ retentionDays: options.analyticsConfig?.retentionDays || 365
4355
+ }
4347
4356
  };
4348
4357
  this.transactionResource = null;
4349
4358
  this.targetResource = null;
4359
+ this.analyticsResource = null;
4350
4360
  this.consolidationTimer = null;
4351
4361
  this.gcTimer = null;
4352
4362
  this.pendingTransactions = /* @__PURE__ */ new Map();
@@ -4433,6 +4443,9 @@ class EventualConsistencyPlugin extends Plugin {
4433
4443
  throw new Error(`Failed to create lock resource: ${lockErr?.message}`);
4434
4444
  }
4435
4445
  this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
4446
+ if (this.config.enableAnalytics) {
4447
+ await this.createAnalyticsResource();
4448
+ }
4436
4449
  this.addHelperMethods();
4437
4450
  if (this.config.autoConsolidate) {
4438
4451
  this.startConsolidationTimer();
@@ -4484,6 +4497,53 @@ class EventualConsistencyPlugin extends Plugin {
4484
4497
  };
4485
4498
  return partitions;
4486
4499
  }
4500
+ async createAnalyticsResource() {
4501
+ const analyticsResourceName = `${this.config.resource}_analytics_${this.config.field}`;
4502
+ const [ok, err, analyticsResource] = await tryFn(
4503
+ () => this.database.createResource({
4504
+ name: analyticsResourceName,
4505
+ attributes: {
4506
+ id: "string|required",
4507
+ period: "string|required",
4508
+ // 'hour', 'day', 'month'
4509
+ cohort: "string|required",
4510
+ // ISO format: '2025-10-09T14', '2025-10-09', '2025-10'
4511
+ // Aggregated metrics
4512
+ transactionCount: "number|required",
4513
+ totalValue: "number|required",
4514
+ avgValue: "number|required",
4515
+ minValue: "number|required",
4516
+ maxValue: "number|required",
4517
+ // Operation breakdown
4518
+ operations: "object|optional",
4519
+ // { add: { count, sum }, sub: { count, sum }, set: { count, sum } }
4520
+ // Metadata
4521
+ recordCount: "number|required",
4522
+ // Distinct originalIds
4523
+ consolidatedAt: "string|required",
4524
+ updatedAt: "string|required"
4525
+ },
4526
+ behavior: "body-overflow",
4527
+ timestamps: false,
4528
+ partitions: {
4529
+ byPeriod: {
4530
+ fields: { period: "string" }
4531
+ },
4532
+ byCohort: {
4533
+ fields: { cohort: "string" }
4534
+ }
4535
+ }
4536
+ })
4537
+ );
4538
+ if (!ok && !this.database.resources[analyticsResourceName]) {
4539
+ console.warn(`[EventualConsistency] Failed to create analytics resource: ${err?.message}`);
4540
+ return;
4541
+ }
4542
+ this.analyticsResource = ok ? analyticsResource : this.database.resources[analyticsResourceName];
4543
+ if (this.config.verbose) {
4544
+ console.log(`[EventualConsistency] Analytics resource created: ${analyticsResourceName}`);
4545
+ }
4546
+ }
4487
4547
  /**
4488
4548
  * Auto-detect timezone from environment or system
4489
4549
  * @private
@@ -4831,6 +4891,9 @@ class EventualConsistencyPlugin extends Plugin {
4831
4891
  if (errors && errors.length > 0 && this.config.verbose) {
4832
4892
  console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
4833
4893
  }
4894
+ if (this.config.enableAnalytics && transactionsToUpdate.length > 0) {
4895
+ await this.updateAnalytics(transactionsToUpdate);
4896
+ }
4834
4897
  }
4835
4898
  return consolidatedValue;
4836
4899
  } finally {
@@ -5038,6 +5101,490 @@ class EventualConsistencyPlugin extends Plugin {
5038
5101
  await tryFn(() => this.lockResource.delete(gcLockId));
5039
5102
  }
5040
5103
  }
5104
+ /**
5105
+ * Update analytics with consolidated transactions
5106
+ * @param {Array} transactions - Array of transactions that were just consolidated
5107
+ * @private
5108
+ */
5109
+ async updateAnalytics(transactions) {
5110
+ if (!this.analyticsResource || transactions.length === 0) return;
5111
+ try {
5112
+ const byHour = this._groupByCohort(transactions, "cohortHour");
5113
+ for (const [cohort, txns] of Object.entries(byHour)) {
5114
+ await this._upsertAnalytics("hour", cohort, txns);
5115
+ }
5116
+ if (this.config.analyticsConfig.rollupStrategy === "incremental") {
5117
+ const uniqueHours = Object.keys(byHour);
5118
+ for (const cohortHour of uniqueHours) {
5119
+ await this._rollupAnalytics(cohortHour);
5120
+ }
5121
+ }
5122
+ } catch (error) {
5123
+ if (this.config.verbose) {
5124
+ console.warn(`[EventualConsistency] Analytics update error:`, error.message);
5125
+ }
5126
+ }
5127
+ }
5128
+ /**
5129
+ * Group transactions by cohort
5130
+ * @private
5131
+ */
5132
+ _groupByCohort(transactions, cohortField) {
5133
+ const groups = {};
5134
+ for (const txn of transactions) {
5135
+ const cohort = txn[cohortField];
5136
+ if (!cohort) continue;
5137
+ if (!groups[cohort]) {
5138
+ groups[cohort] = [];
5139
+ }
5140
+ groups[cohort].push(txn);
5141
+ }
5142
+ return groups;
5143
+ }
5144
+ /**
5145
+ * Upsert analytics for a specific period and cohort
5146
+ * @private
5147
+ */
5148
+ async _upsertAnalytics(period, cohort, transactions) {
5149
+ const id = `${period}-${cohort}`;
5150
+ const transactionCount = transactions.length;
5151
+ const signedValues = transactions.map((t) => {
5152
+ if (t.operation === "sub") return -t.value;
5153
+ return t.value;
5154
+ });
5155
+ const totalValue = signedValues.reduce((sum, v) => sum + v, 0);
5156
+ const avgValue = totalValue / transactionCount;
5157
+ const minValue = Math.min(...signedValues);
5158
+ const maxValue = Math.max(...signedValues);
5159
+ const operations = this._calculateOperationBreakdown(transactions);
5160
+ const recordCount = new Set(transactions.map((t) => t.originalId)).size;
5161
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5162
+ const [existingOk, existingErr, existing] = await tryFn(
5163
+ () => this.analyticsResource.get(id)
5164
+ );
5165
+ if (existingOk && existing) {
5166
+ const newTransactionCount = existing.transactionCount + transactionCount;
5167
+ const newTotalValue = existing.totalValue + totalValue;
5168
+ const newAvgValue = newTotalValue / newTransactionCount;
5169
+ const newMinValue = Math.min(existing.minValue, minValue);
5170
+ const newMaxValue = Math.max(existing.maxValue, maxValue);
5171
+ const newOperations = { ...existing.operations };
5172
+ for (const [op, stats] of Object.entries(operations)) {
5173
+ if (!newOperations[op]) {
5174
+ newOperations[op] = { count: 0, sum: 0 };
5175
+ }
5176
+ newOperations[op].count += stats.count;
5177
+ newOperations[op].sum += stats.sum;
5178
+ }
5179
+ const newRecordCount = Math.max(existing.recordCount, recordCount);
5180
+ await tryFn(
5181
+ () => this.analyticsResource.update(id, {
5182
+ transactionCount: newTransactionCount,
5183
+ totalValue: newTotalValue,
5184
+ avgValue: newAvgValue,
5185
+ minValue: newMinValue,
5186
+ maxValue: newMaxValue,
5187
+ operations: newOperations,
5188
+ recordCount: newRecordCount,
5189
+ updatedAt: now
5190
+ })
5191
+ );
5192
+ } else {
5193
+ await tryFn(
5194
+ () => this.analyticsResource.insert({
5195
+ id,
5196
+ period,
5197
+ cohort,
5198
+ transactionCount,
5199
+ totalValue,
5200
+ avgValue,
5201
+ minValue,
5202
+ maxValue,
5203
+ operations,
5204
+ recordCount,
5205
+ consolidatedAt: now,
5206
+ updatedAt: now
5207
+ })
5208
+ );
5209
+ }
5210
+ }
5211
+ /**
5212
+ * Calculate operation breakdown
5213
+ * @private
5214
+ */
5215
+ _calculateOperationBreakdown(transactions) {
5216
+ const breakdown = {};
5217
+ for (const txn of transactions) {
5218
+ const op = txn.operation;
5219
+ if (!breakdown[op]) {
5220
+ breakdown[op] = { count: 0, sum: 0 };
5221
+ }
5222
+ breakdown[op].count++;
5223
+ const signedValue = op === "sub" ? -txn.value : txn.value;
5224
+ breakdown[op].sum += signedValue;
5225
+ }
5226
+ return breakdown;
5227
+ }
5228
+ /**
5229
+ * Roll up hourly analytics to daily and monthly
5230
+ * @private
5231
+ */
5232
+ async _rollupAnalytics(cohortHour) {
5233
+ const cohortDate = cohortHour.substring(0, 10);
5234
+ const cohortMonth = cohortHour.substring(0, 7);
5235
+ await this._rollupPeriod("day", cohortDate, cohortDate);
5236
+ await this._rollupPeriod("month", cohortMonth, cohortMonth);
5237
+ }
5238
+ /**
5239
+ * Roll up analytics for a specific period
5240
+ * @private
5241
+ */
5242
+ async _rollupPeriod(period, cohort, sourcePrefix) {
5243
+ const sourcePeriod = period === "day" ? "hour" : "day";
5244
+ const [ok, err, allAnalytics] = await tryFn(
5245
+ () => this.analyticsResource.list()
5246
+ );
5247
+ if (!ok || !allAnalytics) return;
5248
+ const sourceAnalytics = allAnalytics.filter(
5249
+ (a) => a.period === sourcePeriod && a.cohort.startsWith(sourcePrefix)
5250
+ );
5251
+ if (sourceAnalytics.length === 0) return;
5252
+ const transactionCount = sourceAnalytics.reduce((sum, a) => sum + a.transactionCount, 0);
5253
+ const totalValue = sourceAnalytics.reduce((sum, a) => sum + a.totalValue, 0);
5254
+ const avgValue = totalValue / transactionCount;
5255
+ const minValue = Math.min(...sourceAnalytics.map((a) => a.minValue));
5256
+ const maxValue = Math.max(...sourceAnalytics.map((a) => a.maxValue));
5257
+ const operations = {};
5258
+ for (const analytics of sourceAnalytics) {
5259
+ for (const [op, stats] of Object.entries(analytics.operations || {})) {
5260
+ if (!operations[op]) {
5261
+ operations[op] = { count: 0, sum: 0 };
5262
+ }
5263
+ operations[op].count += stats.count;
5264
+ operations[op].sum += stats.sum;
5265
+ }
5266
+ }
5267
+ const recordCount = Math.max(...sourceAnalytics.map((a) => a.recordCount));
5268
+ const id = `${period}-${cohort}`;
5269
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5270
+ const [existingOk, existingErr, existing] = await tryFn(
5271
+ () => this.analyticsResource.get(id)
5272
+ );
5273
+ if (existingOk && existing) {
5274
+ await tryFn(
5275
+ () => this.analyticsResource.update(id, {
5276
+ transactionCount,
5277
+ totalValue,
5278
+ avgValue,
5279
+ minValue,
5280
+ maxValue,
5281
+ operations,
5282
+ recordCount,
5283
+ updatedAt: now
5284
+ })
5285
+ );
5286
+ } else {
5287
+ await tryFn(
5288
+ () => this.analyticsResource.insert({
5289
+ id,
5290
+ period,
5291
+ cohort,
5292
+ transactionCount,
5293
+ totalValue,
5294
+ avgValue,
5295
+ minValue,
5296
+ maxValue,
5297
+ operations,
5298
+ recordCount,
5299
+ consolidatedAt: now,
5300
+ updatedAt: now
5301
+ })
5302
+ );
5303
+ }
5304
+ }
5305
+ /**
5306
+ * Get analytics for a specific period
5307
+ * @param {string} resourceName - Resource name
5308
+ * @param {string} field - Field name
5309
+ * @param {Object} options - Query options
5310
+ * @returns {Promise<Array>} Analytics data
5311
+ */
5312
+ async getAnalytics(resourceName, field, options = {}) {
5313
+ if (!this.analyticsResource) {
5314
+ throw new Error("Analytics not enabled for this plugin");
5315
+ }
5316
+ const { period = "day", date, startDate, endDate, month, year, breakdown = false } = options;
5317
+ const [ok, err, allAnalytics] = await tryFn(
5318
+ () => this.analyticsResource.list()
5319
+ );
5320
+ if (!ok || !allAnalytics) {
5321
+ return [];
5322
+ }
5323
+ let filtered = allAnalytics.filter((a) => a.period === period);
5324
+ if (date) {
5325
+ if (period === "hour") {
5326
+ filtered = filtered.filter((a) => a.cohort.startsWith(date));
5327
+ } else {
5328
+ filtered = filtered.filter((a) => a.cohort === date);
5329
+ }
5330
+ } else if (startDate && endDate) {
5331
+ filtered = filtered.filter((a) => a.cohort >= startDate && a.cohort <= endDate);
5332
+ } else if (month) {
5333
+ filtered = filtered.filter((a) => a.cohort.startsWith(month));
5334
+ } else if (year) {
5335
+ filtered = filtered.filter((a) => a.cohort.startsWith(String(year)));
5336
+ }
5337
+ filtered.sort((a, b) => a.cohort.localeCompare(b.cohort));
5338
+ if (breakdown === "operations") {
5339
+ return filtered.map((a) => ({
5340
+ cohort: a.cohort,
5341
+ ...a.operations
5342
+ }));
5343
+ }
5344
+ return filtered.map((a) => ({
5345
+ cohort: a.cohort,
5346
+ count: a.transactionCount,
5347
+ sum: a.totalValue,
5348
+ avg: a.avgValue,
5349
+ min: a.minValue,
5350
+ max: a.maxValue,
5351
+ operations: a.operations,
5352
+ recordCount: a.recordCount
5353
+ }));
5354
+ }
5355
+ /**
5356
+ * Fill gaps in analytics data with zeros for continuous time series
5357
+ * @private
5358
+ * @param {Array} data - Sparse analytics data
5359
+ * @param {string} period - Period type ('hour', 'day', 'month')
5360
+ * @param {string} startDate - Start date (ISO format)
5361
+ * @param {string} endDate - End date (ISO format)
5362
+ * @returns {Array} Complete time series with gaps filled
5363
+ */
5364
+ _fillGaps(data, period, startDate, endDate) {
5365
+ if (!data || data.length === 0) {
5366
+ data = [];
5367
+ }
5368
+ const dataMap = /* @__PURE__ */ new Map();
5369
+ data.forEach((item) => {
5370
+ dataMap.set(item.cohort, item);
5371
+ });
5372
+ const result = [];
5373
+ const emptyRecord = {
5374
+ count: 0,
5375
+ sum: 0,
5376
+ avg: 0,
5377
+ min: 0,
5378
+ max: 0,
5379
+ recordCount: 0
5380
+ };
5381
+ if (period === "hour") {
5382
+ const start = /* @__PURE__ */ new Date(startDate + "T00:00:00Z");
5383
+ const end = /* @__PURE__ */ new Date(endDate + "T23:59:59Z");
5384
+ for (let dt = new Date(start); dt <= end; dt.setHours(dt.getHours() + 1)) {
5385
+ const cohort = dt.toISOString().substring(0, 13);
5386
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
5387
+ }
5388
+ } else if (period === "day") {
5389
+ const start = new Date(startDate);
5390
+ const end = new Date(endDate);
5391
+ for (let dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) {
5392
+ const cohort = dt.toISOString().substring(0, 10);
5393
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
5394
+ }
5395
+ } else if (period === "month") {
5396
+ const startYear = parseInt(startDate.substring(0, 4));
5397
+ const startMonth = parseInt(startDate.substring(5, 7));
5398
+ const endYear = parseInt(endDate.substring(0, 4));
5399
+ const endMonth = parseInt(endDate.substring(5, 7));
5400
+ for (let year = startYear; year <= endYear; year++) {
5401
+ const firstMonth = year === startYear ? startMonth : 1;
5402
+ const lastMonth = year === endYear ? endMonth : 12;
5403
+ for (let month = firstMonth; month <= lastMonth; month++) {
5404
+ const cohort = `${year}-${month.toString().padStart(2, "0")}`;
5405
+ result.push(dataMap.get(cohort) || { cohort, ...emptyRecord });
5406
+ }
5407
+ }
5408
+ }
5409
+ return result;
5410
+ }
5411
+ /**
5412
+ * Get analytics for entire month, broken down by days
5413
+ * @param {string} resourceName - Resource name
5414
+ * @param {string} field - Field name
5415
+ * @param {string} month - Month in YYYY-MM format
5416
+ * @param {Object} options - Options
5417
+ * @param {boolean} options.fillGaps - Fill missing days with zeros (default: false)
5418
+ * @returns {Promise<Array>} Daily analytics for the month
5419
+ */
5420
+ async getMonthByDay(resourceName, field, month, options = {}) {
5421
+ const year = parseInt(month.substring(0, 4));
5422
+ const monthNum = parseInt(month.substring(5, 7));
5423
+ const firstDay = new Date(year, monthNum - 1, 1);
5424
+ const lastDay = new Date(year, monthNum, 0);
5425
+ const startDate = firstDay.toISOString().substring(0, 10);
5426
+ const endDate = lastDay.toISOString().substring(0, 10);
5427
+ const data = await this.getAnalytics(resourceName, field, {
5428
+ period: "day",
5429
+ startDate,
5430
+ endDate
5431
+ });
5432
+ if (options.fillGaps) {
5433
+ return this._fillGaps(data, "day", startDate, endDate);
5434
+ }
5435
+ return data;
5436
+ }
5437
+ /**
5438
+ * Get analytics for entire day, broken down by hours
5439
+ * @param {string} resourceName - Resource name
5440
+ * @param {string} field - Field name
5441
+ * @param {string} date - Date in YYYY-MM-DD format
5442
+ * @param {Object} options - Options
5443
+ * @param {boolean} options.fillGaps - Fill missing hours with zeros (default: false)
5444
+ * @returns {Promise<Array>} Hourly analytics for the day
5445
+ */
5446
+ async getDayByHour(resourceName, field, date, options = {}) {
5447
+ const data = await this.getAnalytics(resourceName, field, {
5448
+ period: "hour",
5449
+ date
5450
+ });
5451
+ if (options.fillGaps) {
5452
+ return this._fillGaps(data, "hour", date, date);
5453
+ }
5454
+ return data;
5455
+ }
5456
+ /**
5457
+ * Get analytics for last N days, broken down by days
5458
+ * @param {string} resourceName - Resource name
5459
+ * @param {string} field - Field name
5460
+ * @param {number} days - Number of days to look back (default: 7)
5461
+ * @param {Object} options - Options
5462
+ * @param {boolean} options.fillGaps - Fill missing days with zeros (default: false)
5463
+ * @returns {Promise<Array>} Daily analytics
5464
+ */
5465
+ async getLastNDays(resourceName, field, days = 7, options = {}) {
5466
+ const dates = Array.from({ length: days }, (_, i) => {
5467
+ const date = /* @__PURE__ */ new Date();
5468
+ date.setDate(date.getDate() - i);
5469
+ return date.toISOString().substring(0, 10);
5470
+ }).reverse();
5471
+ const data = await this.getAnalytics(resourceName, field, {
5472
+ period: "day",
5473
+ startDate: dates[0],
5474
+ endDate: dates[dates.length - 1]
5475
+ });
5476
+ if (options.fillGaps) {
5477
+ return this._fillGaps(data, "day", dates[0], dates[dates.length - 1]);
5478
+ }
5479
+ return data;
5480
+ }
5481
+ /**
5482
+ * Get analytics for entire year, broken down by months
5483
+ * @param {string} resourceName - Resource name
5484
+ * @param {string} field - Field name
5485
+ * @param {number} year - Year (e.g., 2025)
5486
+ * @param {Object} options - Options
5487
+ * @param {boolean} options.fillGaps - Fill missing months with zeros (default: false)
5488
+ * @returns {Promise<Array>} Monthly analytics for the year
5489
+ */
5490
+ async getYearByMonth(resourceName, field, year, options = {}) {
5491
+ const data = await this.getAnalytics(resourceName, field, {
5492
+ period: "month",
5493
+ year
5494
+ });
5495
+ if (options.fillGaps) {
5496
+ const startDate = `${year}-01`;
5497
+ const endDate = `${year}-12`;
5498
+ return this._fillGaps(data, "month", startDate, endDate);
5499
+ }
5500
+ return data;
5501
+ }
5502
+ /**
5503
+ * Get analytics for entire month, broken down by hours
5504
+ * @param {string} resourceName - Resource name
5505
+ * @param {string} field - Field name
5506
+ * @param {string} month - Month in YYYY-MM format (or 'last' for previous month)
5507
+ * @param {Object} options - Options
5508
+ * @param {boolean} options.fillGaps - Fill missing hours with zeros (default: false)
5509
+ * @returns {Promise<Array>} Hourly analytics for the month (up to 24*31=744 records)
5510
+ */
5511
+ async getMonthByHour(resourceName, field, month, options = {}) {
5512
+ let year, monthNum;
5513
+ if (month === "last") {
5514
+ const now = /* @__PURE__ */ new Date();
5515
+ now.setMonth(now.getMonth() - 1);
5516
+ year = now.getFullYear();
5517
+ monthNum = now.getMonth() + 1;
5518
+ } else {
5519
+ year = parseInt(month.substring(0, 4));
5520
+ monthNum = parseInt(month.substring(5, 7));
5521
+ }
5522
+ const firstDay = new Date(year, monthNum - 1, 1);
5523
+ const lastDay = new Date(year, monthNum, 0);
5524
+ const startDate = firstDay.toISOString().substring(0, 10);
5525
+ const endDate = lastDay.toISOString().substring(0, 10);
5526
+ const data = await this.getAnalytics(resourceName, field, {
5527
+ period: "hour",
5528
+ startDate,
5529
+ endDate
5530
+ });
5531
+ if (options.fillGaps) {
5532
+ return this._fillGaps(data, "hour", startDate, endDate);
5533
+ }
5534
+ return data;
5535
+ }
5536
+ /**
5537
+ * Get top records by volume
5538
+ * @param {string} resourceName - Resource name
5539
+ * @param {string} field - Field name
5540
+ * @param {Object} options - Query options
5541
+ * @returns {Promise<Array>} Top records
5542
+ */
5543
+ async getTopRecords(resourceName, field, options = {}) {
5544
+ if (!this.transactionResource) {
5545
+ throw new Error("Transaction resource not initialized");
5546
+ }
5547
+ const { period = "day", date, metric = "transactionCount", limit = 10 } = options;
5548
+ const [ok, err, transactions] = await tryFn(
5549
+ () => this.transactionResource.list()
5550
+ );
5551
+ if (!ok || !transactions) {
5552
+ return [];
5553
+ }
5554
+ let filtered = transactions;
5555
+ if (date) {
5556
+ if (period === "hour") {
5557
+ filtered = transactions.filter((t) => t.cohortHour && t.cohortHour.startsWith(date));
5558
+ } else if (period === "day") {
5559
+ filtered = transactions.filter((t) => t.cohortDate === date);
5560
+ } else if (period === "month") {
5561
+ filtered = transactions.filter((t) => t.cohortMonth && t.cohortMonth.startsWith(date));
5562
+ }
5563
+ }
5564
+ const byRecord = {};
5565
+ for (const txn of filtered) {
5566
+ const recordId = txn.originalId;
5567
+ if (!byRecord[recordId]) {
5568
+ byRecord[recordId] = { count: 0, sum: 0 };
5569
+ }
5570
+ byRecord[recordId].count++;
5571
+ byRecord[recordId].sum += txn.value;
5572
+ }
5573
+ const records = Object.entries(byRecord).map(([recordId, stats]) => ({
5574
+ recordId,
5575
+ count: stats.count,
5576
+ sum: stats.sum
5577
+ }));
5578
+ records.sort((a, b) => {
5579
+ if (metric === "transactionCount") {
5580
+ return b.count - a.count;
5581
+ } else if (metric === "totalValue") {
5582
+ return b.sum - a.sum;
5583
+ }
5584
+ return 0;
5585
+ });
5586
+ return records.slice(0, limit);
5587
+ }
5041
5588
  }
5042
5589
 
5043
5590
  class FullTextPlugin extends Plugin {
@@ -11105,7 +11652,7 @@ class Database extends EventEmitter {
11105
11652
  this.id = idGenerator(7);
11106
11653
  this.version = "1";
11107
11654
  this.s3dbVersion = (() => {
11108
- const [ok, err, version] = tryFn(() => true ? "10.0.3" : "latest");
11655
+ const [ok, err, version] = tryFn(() => true ? "10.0.5" : "latest");
11109
11656
  return ok ? version : "latest";
11110
11657
  })();
11111
11658
  this.resources = {};