s3db.js 10.0.3 → 10.0.4

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
@@ -4339,10 +4339,20 @@ class EventualConsistencyPlugin extends Plugin {
4339
4339
  // Days to keep applied transactions
4340
4340
  gcInterval: options.gcInterval || 86400,
4341
4341
  // 24 hours (in seconds)
4342
- verbose: options.verbose || false
4342
+ verbose: options.verbose || false,
4343
+ // Analytics configuration
4344
+ enableAnalytics: options.enableAnalytics || false,
4345
+ analyticsConfig: {
4346
+ periods: options.analyticsConfig?.periods || ["hour", "day", "month"],
4347
+ metrics: options.analyticsConfig?.metrics || ["count", "sum", "avg", "min", "max"],
4348
+ rollupStrategy: options.analyticsConfig?.rollupStrategy || "incremental",
4349
+ // 'incremental' or 'batch'
4350
+ retentionDays: options.analyticsConfig?.retentionDays || 365
4351
+ }
4343
4352
  };
4344
4353
  this.transactionResource = null;
4345
4354
  this.targetResource = null;
4355
+ this.analyticsResource = null;
4346
4356
  this.consolidationTimer = null;
4347
4357
  this.gcTimer = null;
4348
4358
  this.pendingTransactions = /* @__PURE__ */ new Map();
@@ -4429,6 +4439,9 @@ class EventualConsistencyPlugin extends Plugin {
4429
4439
  throw new Error(`Failed to create lock resource: ${lockErr?.message}`);
4430
4440
  }
4431
4441
  this.lockResource = lockOk ? lockResource : this.database.resources[lockResourceName];
4442
+ if (this.config.enableAnalytics) {
4443
+ await this.createAnalyticsResource();
4444
+ }
4432
4445
  this.addHelperMethods();
4433
4446
  if (this.config.autoConsolidate) {
4434
4447
  this.startConsolidationTimer();
@@ -4480,6 +4493,53 @@ class EventualConsistencyPlugin extends Plugin {
4480
4493
  };
4481
4494
  return partitions;
4482
4495
  }
