s3db.js 11.0.5 → 11.2.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.
@@ -18,8 +18,9 @@ import { getCohortInfo, resolveFieldAndPlugin } from "./utils.js";
18
18
  export function addHelperMethods(resource, plugin, config) {
19
19
  // Add method to set value (replaces current value)
20
20
  // Signature: set(id, field, value)
21
+ // Supports dot notation: set(id, 'utmResults.medium', 10)
21
22
  resource.set = async (id, field, value) => {
22
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, value);
23
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, value);
23
24
 
24
25
  // Create transaction inline
25
26
  const now = new Date();
@@ -29,6 +30,7 @@ export function addHelperMethods(resource, plugin, config) {
29
30
  id: idGenerator(),
30
31
  originalId: id,
31
32
  field: handler.field,
33
+ fieldPath: fieldPath, // Store full path for nested access
32
34
  value: value,
33
35
  operation: 'set',
34
36
  timestamp: now.toISOString(),
@@ -43,7 +45,7 @@ export function addHelperMethods(resource, plugin, config) {
43
45
 
44
46
  // In sync mode, immediately consolidate
45
47
  if (config.mode === 'sync') {
46
- return await plugin._syncModeConsolidate(handler, id, field);
48
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
47
49
  }
48
50
 
49
51
  return value;
@@ -51,8 +53,9 @@ export function addHelperMethods(resource, plugin, config) {
51
53
 
52
54
  // Add method to increment value
53
55
  // Signature: add(id, field, amount)
56
+ // Supports dot notation: add(id, 'utmResults.medium', 5)
54
57
  resource.add = async (id, field, amount) => {
55
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
58
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
56
59
 
57
60
  // Create transaction inline
58
61
  const now = new Date();
@@ -62,6 +65,7 @@ export function addHelperMethods(resource, plugin, config) {
62
65
  id: idGenerator(),
63
66
  originalId: id,
64
67
  field: handler.field,
68
+ fieldPath: fieldPath, // Store full path for nested access
65
69
  value: amount,
66
70
  operation: 'add',
67
71
  timestamp: now.toISOString(),
@@ -76,19 +80,25 @@ export function addHelperMethods(resource, plugin, config) {
76
80
 
77
81
  // In sync mode, immediately consolidate
78
82
  if (config.mode === 'sync') {
79
- return await plugin._syncModeConsolidate(handler, id, field);
83
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
80
84
  }
81
85
 
82
86
  // Async mode - return current value (optimistic)
87
+ // Note: For nested paths, we need to use lodash get
83
88
  const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
84
- const currentValue = (ok && record) ? (record[field] || 0) : 0;
89
+ if (!ok || !record) return amount;
90
+
91
+ // Get current value from nested path
92
+ const lodash = await import('lodash-es');
93
+ const currentValue = lodash.get(record, fieldPath, 0);
85
94
  return currentValue + amount;
86
95
  };
87
96
 
88
97
  // Add method to decrement value
89
98
  // Signature: sub(id, field, amount)
99
+ // Supports dot notation: sub(id, 'utmResults.medium', 3)
90
100
  resource.sub = async (id, field, amount) => {
91
- const { plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
101
+ const { field: rootField, fieldPath, plugin: handler } = resolveFieldAndPlugin(resource, field, amount);
92
102
 
93
103
  // Create transaction inline
94
104
  const now = new Date();
@@ -98,6 +108,7 @@ export function addHelperMethods(resource, plugin, config) {
98
108
  id: idGenerator(),
99
109
  originalId: id,
100
110
  field: handler.field,
111
+ fieldPath: fieldPath, // Store full path for nested access
101
112
  value: amount,
102
113
  operation: 'sub',
103
114
  timestamp: now.toISOString(),
@@ -112,12 +123,17 @@ export function addHelperMethods(resource, plugin, config) {
112
123
 
113
124
  // In sync mode, immediately consolidate
114
125
  if (config.mode === 'sync') {
115
- return await plugin._syncModeConsolidate(handler, id, field);
126
+ return await plugin._syncModeConsolidate(handler, id, fieldPath);
116
127
  }
117
128
 
118
129
  // Async mode - return current value (optimistic)
130
+ // Note: For nested paths, we need to use lodash get
119
131
  const [ok, err, record] = await tryFn(() => handler.targetResource.get(id));
120
- const currentValue = (ok && record) ? (record[field] || 0) : 0;
132
+ if (!ok || !record) return -amount;
133
+
134
+ // Get current value from nested path
135
+ const lodash = await import('lodash-es');
136
+ const currentValue = lodash.get(record, fieldPath, 0);
121
137
  return currentValue - amount;
122
138
  };
123
139
 
@@ -95,11 +95,12 @@ 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',
101
102
  cohortDate: 'string|required',
102
- cohortHour: 'string|required',
103
+ cohortHour: 'string|optional', // ✅ FIX BUG #2: Changed from required to optional for migration compatibility
103
104
  cohortWeek: 'string|optional',
104
105
  cohortMonth: 'string|optional',
105
106
  source: 'string|optional',
@@ -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
  /**
@@ -205,3 +355,67 @@ export function groupByCohort(transactions, cohortField) {
205
355
  }
206
356
  return groups;
207
357
  }
358
+
359
+ /**
360
+ * Ensure transaction has cohortHour field
361
+ * ✅ FIX BUG #2: Calculate cohortHour from timestamp if missing
362
+ *
363
+ * @param {Object} transaction - Transaction to check/fix
364
+ * @param {string} timezone - Timezone to use for cohort calculation
365
+ * @param {boolean} verbose - Whether to log warnings
366
+ * @returns {Object} Transaction with cohortHour populated
367
+ */
368
+ export function ensureCohortHour(transaction, timezone = 'UTC', verbose = false) {
369
+ // If cohortHour already exists, return as-is
370
+ if (transaction.cohortHour) {
371
+ return transaction;
372
+ }
373
+
374
+ // Calculate cohortHour from timestamp
375
+ if (transaction.timestamp) {
376
+ const date = new Date(transaction.timestamp);
377
+ const cohortInfo = getCohortInfo(date, timezone, verbose);
378
+
379
+ if (verbose) {
380
+ console.log(
381
+ `[EventualConsistency] Transaction ${transaction.id} missing cohortHour, ` +
382
+ `calculated from timestamp: ${cohortInfo.hour}`
383
+ );
384
+ }
385
+
386
+ // Add cohortHour (and other cohort fields if missing)
387
+ transaction.cohortHour = cohortInfo.hour;
388
+
389
+ if (!transaction.cohortWeek) {
390
+ transaction.cohortWeek = cohortInfo.week;
391
+ }
392
+
393
+ if (!transaction.cohortMonth) {
394
+ transaction.cohortMonth = cohortInfo.month;
395
+ }
396
+ } else if (verbose) {
397
+ console.warn(
398
+ `[EventualConsistency] Transaction ${transaction.id} missing both cohortHour and timestamp, ` +
399
+ `cannot calculate cohort`
400
+ );
401
+ }
402
+
403
+ return transaction;
404
+ }
405
+
406
+ /**
407
+ * Ensure all transactions in array have cohortHour
408
+ * ✅ FIX BUG #2: Batch version of ensureCohortHour
409
+ *
410
+ * @param {Array} transactions - Transactions to check/fix
411
+ * @param {string} timezone - Timezone to use for cohort calculation
412
+ * @param {boolean} verbose - Whether to log warnings
413
+ * @returns {Array} Transactions with cohortHour populated
414
+ */
415
+ export function ensureCohortHours(transactions, timezone = 'UTC', verbose = false) {
416
+ if (!transactions || !Array.isArray(transactions)) {
417
+ return transactions;
418
+ }
419
+
420
+ return transactions.map(txn => ensureCohortHour(txn, timezone, verbose));
421
+ }