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.
- package/README.md +59 -2
- package/SECURITY.md +76 -0
- package/dist/s3db.cjs.js +446 -86
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +446 -86
- package/dist/s3db.es.js.map +1 -1
- package/package.json +3 -1
- package/src/concerns/crypto.js +7 -14
- package/src/plugins/eventual-consistency/analytics.js +164 -2
- package/src/plugins/eventual-consistency/consolidation.js +228 -80
- package/src/plugins/eventual-consistency/helpers.js +24 -8
- package/src/plugins/eventual-consistency/install.js +2 -1
- package/src/plugins/eventual-consistency/utils.js +218 -4
- package/src/concerns/advanced-metadata-encoding.js +0 -440
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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|
|
|
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
|
+
}
|