4496
+ async createAnalyticsResource() {
4497
+ const analyticsResourceName = `${this.config.resource}_analytics_${this.config.field}`;
4498
+ const [ok, err, analyticsResource] = await tryFn(
4499
+ () => this.database.createResource({
4500
+ name: analyticsResourceName,
4501
+ attributes: {
4502
+ id: "string|required",
4503
+ period: "string|required",
4504
+ // 'hour', 'day', 'month'
4505
+ cohort: "string|required",
4506
+ // ISO format: '2025-10-09T14', '2025-10-09', '2025-10'
4507
+ // Aggregated metrics
4508
+ transactionCount: "number|required",
4509
+ totalValue: "number|required",
4510
+ avgValue: "number|required",
4511
+ minValue: "number|required",
4512
+ maxValue: "number|required",
4513
+ // Operation breakdown
4514
+ operations: "object|optional",
4515
+ // { add: { count, sum }, sub: { count, sum }, set: { count, sum } }
4516
+ // Metadata
4517
+ recordCount: "number|required",
4518
+ // Distinct originalIds
4519
+ consolidatedAt: "string|required",
4520
+ updatedAt: "string|required"
4521
+ },
4522
+ behavior: "body-overflow",
4523
+ timestamps: false,
4524
+ partitions: {
4525
+ byPeriod: {
4526
+ fields: { period: "string" }
4527
+ },
4528
+ byCohort: {
4529
+ fields: { cohort: "string" }
4530
+ }
4531
+ }
4532
+ })
4533
+ );
4534
+ if (!ok && !this.database.resources[analyticsResourceName]) {
4535
+ console.warn(`[EventualConsistency] Failed to create analytics resource: ${err?.message}`);
4536
+ return;
4537
+ }
4538
+ this.analyticsResource = ok ? analyticsResource : this.database.resources[analyticsResourceName];
4539
+ if (this.config.verbose) {
4540
+ console.log(`[EventualConsistency] Analytics resource created: ${analyticsResourceName}`);
4541
+ }
4542
+ }
4483
4543
  /**
4484
4544
  * Auto-detect timezone from environment or system
4485
4545
  * @private
@@ -4827,6 +4887,9 @@ class EventualConsistencyPlugin extends Plugin {
4827
4887
  if (errors && errors.length > 0 && this.config.verbose) {
4828
4888
  console.warn(`[EventualConsistency] ${errors.length} transactions failed to mark as applied`);
4829
4889
  }
4890
+ if (this.config.enableAnalytics && transactionsToUpdate.length > 0) {
4891
+ await this.updateAnalytics(transactionsToUpdate);
4892
+ }
4830
4893
  }
4831
4894
  return consolidatedValue;
4832
4895
  } finally {
@@ -5034,6 +5097,402 @@ class EventualConsistencyPlugin extends Plugin {
5034
5097
  await tryFn(() => this.lockResource.delete(gcLockId));
5035
5098
  }
5036
5099
  }
5100
+ /**
5101
+ * Update analytics with consolidated transactions
5102
+ * @param {Array} transactions - Array of transactions that were just consolidated
5103
+ * @private
5104
+ */
5105
+ async updateAnalytics(transactions) {
5106
+ if (!this.analyticsResource || transactions.length === 0) return;
5107
+ try {
5108
+ const byHour = this._groupByCohort(transactions, "cohortHour");
5109
+ for (const [cohort, txns] of Object.entries(byHour)) {
5110
+ await this._upsertAnalytics("hour", cohort, txns);
5111
+ }
5112
+ if (this.config.analyticsConfig.rollupStrategy === "incremental") {
5113
+ const uniqueHours = Object.keys(byHour);
5114
+ for (const cohortHour of uniqueHours) {
5115
+ await this._rollupAnalytics(cohortHour);
5116
+ }
5117
+ }
5118
+ } catch (error) {
5119
+ if (this.config.verbose) {
5120
+ console.warn(`[EventualConsistency] Analytics update error:`, error.message);
5121
+ }
5122
+ }
5123
+ }
5124
+ /**
5125
+ * Group transactions by cohort
5126
+ * @private
5127
+ */
5128
+ _groupByCohort(transactions, cohortField) {
5129
+ const groups = {};
5130
+ for (const txn of transactions) {
5131
+ const cohort = txn[cohortField];
5132
+ if (!cohort) continue;
5133
+ if (!groups[cohort]) {
5134
+ groups[cohort] = [];
5135
+ }
5136
+ groups[cohort].push(txn);
5137
+ }
5138
+ return groups;
5139
+ }
5140
+ /**
5141
+ * Upsert analytics for a specific period and cohort
5142
+ * @private
5143
+ */
5144
+ async _upsertAnalytics(period, cohort, transactions) {
5145
+ const id = `${period}-${cohort}`;
5146
+ const transactionCount = transactions.length;
5147
+ const signedValues = transactions.map((t) => {
5148
+ if (t.operation === "sub") return -t.value;
5149
+ return t.value;
5150
+ });
5151
+ const totalValue = signedValues.reduce((sum, v) => sum + v, 0);
5152
+ const avgValue = totalValue / transactionCount;
5153
+ const minValue = Math.min(...signedValues);
5154
+ const maxValue = Math.max(...signedValues);
5155
+ const operations = this._calculateOperationBreakdown(transactions);
5156
+ const recordCount = new Set(transactions.map((t) => t.originalId)).size;
5157
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5158
+ const [existingOk, existingErr, existing] = await tryFn(
5159
+ () => this.analyticsResource.get(id)
5160
+ );
5161
+ if (existingOk && existing) {
5162
+ const newTransactionCount = existing.transactionCount + transactionCount;
5163
+ const newTotalValue = existing.totalValue + totalValue;
5164
+ const newAvgValue = newTotalValue / newTransactionCount;
5165
+ const newMinValue = Math.min(existing.minValue, minValue);
5166
+ const newMaxValue = Math.max(existing.maxValue, maxValue);
5167
+ const newOperations = { ...existing.operations };
5168
+ for (const [op, stats] of Object.entries(operations)) {
5169
+ if (!newOperations[op]) {
5170
+ newOperations[op] = { count: 0, sum: 0 };
5171
+ }
5172
+ newOperations[op].count += stats.count;
5173
+ newOperations[op].sum += stats.sum;
5174
+ }
5175
+ const newRecordCount = Math.max(existing.recordCount, recordCount);
5176
+ await tryFn(
5177
+ () => this.analyticsResource.update(id, {
5178
+ transactionCount: newTransactionCount,
5179
+ totalValue: newTotalValue,
5180
+ avgValue: newAvgValue,
5181
+ minValue: newMinValue,
5182
+ maxValue: newMaxValue,
5183
+ operations: newOperations,
5184
+ recordCount: newRecordCount,
5185
+ updatedAt: now
5186
+ })
5187
+ );
5188
+ } else {
5189
+ await tryFn(
5190
+ () => this.analyticsResource.insert({
5191
+ id,
5192
+ period,
5193
+ cohort,
5194
+ transactionCount,
5195
+ totalValue,
5196
+ avgValue,
5197
+ minValue,
5198
+ maxValue,
5199
+ operations,
5200
+ recordCount,
5201
+ consolidatedAt: now,
5202
+ updatedAt: now
5203
+ })
5204
+ );
5205
+ }
5206
+ }
5207
+ /**
5208
+ * Calculate operation breakdown
5209
+ * @private
5210
+ */
5211
+ _calculateOperationBreakdown(transactions) {
5212
+ const breakdown = {};
5213
+ for (const txn of transactions) {
5214
+ const op = txn.operation;
5215
+ if (!breakdown[op]) {
5216
+ breakdown[op] = { count: 0, sum: 0 };
5217
+ }
5218
+ breakdown[op].count++;
5219
+ const signedValue = op === "sub" ? -txn.value : txn.value;
5220
+ breakdown[op].sum += signedValue;
5221
+ }
5222
+ return breakdown;
5223
+ }
5224
+ /**
5225
+ * Roll up hourly analytics to daily and monthly
5226
+ * @private
5227
+ */
5228
+ async _rollupAnalytics(cohortHour) {
5229
+ const cohortDate = cohortHour.substring(0, 10);
5230
+ const cohortMonth = cohortHour.substring(0, 7);
5231
+ await this._rollupPeriod("day", cohortDate, cohortDate);
5232
+ await this._rollupPeriod("month", cohortMonth, cohortMonth);
5233
+ }
5234
+ /**
5235
+ * Roll up analytics for a specific period
5236
+ * @private
5237
+ */
5238
+ async _rollupPeriod(period, cohort, sourcePrefix) {
5239
+ const sourcePeriod = period === "day" ? "hour" : "day";
5240
+ const [ok, err, allAnalytics] = await tryFn(
5241
+ () => this.analyticsResource.list()
5242
+ );
5243
+ if (!ok || !allAnalytics) return;
5244
+ const sourceAnalytics = allAnalytics.filter(
5245
+ (a) => a.period === sourcePeriod && a.cohort.startsWith(sourcePrefix)
5246
+ );
5247
+ if (sourceAnalytics.length === 0) return;
5248
+ const transactionCount = sourceAnalytics.reduce((sum, a) => sum + a.transactionCount, 0);
5249
+ const totalValue = sourceAnalytics.reduce((sum, a) => sum + a.totalValue, 0);
5250
+ const avgValue = totalValue / transactionCount;
5251
+ const minValue = Math.min(...sourceAnalytics.map((a) => a.minValue));
5252
+ const maxValue = Math.max(...sourceAnalytics.map((a) => a.maxValue));
5253
+ const operations = {};
5254
+ for (const analytics of sourceAnalytics) {
5255
+ for (const [op, stats] of Object.entries(analytics.operations || {})) {
5256
+ if (!operations[op]) {
5257
+ operations[op] = { count: 0, sum: 0 };
5258
+ }
5259
+ operations[op].count += stats.count;
5260
+ operations[op].sum += stats.sum;
5261
+ }
5262
+ }
5263
+ const recordCount = Math.max(...sourceAnalytics.map((a) => a.recordCount));
5264
+ const id = `${period}-${cohort}`;
5265
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5266
+ const [existingOk, existingErr, existing] = await tryFn(
5267
+ () => this.analyticsResource.get(id)
5268
+ );
5269
+ if (existingOk && existing) {
5270
+ await tryFn(
5271
+ () => this.analyticsResource.update(id, {
5272
+ transactionCount,
5273
+ totalValue,
5274
+ avgValue,
5275
+ minValue,
5276
+ maxValue,
5277
+ operations,
5278
+ recordCount,
5279
+ updatedAt: now
5280
+ })
5281
+ );
5282
+ } else {
5283
+ await tryFn(
5284
+ () => this.analyticsResource.insert({
5285
+ id,
5286
+ period,
5287
+ cohort,
5288
+ transactionCount,
5289
+ totalValue,
5290
+ avgValue,
5291
+ minValue,
5292
+ maxValue,
5293
+ operations,
5294
+ recordCount,
5295
+ consolidatedAt: now,
5296
+ updatedAt: now
5297
+ })
5298
+ );
5299
+ }
5300
+ }
5301
+ /**
5302
+ * Get analytics for a specific period
5303
+ * @param {string} resourceName - Resource name
5304
+ * @param {string} field - Field name
5305
+ * @param {Object} options - Query options
5306
+ * @returns {Promise<Array>} Analytics data
5307
+ */
5308
+ async getAnalytics(resourceName, field, options = {}) {
5309
+ if (!this.analyticsResource) {
5310
+ throw new Error("Analytics not enabled for this plugin");
5311
+ }
5312
+ const { period = "day", date, startDate, endDate, month, year, breakdown = false } = options;
5313
+ const [ok, err, allAnalytics] = await tryFn(
5314
+ () => this.analyticsResource.list()
5315
+ );
5316
+ if (!ok || !allAnalytics) {
5317
+ return [];
5318
+ }
5319
+ let filtered = allAnalytics.filter((a) => a.period === period);
5320
+ if (date) {
5321
+ if (period === "hour") {
5322
+ filtered = filtered.filter((a) => a.cohort.startsWith(date));
5323
+ } else {
5324
+ filtered = filtered.filter((a) => a.cohort === date);
5325
+ }
5326
+ } else if (startDate && endDate) {
5327
+ filtered = filtered.filter((a) => a.cohort >= startDate && a.cohort <= endDate);
5328
+ } else if (month) {
5329
+ filtered = filtered.filter((a) => a.cohort.startsWith(month));
5330
+ } else if (year) {
5331
+ filtered = filtered.filter((a) => a.cohort.startsWith(String(year)));
5332
+ }
5333
+ filtered.sort((a, b) => a.cohort.localeCompare(b.cohort));
5334
+ if (breakdown === "operations") {
5335
+ return filtered.map((a) => ({
5336
+ cohort: a.cohort,
5337
+ ...a.operations
5338
+ }));
5339
+ }
5340
+ return filtered.map((a) => ({
5341
+ cohort: a.cohort,
5342
+ count: a.transactionCount,
5343
+ sum: a.totalValue,
5344
+ avg: a.avgValue,
5345
+ min: a.minValue,
5346
+ max: a.maxValue,
5347
+ operations: a.operations,
5348
+ recordCount: a.recordCount
5349
+ }));
5350
+ }
5351
+ /**
5352
+ * Get analytics for entire month, broken down by days
5353
+ * @param {string} resourceName - Resource name
5354
+ * @param {string} field - Field name
5355
+ * @param {string} month - Month in YYYY-MM format
5356
+ * @returns {Promise<Array>} Daily analytics for the month
5357
+ */
5358
+ async getMonthByDay(resourceName, field, month) {
5359
+ const year = parseInt(month.substring(0, 4));
5360
+ const monthNum = parseInt(month.substring(5, 7));
5361
+ const firstDay = new Date(year, monthNum - 1, 1);
5362
+ const lastDay = new Date(year, monthNum, 0);
5363
+ const startDate = firstDay.toISOString().substring(0, 10);
5364
+ const endDate = lastDay.toISOString().substring(0, 10);
5365
+ return await this.getAnalytics(resourceName, field, {
5366
+ period: "day",
5367
+ startDate,
5368
+ endDate
5369
+ });
5370
+ }
5371
+ /**
5372
+ * Get analytics for entire day, broken down by hours
5373
+ * @param {string} resourceName - Resource name
5374
+ * @param {string} field - Field name
5375
+ * @param {string} date - Date in YYYY-MM-DD format
5376
+ * @returns {Promise<Array>} Hourly analytics for the day
5377
+ */
5378
+ async getDayByHour(resourceName, field, date) {
5379
+ return await this.getAnalytics(resourceName, field, {
5380
+ period: "hour",
5381
+ date
5382
+ });
5383
+ }
5384
+ /**
5385
+ * Get analytics for last N days, broken down by days
5386
+ * @param {string} resourceName - Resource name
5387
+ * @param {string} field - Field name
5388
+ * @param {number} days - Number of days to look back (default: 7)
5389
+ * @returns {Promise<Array>} Daily analytics
5390
+ */
5391
+ async getLastNDays(resourceName, field, days = 7) {
5392
+ const dates = Array.from({ length: days }, (_, i) => {
5393
+ const date = /* @__PURE__ */ new Date();
5394
+ date.setDate(date.getDate() - i);
5395
+ return date.toISOString().substring(0, 10);
5396
+ }).reverse();
5397
+ return await this.getAnalytics(resourceName, field, {
5398
+ period: "day",
5399
+ startDate: dates[0],
5400
+ endDate: dates[dates.length - 1]
5401
+ });
5402
+ }
5403
+ /**
5404
+ * Get analytics for entire year, broken down by months
5405
+ * @param {string} resourceName - Resource name
5406
+ * @param {string} field - Field name
5407
+ * @param {number} year - Year (e.g., 2025)
5408
+ * @returns {Promise<Array>} Monthly analytics for the year
5409
+ */
5410
+ async getYearByMonth(resourceName, field, year) {
5411
+ return await this.getAnalytics(resourceName, field, {
5412
+ period: "month",
5413
+ year
5414
+ });
5415
+ }
5416
+ /**
5417
+ * Get analytics for entire month, broken down by hours
5418
+ * @param {string} resourceName - Resource name
5419
+ * @param {string} field - Field name
5420
+ * @param {string} month - Month in YYYY-MM format (or 'last' for previous month)
5421
+ * @returns {Promise<Array>} Hourly analytics for the month (up to 24*31=744 records)
5422
+ */
5423
+ async getMonthByHour(resourceName, field, month) {
5424
+ let year, monthNum;
5425
+ if (month === "last") {
5426
+ const now = /* @__PURE__ */ new Date();
5427
+ now.setMonth(now.getMonth() - 1);
5428
+ year = now.getFullYear();
5429
+ monthNum = now.getMonth() + 1;
5430
+ } else {
5431
+ year = parseInt(month.substring(0, 4));
5432
+ monthNum = parseInt(month.substring(5, 7));
5433
+ }
5434
+ const firstDay = new Date(year, monthNum - 1, 1);
5435
+ const lastDay = new Date(year, monthNum, 0);
5436
+ const startDate = firstDay.toISOString().substring(0, 10);
5437
+ const endDate = lastDay.toISOString().substring(0, 10);
5438
+ return await this.getAnalytics(resourceName, field, {
5439
+ period: "hour",
5440
+ startDate,
5441
+ endDate
5442
+ });
5443
+ }
5444
+ /**
5445
+ * Get top records by volume
5446
+ * @param {string} resourceName - Resource name
5447
+ * @param {string} field - Field name
5448
+ * @param {Object} options - Query options
5449
+ * @returns {Promise<Array>} Top records
5450
+ */
5451
+ async getTopRecords(resourceName, field, options = {}) {
5452
+ if (!this.transactionResource) {
5453
+ throw new Error("Transaction resource not initialized");
5454
+ }
5455
+ const { period = "day", date, metric = "transactionCount", limit = 10 } = options;
5456
+ const [ok, err, transactions] = await tryFn(
5457
+ () => this.transactionResource.list()
5458
+ );
5459
+ if (!ok || !transactions) {
5460
+ return [];
5461
+ }
5462
+ let filtered = transactions;
5463
+ if (date) {
5464
+ if (period === "hour") {
5465
+ filtered = transactions.filter((t) => t.cohortHour && t.cohortHour.startsWith(date));
5466
+ } else if (period === "day") {
5467
+ filtered = transactions.filter((t) => t.cohortDate === date);
5468
+ } else if (period === "month") {
5469
+ filtered = transactions.filter((t) => t.cohortMonth && t.cohortMonth.startsWith(date));
5470
+ }
5471
+ }
5472
+ const byRecord = {};
5473
+ for (const txn of filtered) {
5474
+ const recordId = txn.originalId;
5475
+ if (!byRecord[recordId]) {
5476
+ byRecord[recordId] = { count: 0, sum: 0 };
5477
+ }
5478
+ byRecord[recordId].count++;
5479
+ byRecord[recordId].sum += txn.value;
5480
+ }
5481
+ const records = Object.entries(byRecord).map(([recordId, stats]) => ({
5482
+ recordId,
5483
+ count: stats.count,
5484
+ sum: stats.sum
5485
+ }));
5486
+ records.sort((a, b) => {
5487
+ if (metric === "transactionCount") {
5488
+ return b.count - a.count;
5489
+ } else if (metric === "totalValue") {
5490
+ return b.sum - a.sum;
5491
+ }
5492
+ return 0;
5493
+ });
5494
+ return records.slice(0, limit);
5495
+ }
5037
5496
  }
5038
5497
 
5039
5498
  class FullTextPlugin extends Plugin {
@@ -11101,7 +11560,7 @@ class Database extends EventEmitter {
11101
11560
  this.id = idGenerator(7);
11102
11561
  this.version = "1";
11103
11562
  this.s3dbVersion = (() => {
11104
- const [ok, err, version] = tryFn(() => true ? "10.0.3" : "latest");
11563
+ const [ok, err, version] = tryFn(() => true ? "10.0.4" : "latest");
11105
11564
  return ok ? version : "latest";
11106
11565
  })();
11107
11566
  this.resources = {};