s3db.js 11.0.4 → 11.1.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.
@@ -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 } from "./analytics.js";
20
+ import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getYearByWeek, getMonthByWeek, getMonthByHour, getTopRecords, getYearByDay, getWeekByDay, getWeekByHour, getLastNHours, getLastNWeeks, getLastNMonths } from "./analytics.js";
21
21
  import { onInstall, onStart, onStop, watchForResource, completeFieldSetup } from "./install.js";
22
22
 
23
23
  export class EventualConsistencyPlugin extends Plugin {
@@ -459,6 +459,78 @@ export class EventualConsistencyPlugin extends Plugin {
459
459
  async getTopRecords(resourceName, field, options = {}) {
460
460
  return await getTopRecords(resourceName, field, options, this.fieldHandlers);
461
461
  }
462
+
463
+ /**
464
+ * Get analytics for entire year, broken down by days
465
+ * @param {string} resourceName - Resource name
466
+ * @param {string} field - Field name
467
+ * @param {number} year - Year (e.g., 2025)
468
+ * @param {Object} options - Options
469
+ * @returns {Promise<Array>} Daily analytics for the year (up to 365/366 records)
470
+ */
471
+ async getYearByDay(resourceName, field, year, options = {}) {
472
+ return await getYearByDay(resourceName, field, year, options, this.fieldHandlers);
473
+ }
474
+
475
+ /**
476
+ * Get analytics for entire week, broken down by days
477
+ * @param {string} resourceName - Resource name
478
+ * @param {string} field - Field name
479
+ * @param {string} week - Week in YYYY-Www format (e.g., '2025-W42')
480
+ * @param {Object} options - Options
481
+ * @returns {Promise<Array>} Daily analytics for the week (7 records)
482
+ */
483
+ async getWeekByDay(resourceName, field, week, options = {}) {
484
+ return await getWeekByDay(resourceName, field, week, options, this.fieldHandlers);
485
+ }
486
+
487
+ /**
488
+ * Get analytics for entire week, broken down by hours
489
+ * @param {string} resourceName - Resource name
490
+ * @param {string} field - Field name
491
+ * @param {string} week - Week in YYYY-Www format (e.g., '2025-W42')
492
+ * @param {Object} options - Options
493
+ * @returns {Promise<Array>} Hourly analytics for the week (168 records)
494
+ */
495
+ async getWeekByHour(resourceName, field, week, options = {}) {
496
+ return await getWeekByHour(resourceName, field, week, options, this.fieldHandlers);
497
+ }
498
+
499
+ /**
500
+ * Get analytics for last N hours
501
+ * @param {string} resourceName - Resource name
502
+ * @param {string} field - Field name
503
+ * @param {number} hours - Number of hours to look back (default: 24)
504
+ * @param {Object} options - Options
505
+ * @returns {Promise<Array>} Hourly analytics
506
+ */
507
+ async getLastNHours(resourceName, field, hours = 24, options = {}) {
508
+ return await getLastNHours(resourceName, field, hours, options, this.fieldHandlers);
509
+ }
510
+
511
+ /**
512
+ * Get analytics for last N weeks
513
+ * @param {string} resourceName - Resource name
514
+ * @param {string} field - Field name
515
+ * @param {number} weeks - Number of weeks to look back (default: 4)
516
+ * @param {Object} options - Options
517
+ * @returns {Promise<Array>} Weekly analytics
518
+ */
519
+ async getLastNWeeks(resourceName, field, weeks = 4, options = {}) {
520
+ return await getLastNWeeks(resourceName, field, weeks, options, this.fieldHandlers);
521
+ }
522
+
523
+ /**
524
+ * Get analytics for last N months
525
+ * @param {string} resourceName - Resource name
526
+ * @param {string} field - Field name
527
+ * @param {number} months - Number of months to look back (default: 12)
528
+ * @param {Object} options - Options
529
+ * @returns {Promise<Array>} Monthly analytics
530
+ */
531
+ async getLastNMonths(resourceName, field, months = 12, options = {}) {
532
+ return await getLastNMonths(resourceName, field, months, options, this.fieldHandlers);
533
+ }
462
534
  }
463
535
 
464
536
  export default EventualConsistencyPlugin;
@@ -95,6 +95,7 @@ export async function completeFieldSetup(handler, database, config, plugin) {
95
95
  id: 'string|required',
96
96
  originalId: 'string|required',
97
97
  field: 'string|required',
98
+ fieldPath: 'string|optional', // Support for nested field paths (e.g., 'utmResults.medium')
98
99
  value: 'number|required',
99
100
  operation: 'string|required',
100
101
  timestamp: 'string|required',
@@ -160,19 +160,169 @@ export function createFieldHandler(resourceName, fieldName) {
160
160
  };
161
161
  }
162
162
 
163
+ /**
164
+ * Validate nested path in resource schema
165
+ * Allows 1 level of nesting after 'json' type fields
166
+ *
167
+ * @param {Object} resource - Resource object
168
+ * @param {string} fieldPath - Dot-notation path (e.g., 'utmResults.medium.google')
169
+ * @returns {Object} { valid: boolean, rootField: string, fullPath: string, error?: string }
170
+ */
171
+ export function validateNestedPath(resource, fieldPath) {
172
+ const parts = fieldPath.split('.');
173
+ const rootField = parts[0];
174
+
175
+ // Root field must exist in resource attributes
176
+ if (!resource.attributes || !resource.attributes[rootField]) {
177
+ return {
178
+ valid: false,
179
+ rootField,
180
+ fullPath: fieldPath,
181
+ error: `Root field "${rootField}" not found in resource attributes`
182
+ };
183
+ }
184
+
185
+ // If no nesting, just return valid
186
+ if (parts.length === 1) {
187
+ return { valid: true, rootField, fullPath: fieldPath };
188
+ }
189
+
190
+ // Validate nested path
191
+ let current = resource.attributes[rootField];
192
+ let foundJson = false;
193
+ let levelsAfterJson = 0;
194
+
195
+ for (let i = 1; i < parts.length; i++) {
196
+ const part = parts[i];
197
+
198
+ // If we found 'json' before, count levels
199
+ if (foundJson) {
200
+ levelsAfterJson++;
201
+ // Only allow 1 level after 'json'
202
+ if (levelsAfterJson > 1) {
203
+ return {
204
+ valid: false,
205
+ rootField,
206
+ fullPath: fieldPath,
207
+ error: `Path "${fieldPath}" exceeds 1 level after 'json' field. Maximum nesting after 'json' is 1 level.`
208
+ };
209
+ }
210
+ // After 'json', we can't validate further, but we allow 1 level
211
+ continue;
212
+ }
213
+
214
+ // Check if current level is 'json' type
215
+ if (typeof current === 'string') {
216
+ if (current === 'json' || current.startsWith('json|')) {
217
+ foundJson = true;
218
+ levelsAfterJson++;
219
+ // Allow 1 level after json
220
+ if (levelsAfterJson > 1) {
221
+ return {
222
+ valid: false,
223
+ rootField,
224
+ fullPath: fieldPath,
225
+ error: `Path "${fieldPath}" exceeds 1 level after 'json' field`
226
+ };
227
+ }
228
+ continue;
229
+ }
230
+ // Other string types can't be nested
231
+ return {
232
+ valid: false,
233
+ rootField,
234
+ fullPath: fieldPath,
235
+ error: `Field "${parts.slice(0, i).join('.')}" is type "${current}" and cannot be nested`
236
+ };
237
+ }
238
+
239
+ // Check if current is an object with nested structure
240
+ if (typeof current === 'object') {
241
+ // Check for $$type
242
+ if (current.$$type) {
243
+ const type = current.$$type;
244
+ if (type === 'json' || type.includes('json')) {
245
+ foundJson = true;
246
+ levelsAfterJson++;
247
+ continue;
248
+ }
249
+ if (type !== 'object' && !type.includes('object')) {
250
+ return {
251
+ valid: false,
252
+ rootField,
253
+ fullPath: fieldPath,
254
+ error: `Field "${parts.slice(0, i).join('.')}" is type "${type}" and cannot be nested`
255
+ };
256
+ }
257
+ }
258
+
259
+ // Navigate to next level
260
+ if (!current[part]) {
261
+ return {
262
+ valid: false,
263
+ rootField,
264
+ fullPath: fieldPath,
265
+ error: `Field "${part}" not found in "${parts.slice(0, i).join('.')}"`
266
+ };
267
+ }
268
+ current = current[part];
269
+ } else {
270
+ return {
271
+ valid: false,
272
+ rootField,
273
+ fullPath: fieldPath,
274
+ error: `Invalid structure at "${parts.slice(0, i).join('.')}"`
275
+ };
276
+ }
277
+ }
278
+
279
+ return { valid: true, rootField, fullPath: fieldPath };
280
+ }
281
+
163
282
  /**
164
283
  * Resolve field and plugin from arguments
284
+ * Supports dot notation for nested fields (e.g., 'utmResults.medium.google')
285
+ *
165
286
  * @param {Object} resource - Resource object
166
- * @param {string} field - Field name
287
+ * @param {string} field - Field name or path (supports dot notation)
167
288
  * @param {*} value - Value (for error reporting)
168
- * @returns {Object} Resolved field and plugin handler
169
- * @throws {Error} If field or plugin not found
289
+ * @returns {Object} Resolved field, path, and plugin handler
290
+ * @throws {Error} If field or plugin not found, or path is invalid
170
291
  */
171
292
  export function resolveFieldAndPlugin(resource, field, value) {
172
293
  if (!resource._eventualConsistencyPlugins) {
173
294
  throw new Error(`No eventual consistency plugins configured for this resource`);
174
295
  }
175
296
 
297
+ // Check if field contains dot notation (nested path)
298
+ if (field.includes('.')) {
299
+ const validation = validateNestedPath(resource, field);
300
+
301
+ if (!validation.valid) {
302
+ throw new Error(validation.error);
303
+ }
304
+
305
+ // Get plugin for root field
306
+ const rootField = validation.rootField;
307
+ const fieldPlugin = resource._eventualConsistencyPlugins[rootField];
308
+
309
+ if (!fieldPlugin) {
310
+ const availableFields = Object.keys(resource._eventualConsistencyPlugins).join(', ');
311
+ throw new Error(
312
+ `No eventual consistency plugin found for root field "${rootField}". ` +
313
+ `Available fields: ${availableFields}`
314
+ );
315
+ }
316
+
317
+ return {
318
+ field: rootField, // Root field for plugin lookup
319
+ fieldPath: field, // Full path for nested access
320
+ value,
321
+ plugin: fieldPlugin
322
+ };
323
+ }
324
+
325
+ // Simple field (no nesting)
176
326
  const fieldPlugin = resource._eventualConsistencyPlugins[field];
177
327
 
178
328
  if (!fieldPlugin) {
@@ -183,7 +333,7 @@ export function resolveFieldAndPlugin(resource, field, value) {
183
333
  );
184
334
  }
185
335
 
186
- return { field, value, plugin: fieldPlugin };
336
+ return { field, fieldPath: field, value, plugin: fieldPlugin };
187
337
  }
188
338
 
189
339
  /**