s3db.js 11.2.0 → 11.2.3
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 +55029 -0
- package/dist/s3db.cjs.js +532 -20
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +532 -21
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -4
- package/src/database.class.js +3 -0
- package/src/errors.js +115 -4
- package/src/plugins/cache/memory-cache.class.js +216 -33
- package/src/plugins/cache.plugin.js +85 -1
- package/src/plugins/eventual-consistency/analytics.js +145 -0
- package/src/plugins/eventual-consistency/config.js +4 -1
- package/src/plugins/eventual-consistency/consolidation.js +38 -4
- package/src/plugins/eventual-consistency/index.js +203 -1
- package/src/plugins/eventual-consistency/install.js +22 -0
- package/src/resource.class.js +8 -1
|
@@ -1209,3 +1209,148 @@ export async function getLastNMonths(resourceName, field, months = 12, options,
|
|
|
1209
1209
|
|
|
1210
1210
|
return data;
|
|
1211
1211
|
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Get raw transaction events for custom aggregation
|
|
1215
|
+
*
|
|
1216
|
+
* This method provides direct access to the underlying transaction events,
|
|
1217
|
+
* allowing developers to perform custom aggregations beyond the pre-built analytics.
|
|
1218
|
+
* Useful for complex queries, custom metrics, or when you need the raw event data.
|
|
1219
|
+
*
|
|
1220
|
+
* @param {string} resourceName - Resource name
|
|
1221
|
+
* @param {string} field - Field name
|
|
1222
|
+
* @param {Object} options - Query options
|
|
1223
|
+
* @param {string} options.recordId - Filter by specific record ID
|
|
1224
|
+
* @param {string} options.startDate - Start date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
|
|
1225
|
+
* @param {string} options.endDate - End date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
|
|
1226
|
+
* @param {string} options.cohortDate - Filter by cohort date (YYYY-MM-DD)
|
|
1227
|
+
* @param {string} options.cohortHour - Filter by cohort hour (YYYY-MM-DDTHH)
|
|
1228
|
+
* @param {string} options.cohortMonth - Filter by cohort month (YYYY-MM)
|
|
1229
|
+
* @param {boolean} options.applied - Filter by applied status (true/false/undefined for both)
|
|
1230
|
+
* @param {string} options.operation - Filter by operation type ('add', 'sub', 'set')
|
|
1231
|
+
* @param {number} options.limit - Maximum number of events to return
|
|
1232
|
+
* @param {Object} fieldHandlers - Field handlers map
|
|
1233
|
+
* @returns {Promise<Array>} Raw transaction events
|
|
1234
|
+
*
|
|
1235
|
+
* @example
|
|
1236
|
+
* // Get all events for a specific record
|
|
1237
|
+
* const events = await plugin.getRawEvents('wallets', 'balance', {
|
|
1238
|
+
* recordId: 'wallet1'
|
|
1239
|
+
* });
|
|
1240
|
+
*
|
|
1241
|
+
* @example
|
|
1242
|
+
* // Get events for a specific time range
|
|
1243
|
+
* const events = await plugin.getRawEvents('wallets', 'balance', {
|
|
1244
|
+
* startDate: '2025-10-01',
|
|
1245
|
+
* endDate: '2025-10-31'
|
|
1246
|
+
* });
|
|
1247
|
+
*
|
|
1248
|
+
* @example
|
|
1249
|
+
* // Get only pending (unapplied) transactions
|
|
1250
|
+
* const pending = await plugin.getRawEvents('wallets', 'balance', {
|
|
1251
|
+
* applied: false
|
|
1252
|
+
* });
|
|
1253
|
+
*/
|
|
1254
|
+
export async function getRawEvents(resourceName, field, options, fieldHandlers) {
|
|
1255
|
+
// Get handler for this resource/field combination
|
|
1256
|
+
const resourceHandlers = fieldHandlers.get(resourceName);
|
|
1257
|
+
if (!resourceHandlers) {
|
|
1258
|
+
throw new Error(`No eventual consistency configured for resource: ${resourceName}`);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const handler = resourceHandlers.get(field);
|
|
1262
|
+
if (!handler) {
|
|
1263
|
+
throw new Error(`No eventual consistency configured for field: ${resourceName}.${field}`);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (!handler.transactionResource) {
|
|
1267
|
+
throw new Error('Transaction resource not initialized');
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const {
|
|
1271
|
+
recordId,
|
|
1272
|
+
startDate,
|
|
1273
|
+
endDate,
|
|
1274
|
+
cohortDate,
|
|
1275
|
+
cohortHour,
|
|
1276
|
+
cohortMonth,
|
|
1277
|
+
applied,
|
|
1278
|
+
operation,
|
|
1279
|
+
limit
|
|
1280
|
+
} = options;
|
|
1281
|
+
|
|
1282
|
+
// Build query object for partition-based filtering
|
|
1283
|
+
const query = {};
|
|
1284
|
+
|
|
1285
|
+
// Filter by recordId (uses partition if available)
|
|
1286
|
+
if (recordId !== undefined) {
|
|
1287
|
+
query.originalId = recordId;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Filter by applied status (uses partition)
|
|
1291
|
+
if (applied !== undefined) {
|
|
1292
|
+
query.applied = applied;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Fetch transactions using partition-aware query
|
|
1296
|
+
const [ok, err, allTransactions] = await tryFn(() =>
|
|
1297
|
+
handler.transactionResource.query(query)
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
if (!ok || !allTransactions) {
|
|
1301
|
+
return [];
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Ensure all transactions have cohort fields
|
|
1305
|
+
let filtered = allTransactions;
|
|
1306
|
+
|
|
1307
|
+
// Filter by operation type
|
|
1308
|
+
if (operation !== undefined) {
|
|
1309
|
+
filtered = filtered.filter(t => t.operation === operation);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Filter by temporal fields (these are in-memory filters after partition query)
|
|
1313
|
+
if (cohortDate) {
|
|
1314
|
+
filtered = filtered.filter(t => t.cohortDate === cohortDate);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (cohortHour) {
|
|
1318
|
+
filtered = filtered.filter(t => t.cohortHour === cohortHour);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if (cohortMonth) {
|
|
1322
|
+
filtered = filtered.filter(t => t.cohortMonth === cohortMonth);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (startDate && endDate) {
|
|
1326
|
+
// Determine which cohort field to use based on format
|
|
1327
|
+
const isHourly = startDate.length > 10; // YYYY-MM-DDTHH vs YYYY-MM-DD
|
|
1328
|
+
const cohortField = isHourly ? 'cohortHour' : 'cohortDate';
|
|
1329
|
+
|
|
1330
|
+
filtered = filtered.filter(t =>
|
|
1331
|
+
t[cohortField] && t[cohortField] >= startDate && t[cohortField] <= endDate
|
|
1332
|
+
);
|
|
1333
|
+
} else if (startDate) {
|
|
1334
|
+
const isHourly = startDate.length > 10;
|
|
1335
|
+
const cohortField = isHourly ? 'cohortHour' : 'cohortDate';
|
|
1336
|
+
filtered = filtered.filter(t => t[cohortField] && t[cohortField] >= startDate);
|
|
1337
|
+
} else if (endDate) {
|
|
1338
|
+
const isHourly = endDate.length > 10;
|
|
1339
|
+
const cohortField = isHourly ? 'cohortHour' : 'cohortDate';
|
|
1340
|
+
filtered = filtered.filter(t => t[cohortField] && t[cohortField] <= endDate);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Sort by timestamp (newest first by default)
|
|
1344
|
+
filtered.sort((a, b) => {
|
|
1345
|
+
const aTime = new Date(a.timestamp || a.createdAt).getTime();
|
|
1346
|
+
const bTime = new Date(b.timestamp || b.createdAt).getTime();
|
|
1347
|
+
return bTime - aTime;
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// Apply limit
|
|
1351
|
+
if (limit && limit > 0) {
|
|
1352
|
+
filtered = filtered.slice(0, limit);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return filtered;
|
|
1356
|
+
}
|
|
@@ -47,9 +47,12 @@ export function createConfig(options, detectedTimezone) {
|
|
|
47
47
|
autoConsolidate: consolidation.auto !== false,
|
|
48
48
|
mode: consolidation.mode || 'async',
|
|
49
49
|
|
|
50
|
-
// ✅
|
|
50
|
+
// ✅ Performance tuning - Mark applied concurrency (default 50, up from 10)
|
|
51
51
|
markAppliedConcurrency: consolidation.markAppliedConcurrency ?? 50,
|
|
52
52
|
|
|
53
|
+
// ✅ Performance tuning - Recalculate concurrency (default 50, up from 10)
|
|
54
|
+
recalculateConcurrency: consolidation.recalculateConcurrency ?? 50,
|
|
55
|
+
|
|
53
56
|
// Late arrivals
|
|
54
57
|
lateArrivalStrategy: lateArrivals.strategy || 'warn',
|
|
55
58
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import tryFn from "../../concerns/try-fn.js";
|
|
7
7
|
import { PromisePool } from "@supercharge/promise-pool";
|
|
8
8
|
import { idGenerator } from "../../concerns/id.js";
|
|
9
|
-
import { getCohortInfo, createSyntheticSetTransaction } from "./utils.js";
|
|
9
|
+
import { getCohortInfo, createSyntheticSetTransaction, ensureCohortHour } from "./utils.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Start consolidation timer for a handler
|
|
@@ -612,12 +612,43 @@ export async function consolidateRecord(
|
|
|
612
612
|
.for(transactionsToUpdate)
|
|
613
613
|
.withConcurrency(markAppliedConcurrency) // ✅ Configurável e maior!
|
|
614
614
|
.process(async (txn) => {
|
|
615
|
+
// ✅ FIX BUG #3: Ensure cohort fields exist before marking as applied
|
|
616
|
+
// This handles legacy transactions missing cohortHour, cohortDate, etc.
|
|
617
|
+
const txnWithCohorts = ensureCohortHour(txn, config.cohort.timezone, false);
|
|
618
|
+
|
|
619
|
+
// Build update data with applied flag
|
|
620
|
+
const updateData = { applied: true };
|
|
621
|
+
|
|
622
|
+
// Add missing cohort fields if they were calculated
|
|
623
|
+
if (txnWithCohorts.cohortHour && !txn.cohortHour) {
|
|
624
|
+
updateData.cohortHour = txnWithCohorts.cohortHour;
|
|
625
|
+
}
|
|
626
|
+
if (txnWithCohorts.cohortDate && !txn.cohortDate) {
|
|
627
|
+
updateData.cohortDate = txnWithCohorts.cohortDate;
|
|
628
|
+
}
|
|
629
|
+
if (txnWithCohorts.cohortWeek && !txn.cohortWeek) {
|
|
630
|
+
updateData.cohortWeek = txnWithCohorts.cohortWeek;
|
|
631
|
+
}
|
|
632
|
+
if (txnWithCohorts.cohortMonth && !txn.cohortMonth) {
|
|
633
|
+
updateData.cohortMonth = txnWithCohorts.cohortMonth;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Handle null value field (legacy data might have null)
|
|
637
|
+
if (txn.value === null || txn.value === undefined) {
|
|
638
|
+
updateData.value = 1; // Default to 1 for backward compatibility
|
|
639
|
+
}
|
|
640
|
+
|
|
615
641
|
const [ok, err] = await tryFn(() =>
|
|
616
|
-
transactionResource.update(txn.id,
|
|
642
|
+
transactionResource.update(txn.id, updateData)
|
|
617
643
|
);
|
|
618
644
|
|
|
619
645
|
if (!ok && config.verbose) {
|
|
620
|
-
console.warn(
|
|
646
|
+
console.warn(
|
|
647
|
+
`[EventualConsistency] Failed to mark transaction ${txn.id} as applied:`,
|
|
648
|
+
err?.message,
|
|
649
|
+
'Update data:',
|
|
650
|
+
updateData
|
|
651
|
+
);
|
|
621
652
|
}
|
|
622
653
|
|
|
623
654
|
return ok;
|
|
@@ -917,9 +948,12 @@ export async function recalculateRecord(
|
|
|
917
948
|
// Exclude anchor transactions (they should always be applied)
|
|
918
949
|
const transactionsToReset = allTransactions.filter(txn => txn.source !== 'anchor');
|
|
919
950
|
|
|
951
|
+
// ✅ OPTIMIZATION: Use higher concurrency for recalculate (default 50 vs 10)
|
|
952
|
+
const recalculateConcurrency = config.recalculateConcurrency || 50;
|
|
953
|
+
|
|
920
954
|
const { results, errors } = await PromisePool
|
|
921
955
|
.for(transactionsToReset)
|
|
922
|
-
.withConcurrency(
|
|
956
|
+
.withConcurrency(recalculateConcurrency)
|
|
923
957
|
.process(async (txn) => {
|
|
924
958
|
const [ok, err] = await tryFn(() =>
|
|
925
959
|
transactionResource.update(txn.id, { applied: false })
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
runConsolidation
|
|
18
18
|
} from "./consolidation.js";
|
|
19
19
|
import { runGarbageCollection } from "./garbage-collection.js";
|
|
20
|
-
import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getYearByWeek, getMonthByWeek, getMonthByHour, getTopRecords, getYearByDay, getWeekByDay, getWeekByHour, getLastNHours, getLastNWeeks, getLastNMonths } from "./analytics.js";
|
|
20
|
+
import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getYearByWeek, getMonthByWeek, getMonthByHour, getTopRecords, getYearByDay, getWeekByDay, getWeekByHour, getLastNHours, getLastNWeeks, getLastNMonths, getRawEvents } from "./analytics.js";
|
|
21
21
|
import { onInstall, onStart, onStop, watchForResource, completeFieldSetup } from "./install.js";
|
|
22
22
|
|
|
23
23
|
export class EventualConsistencyPlugin extends Plugin {
|
|
@@ -531,6 +531,208 @@ export class EventualConsistencyPlugin extends Plugin {
|
|
|
531
531
|
async getLastNMonths(resourceName, field, months = 12, options = {}) {
|
|
532
532
|
return await getLastNMonths(resourceName, field, months, options, this.fieldHandlers);
|
|
533
533
|
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Get raw transaction events for custom aggregation
|
|
537
|
+
*
|
|
538
|
+
* This method provides direct access to the underlying transaction events,
|
|
539
|
+
* allowing developers to perform custom aggregations beyond the pre-built analytics.
|
|
540
|
+
* Useful for complex queries, custom metrics, or when you need the raw event data.
|
|
541
|
+
*
|
|
542
|
+
* @param {string} resourceName - Resource name
|
|
543
|
+
* @param {string} field - Field name
|
|
544
|
+
* @param {Object} options - Query options
|
|
545
|
+
* @param {string} options.recordId - Filter by specific record ID
|
|
546
|
+
* @param {string} options.startDate - Start date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
|
|
547
|
+
* @param {string} options.endDate - End date filter (YYYY-MM-DD or YYYY-MM-DDTHH)
|
|
548
|
+
* @param {string} options.cohortDate - Filter by cohort date (YYYY-MM-DD)
|
|
549
|
+
* @param {string} options.cohortHour - Filter by cohort hour (YYYY-MM-DDTHH)
|
|
550
|
+
* @param {string} options.cohortMonth - Filter by cohort month (YYYY-MM)
|
|
551
|
+
* @param {boolean} options.applied - Filter by applied status (true/false/undefined for both)
|
|
552
|
+
* @param {string} options.operation - Filter by operation type ('add', 'sub', 'set')
|
|
553
|
+
* @param {number} options.limit - Maximum number of events to return
|
|
554
|
+
* @returns {Promise<Array>} Raw transaction events
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* // Get all events for a specific record
|
|
558
|
+
* const events = await plugin.getRawEvents('wallets', 'balance', {
|
|
559
|
+
* recordId: 'wallet1'
|
|
560
|
+
* });
|
|
561
|
+
*
|
|
562
|
+
* @example
|
|
563
|
+
* // Get events for a specific time range
|
|
564
|
+
* const events = await plugin.getRawEvents('wallets', 'balance', {
|
|
565
|
+
* startDate: '2025-10-01',
|
|
566
|
+
* endDate: '2025-10-31'
|
|
567
|
+
* });
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* // Get only pending (unapplied) transactions
|
|
571
|
+
* const pending = await plugin.getRawEvents('wallets', 'balance', {
|
|
572
|
+
* applied: false
|
|
573
|
+
* });
|
|
574
|
+
*/
|
|
575
|
+
async getRawEvents(resourceName, field, options = {}) {
|
|
576
|
+
return await getRawEvents(resourceName, field, options, this.fieldHandlers);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Get diagnostics information about the plugin state
|
|
581
|
+
*
|
|
582
|
+
* This method provides comprehensive diagnostic information about the EventualConsistencyPlugin,
|
|
583
|
+
* including configured resources, field handlers, timers, and overall health status.
|
|
584
|
+
* Useful for debugging initialization issues, configuration problems, or runtime errors.
|
|
585
|
+
*
|
|
586
|
+
* @param {Object} options - Diagnostic options
|
|
587
|
+
* @param {string} options.resourceName - Optional: limit diagnostics to specific resource
|
|
588
|
+
* @param {string} options.field - Optional: limit diagnostics to specific field
|
|
589
|
+
* @param {boolean} options.includeStats - Include transaction statistics (default: false)
|
|
590
|
+
* @returns {Promise<Object>} Diagnostic information
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* // Get overall plugin diagnostics
|
|
594
|
+
* const diagnostics = await plugin.getDiagnostics();
|
|
595
|
+
* console.log(diagnostics);
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* // Get diagnostics for specific resource/field with stats
|
|
599
|
+
* const diagnostics = await plugin.getDiagnostics({
|
|
600
|
+
* resourceName: 'wallets',
|
|
601
|
+
* field: 'balance',
|
|
602
|
+
* includeStats: true
|
|
603
|
+
* });
|
|
604
|
+
*/
|
|
605
|
+
async getDiagnostics(options = {}) {
|
|
606
|
+
const { resourceName, field, includeStats = false } = options;
|
|
607
|
+
|
|
608
|
+
const diagnostics = {
|
|
609
|
+
plugin: {
|
|
610
|
+
name: 'EventualConsistencyPlugin',
|
|
611
|
+
initialized: this.database !== null && this.database !== undefined,
|
|
612
|
+
verbose: this.config.verbose || false,
|
|
613
|
+
timezone: this.config.cohort?.timezone || 'UTC',
|
|
614
|
+
consolidation: {
|
|
615
|
+
mode: this.config.consolidation?.mode || 'timer',
|
|
616
|
+
interval: this.config.consolidation?.interval || 60000,
|
|
617
|
+
batchSize: this.config.consolidation?.batchSize || 100
|
|
618
|
+
},
|
|
619
|
+
garbageCollection: {
|
|
620
|
+
enabled: this.config.garbageCollection?.enabled !== false,
|
|
621
|
+
retentionDays: this.config.garbageCollection?.retentionDays || 30,
|
|
622
|
+
interval: this.config.garbageCollection?.interval || 3600000
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
resources: [],
|
|
626
|
+
errors: [],
|
|
627
|
+
warnings: []
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Iterate through configured resources
|
|
631
|
+
for (const [resName, resourceHandlers] of this.fieldHandlers.entries()) {
|
|
632
|
+
// Skip if filtering by resource and this isn't it
|
|
633
|
+
if (resourceName && resName !== resourceName) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const resourceDiag = {
|
|
638
|
+
name: resName,
|
|
639
|
+
fields: []
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
for (const [fieldName, handler] of resourceHandlers.entries()) {
|
|
643
|
+
// Skip if filtering by field and this isn't it
|
|
644
|
+
if (field && fieldName !== field) {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const fieldDiag = {
|
|
649
|
+
name: fieldName,
|
|
650
|
+
type: handler.type || 'counter',
|
|
651
|
+
analyticsEnabled: handler.analyticsResource !== null && handler.analyticsResource !== undefined,
|
|
652
|
+
resources: {
|
|
653
|
+
transaction: handler.transactionResource?.name || null,
|
|
654
|
+
target: handler.targetResource?.name || null,
|
|
655
|
+
analytics: handler.analyticsResource?.name || null
|
|
656
|
+
},
|
|
657
|
+
timers: {
|
|
658
|
+
consolidation: handler.consolidationTimer !== null && handler.consolidationTimer !== undefined,
|
|
659
|
+
garbageCollection: handler.garbageCollectionTimer !== null && handler.garbageCollectionTimer !== undefined
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// Check for common issues
|
|
664
|
+
if (!handler.transactionResource) {
|
|
665
|
+
diagnostics.errors.push({
|
|
666
|
+
resource: resName,
|
|
667
|
+
field: fieldName,
|
|
668
|
+
issue: 'Missing transaction resource',
|
|
669
|
+
suggestion: 'Ensure plugin is installed and resources are created after plugin installation'
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!handler.targetResource) {
|
|
674
|
+
diagnostics.warnings.push({
|
|
675
|
+
resource: resName,
|
|
676
|
+
field: fieldName,
|
|
677
|
+
issue: 'Missing target resource',
|
|
678
|
+
suggestion: 'Target resource may not have been created yet'
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (handler.analyticsResource && !handler.analyticsResource.name) {
|
|
683
|
+
diagnostics.errors.push({
|
|
684
|
+
resource: resName,
|
|
685
|
+
field: fieldName,
|
|
686
|
+
issue: 'Invalid analytics resource',
|
|
687
|
+
suggestion: 'Analytics resource exists but has no name - possible initialization failure'
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Include statistics if requested
|
|
692
|
+
if (includeStats && handler.transactionResource) {
|
|
693
|
+
try {
|
|
694
|
+
const [okPending, errPending, pendingTxns] = await handler.transactionResource.query({ applied: false }).catch(() => [false, null, []]);
|
|
695
|
+
const [okApplied, errApplied, appliedTxns] = await handler.transactionResource.query({ applied: true }).catch(() => [false, null, []]);
|
|
696
|
+
|
|
697
|
+
fieldDiag.stats = {
|
|
698
|
+
pendingTransactions: okPending ? (pendingTxns?.length || 0) : 'error',
|
|
699
|
+
appliedTransactions: okApplied ? (appliedTxns?.length || 0) : 'error',
|
|
700
|
+
totalTransactions: (okPending && okApplied) ? ((pendingTxns?.length || 0) + (appliedTxns?.length || 0)) : 'error'
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
if (handler.analyticsResource) {
|
|
704
|
+
const [okAnalytics, errAnalytics, analyticsRecords] = await handler.analyticsResource.list().catch(() => [false, null, []]);
|
|
705
|
+
fieldDiag.stats.analyticsRecords = okAnalytics ? (analyticsRecords?.length || 0) : 'error';
|
|
706
|
+
}
|
|
707
|
+
} catch (error) {
|
|
708
|
+
diagnostics.warnings.push({
|
|
709
|
+
resource: resName,
|
|
710
|
+
field: fieldName,
|
|
711
|
+
issue: 'Failed to fetch statistics',
|
|
712
|
+
error: error.message
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
resourceDiag.fields.push(fieldDiag);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (resourceDiag.fields.length > 0) {
|
|
721
|
+
diagnostics.resources.push(resourceDiag);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Overall health check
|
|
726
|
+
diagnostics.health = {
|
|
727
|
+
status: diagnostics.errors.length === 0 ? (diagnostics.warnings.length === 0 ? 'healthy' : 'warning') : 'error',
|
|
728
|
+
totalResources: diagnostics.resources.length,
|
|
729
|
+
totalFields: diagnostics.resources.reduce((sum, r) => sum + r.fields.length, 0),
|
|
730
|
+
errorCount: diagnostics.errors.length,
|
|
731
|
+
warningCount: diagnostics.warnings.length
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
return diagnostics;
|
|
735
|
+
}
|
|
534
736
|
}
|
|
535
737
|
|
|
536
738
|
export default EventualConsistencyPlugin;
|
|
@@ -173,6 +173,28 @@ async function createAnalyticsResource(handler, database, resourceName, fieldNam
|
|
|
173
173
|
},
|
|
174
174
|
behavior: 'body-overflow',
|
|
175
175
|
timestamps: false,
|
|
176
|
+
asyncPartitions: true,
|
|
177
|
+
// ✅ Multi-attribute partitions for optimal analytics query performance
|
|
178
|
+
partitions: {
|
|
179
|
+
// Query by period (hour/day/week/month)
|
|
180
|
+
byPeriod: {
|
|
181
|
+
fields: { period: 'string' }
|
|
182
|
+
},
|
|
183
|
+
// Query by period + cohort (e.g., all hour records for specific hours)
|
|
184
|
+
byPeriodCohort: {
|
|
185
|
+
fields: {
|
|
186
|
+
period: 'string',
|
|
187
|
+
cohort: 'string'
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
// Query by field + period (e.g., all daily analytics for clicks field)
|
|
191
|
+
byFieldPeriod: {
|
|
192
|
+
fields: {
|
|
193
|
+
field: 'string',
|
|
194
|
+
period: 'string'
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
176
198
|
createdBy: 'EventualConsistencyPlugin'
|
|
177
199
|
})
|
|
178
200
|
);
|
package/src/resource.class.js
CHANGED
|
@@ -134,6 +134,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
134
134
|
idGenerator: customIdGenerator,
|
|
135
135
|
idSize = 22,
|
|
136
136
|
versioningEnabled = false,
|
|
137
|
+
strictValidation = true,
|
|
137
138
|
events = {},
|
|
138
139
|
asyncEvents = true,
|
|
139
140
|
asyncPartitions = true,
|
|
@@ -149,6 +150,7 @@ export class Resource extends AsyncEventEmitter {
|
|
|
149
150
|
this.parallelism = parallelism;
|
|
150
151
|
this.passphrase = passphrase ?? 'secret';
|
|
151
152
|
this.versioningEnabled = versioningEnabled;
|
|
153
|
+
this.strictValidation = strictValidation;
|
|
152
154
|
|
|
153
155
|
// Configure async events mode
|
|
154
156
|
this.setAsyncMode(asyncEvents);
|
|
@@ -476,9 +478,14 @@ export class Resource extends AsyncEventEmitter {
|
|
|
476
478
|
|
|
477
479
|
/**
|
|
478
480
|
* Validate that all partition fields exist in current resource attributes
|
|
479
|
-
* @throws {Error} If partition fields don't exist in current schema
|
|
481
|
+
* @throws {Error} If partition fields don't exist in current schema (only when strictValidation is true)
|
|
480
482
|
*/
|
|
481
483
|
validatePartitions() {
|
|
484
|
+
// Skip validation if strictValidation is disabled
|
|
485
|
+
if (!this.strictValidation) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
482
489
|
if (!this.config.partitions) {
|
|
483
490
|
return; // No partitions to validate
|
|
484
491
|
}
|