@sschepis/magazine 0.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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/magazine.cjs.js +5 -0
- package/dist/magazine.cjs.js.map +1 -0
- package/dist/magazine.es.js +4149 -0
- package/dist/magazine.es.js.map +1 -0
- package/dist/magazine.umd.js +5 -0
- package/dist/magazine.umd.js.map +1 -0
- package/package.json +74 -0
- package/src/components/ConfigManager.js +202 -0
- package/src/components/ConfigSchema.js +506 -0
- package/src/components/DataCompressor.js +378 -0
- package/src/components/DataTransformer.js +1172 -0
- package/src/components/DebugUtils.js +657 -0
- package/src/components/EventFilter.js +474 -0
- package/src/components/EventMetadata.js +273 -0
- package/src/components/EventStore.js +443 -0
- package/src/components/EventSyncer.js +353 -0
- package/src/components/Logger.js +299 -0
- package/src/components/NetworkManager.js +461 -0
- package/src/components/Paginator.js +343 -0
- package/src/index.js +571 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTransformer - Transforms blockchain events into structured data models using GunDB
|
|
3
|
+
* Inspired by @nomyx/gundb but integrated with Magazine's existing architecture
|
|
4
|
+
*/
|
|
5
|
+
import Logger from './Logger.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Custom error classes for data transformation
|
|
9
|
+
*/
|
|
10
|
+
export class ValidationError extends Error {
|
|
11
|
+
constructor(message, field = null) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'ValidationError';
|
|
14
|
+
this.field = field;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ModelError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'ModelError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* SchemaMigration - Tracks schema versions and migrates documents between versions.
|
|
27
|
+
*/
|
|
28
|
+
export class SchemaMigration {
|
|
29
|
+
constructor(modelName, gunInstance) {
|
|
30
|
+
this.modelName = modelName;
|
|
31
|
+
this.gun = gunInstance;
|
|
32
|
+
this.migrations = new Map();
|
|
33
|
+
this.currentVersion = 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
version(v) {
|
|
37
|
+
this.currentVersion = v;
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
addMigration(fromVersion, toVersion, migrateFn) {
|
|
42
|
+
this.migrations.set(`${fromVersion}:${toVersion}`, migrateFn);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async migrate(document) {
|
|
47
|
+
let docVersion = document._schemaVersion || 1;
|
|
48
|
+
let migrated = { ...document };
|
|
49
|
+
while (docVersion < this.currentVersion) {
|
|
50
|
+
const nextVersion = docVersion + 1;
|
|
51
|
+
const key = `${docVersion}:${nextVersion}`;
|
|
52
|
+
const migrateFn = this.migrations.get(key);
|
|
53
|
+
if (!migrateFn) {
|
|
54
|
+
throw new ModelError(
|
|
55
|
+
`No migration path from version ${docVersion} to ${nextVersion} for model '${this.modelName}'`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
migrated = await migrateFn(migrated);
|
|
59
|
+
migrated._schemaVersion = nextVersion;
|
|
60
|
+
docVersion = nextVersion;
|
|
61
|
+
}
|
|
62
|
+
return migrated;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
needsMigration(document) {
|
|
66
|
+
return (document._schemaVersion || 1) < this.currentVersion;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Simple schema validator (can be extended to use Joi or other validation libraries)
|
|
72
|
+
*/
|
|
73
|
+
class SchemaValidator {
|
|
74
|
+
static validate(data, schema) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
|
|
77
|
+
// Check required fields
|
|
78
|
+
for (const field of schema.required || []) {
|
|
79
|
+
if (!(field in data) || data[field] === null || data[field] === undefined) {
|
|
80
|
+
errors.push(`Field '${field}' is required`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check field types
|
|
85
|
+
for (const [field, definition] of Object.entries(schema.properties || {})) {
|
|
86
|
+
if (field in data && data[field] !== null && data[field] !== undefined) {
|
|
87
|
+
const value = data[field];
|
|
88
|
+
const { type, validate: customValidator } = definition;
|
|
89
|
+
|
|
90
|
+
// Type validation
|
|
91
|
+
if (type && !this.validateType(value, type)) {
|
|
92
|
+
errors.push(`Field '${field}' must be of type ${type}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Custom validation
|
|
96
|
+
if (customValidator && typeof customValidator === 'function') {
|
|
97
|
+
const customResult = customValidator(value);
|
|
98
|
+
if (customResult !== true) {
|
|
99
|
+
errors.push(`Field '${field}': ${customResult || 'validation failed'}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
valid: errors.length === 0,
|
|
107
|
+
errors
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static validateType(value, type) {
|
|
112
|
+
switch (type) {
|
|
113
|
+
case 'string':
|
|
114
|
+
return typeof value === 'string';
|
|
115
|
+
case 'number':
|
|
116
|
+
return typeof value === 'number' && !isNaN(value);
|
|
117
|
+
case 'boolean':
|
|
118
|
+
return typeof value === 'boolean';
|
|
119
|
+
case 'array':
|
|
120
|
+
return Array.isArray(value);
|
|
121
|
+
case 'object':
|
|
122
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
123
|
+
case 'address':
|
|
124
|
+
return typeof value === 'string' && /^0x[a-fA-F0-9]{40}$/.test(value);
|
|
125
|
+
case 'hash':
|
|
126
|
+
return typeof value === 'string' && /^0x[a-fA-F0-9]{64}$/.test(value);
|
|
127
|
+
default:
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* DataModel - Represents a structured data model with CRUD operations
|
|
135
|
+
*/
|
|
136
|
+
export class DataModel {
|
|
137
|
+
constructor(name, schema, gunInstance, transformer, options = {}) {
|
|
138
|
+
this.name = name;
|
|
139
|
+
this.schema = schema;
|
|
140
|
+
this.gun = gunInstance;
|
|
141
|
+
this.transformer = transformer;
|
|
142
|
+
this.options = {
|
|
143
|
+
autoId: true,
|
|
144
|
+
timestampFields: ['createdAt', 'updatedAt'],
|
|
145
|
+
...options
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
this.logger = new Logger({
|
|
149
|
+
context: `DataModel:${name}`,
|
|
150
|
+
level: options.logLevel || 'info'
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Get model's Gun node
|
|
154
|
+
this.modelNode = this.gun.get('models').get(this.name);
|
|
155
|
+
|
|
156
|
+
// Lifecycle hooks: { 'pre:save': [fn], 'post:save': [fn], ... }
|
|
157
|
+
this._hooks = {};
|
|
158
|
+
|
|
159
|
+
// Schema migration instance (set via setMigration)
|
|
160
|
+
this.migration = null;
|
|
161
|
+
|
|
162
|
+
this.logger.info('DataModel initialized', {
|
|
163
|
+
name: this.name,
|
|
164
|
+
options: this.options
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Register a lifecycle hook
|
|
170
|
+
* @param {'pre:save'|'post:save'|'pre:update'|'post:update'|'pre:delete'|'post:delete'} event
|
|
171
|
+
* @param {Function} fn - Hook function. Pre-hooks receive (data) and can return modified data. Post-hooks receive (result).
|
|
172
|
+
*/
|
|
173
|
+
hook(event, fn) {
|
|
174
|
+
if (!this._hooks[event]) this._hooks[event] = [];
|
|
175
|
+
this._hooks[event].push(fn);
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Attach a SchemaMigration instance so documents are auto-migrated on read.
|
|
181
|
+
* @param {SchemaMigration} schemaMigration
|
|
182
|
+
* @returns {DataModel} this (for chaining)
|
|
183
|
+
*/
|
|
184
|
+
setMigration(schemaMigration) {
|
|
185
|
+
this.migration = schemaMigration;
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async _runHooks(event, data) {
|
|
190
|
+
const hooks = this._hooks[event] || [];
|
|
191
|
+
let result = data;
|
|
192
|
+
for (const fn of hooks) {
|
|
193
|
+
const out = await fn(result);
|
|
194
|
+
if (out !== undefined) result = out;
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Generate a unique ID for a new document
|
|
201
|
+
* @returns {string} Unique ID
|
|
202
|
+
*/
|
|
203
|
+
generateId() {
|
|
204
|
+
return `${this.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate data against schema
|
|
209
|
+
* @param {Object} data - Data to validate
|
|
210
|
+
* @throws {ValidationError} If validation fails
|
|
211
|
+
*/
|
|
212
|
+
validate(data) {
|
|
213
|
+
const result = SchemaValidator.validate(data, this.schema);
|
|
214
|
+
if (!result.valid) {
|
|
215
|
+
throw new ValidationError(`Validation failed: ${result.errors.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Prepare data for storage (add timestamps, ID, etc.)
|
|
221
|
+
* @param {Object} data - Raw data
|
|
222
|
+
* @param {boolean} isUpdate - Whether this is an update operation
|
|
223
|
+
* @returns {Object} Prepared data
|
|
224
|
+
*/
|
|
225
|
+
prepareData(data, isUpdate = false) {
|
|
226
|
+
const prepared = { ...data };
|
|
227
|
+
|
|
228
|
+
// Add auto-generated ID if needed
|
|
229
|
+
if (this.options.autoId && !prepared.id && !isUpdate) {
|
|
230
|
+
prepared.id = this.generateId();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Add timestamps
|
|
234
|
+
if (this.options.timestampFields.includes('updatedAt')) {
|
|
235
|
+
prepared.updatedAt = new Date().toISOString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (this.options.timestampFields.includes('createdAt') && !isUpdate) {
|
|
239
|
+
prepared.createdAt = new Date().toISOString();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (this.migration) {
|
|
243
|
+
prepared._schemaVersion = this.migration.currentVersion;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return prepared;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Save a document (create or update)
|
|
251
|
+
* @param {Object} data - Document data
|
|
252
|
+
* @returns {Promise<Object>} Saved document
|
|
253
|
+
*/
|
|
254
|
+
async save(data) {
|
|
255
|
+
this.logger.debug('Saving document', { data });
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
let prepared = this.prepareData(data);
|
|
259
|
+
prepared = await this._runHooks('pre:save', prepared);
|
|
260
|
+
this.validate(prepared);
|
|
261
|
+
|
|
262
|
+
const id = prepared.id;
|
|
263
|
+
if (!id) {
|
|
264
|
+
throw new ModelError('Document must have an ID');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await new Promise((resolve, reject) => {
|
|
268
|
+
this.modelNode.get(id).put(prepared, (ack) => {
|
|
269
|
+
if (ack.err) {
|
|
270
|
+
reject(new ModelError(`Failed to save document: ${ack.err}`));
|
|
271
|
+
} else {
|
|
272
|
+
resolve();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await this._runHooks('post:save', prepared);
|
|
278
|
+
this.logger.info('Document saved', { id, model: this.name });
|
|
279
|
+
return prepared;
|
|
280
|
+
|
|
281
|
+
} catch (error) {
|
|
282
|
+
this.logger.error('Failed to save document', { error: error.message, data });
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Find documents by query
|
|
289
|
+
* @param {Object} query - Query object
|
|
290
|
+
* @param {Object} options - Query options
|
|
291
|
+
* @returns {Promise<Array>} Found documents
|
|
292
|
+
*/
|
|
293
|
+
async find(query = {}, options = {}) {
|
|
294
|
+
this.logger.debug('Finding documents', { query, options });
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const { limit = 100, skip = 0 } = options;
|
|
298
|
+
const results = [];
|
|
299
|
+
|
|
300
|
+
await new Promise((resolve) => {
|
|
301
|
+
let count = 0;
|
|
302
|
+
let skipped = 0;
|
|
303
|
+
|
|
304
|
+
this.modelNode.map().on((data, key) => {
|
|
305
|
+
if (data && typeof data === 'object') {
|
|
306
|
+
// Simple query matching (can be enhanced with more complex queries)
|
|
307
|
+
if (this.matchesQuery(data, query)) {
|
|
308
|
+
if (skipped < skip) {
|
|
309
|
+
skipped++;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (count < limit) {
|
|
314
|
+
results.push({ ...data, _key: key });
|
|
315
|
+
count++;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (count >= limit) {
|
|
319
|
+
resolve();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Resolve after a timeout to handle cases with fewer results
|
|
326
|
+
setTimeout(resolve, 1000);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (this.migration) {
|
|
330
|
+
for (let i = 0; i < results.length; i++) {
|
|
331
|
+
if (this.migration.needsMigration(results[i])) {
|
|
332
|
+
results[i] = await this.migration.migrate(results[i]);
|
|
333
|
+
await this.save(results[i]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.logger.debug('Documents found', { count: results.length, query });
|
|
339
|
+
return results;
|
|
340
|
+
|
|
341
|
+
} catch (error) {
|
|
342
|
+
this.logger.error('Failed to find documents', { error: error.message, query });
|
|
343
|
+
throw new ModelError(`Failed to find documents: ${error.message}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Find a single document by query
|
|
349
|
+
* @param {Object} query - Query object
|
|
350
|
+
* @returns {Promise<Object|null>} Found document or null
|
|
351
|
+
*/
|
|
352
|
+
async findOne(query) {
|
|
353
|
+
const results = await this.find(query, { limit: 1 });
|
|
354
|
+
return results.length > 0 ? results[0] : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Find a document by ID
|
|
359
|
+
* @param {string} id - Document ID
|
|
360
|
+
* @returns {Promise<Object|null>} Found document or null
|
|
361
|
+
*/
|
|
362
|
+
async findById(id) {
|
|
363
|
+
this.logger.debug('Finding document by ID', { id });
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
let doc = await new Promise((resolve) => {
|
|
367
|
+
this.modelNode.get(id).once((data) => {
|
|
368
|
+
if (data && typeof data === 'object') {
|
|
369
|
+
resolve({ ...data, _key: id });
|
|
370
|
+
} else {
|
|
371
|
+
resolve(null);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (doc && this.migration && this.migration.needsMigration(doc)) {
|
|
377
|
+
doc = await this.migration.migrate(doc);
|
|
378
|
+
await this.save(doc);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return doc;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
this.logger.error('Failed to find document by ID', { error: error.message, id });
|
|
384
|
+
throw new ModelError(`Failed to find document: ${error.message}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Update a document by ID
|
|
390
|
+
* @param {string} id - Document ID
|
|
391
|
+
* @param {Object} updateData - Data to update
|
|
392
|
+
* @returns {Promise<Object>} Updated document
|
|
393
|
+
*/
|
|
394
|
+
async update(id, updateData) {
|
|
395
|
+
this.logger.debug('Updating document', { id, updateData });
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const existing = await this.findById(id);
|
|
399
|
+
if (!existing) {
|
|
400
|
+
throw new ModelError(`Document with ID ${id} not found`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const updated = { ...existing, ...updateData, id };
|
|
404
|
+
let prepared = this.prepareData(updated, true);
|
|
405
|
+
prepared = await this._runHooks('pre:update', prepared);
|
|
406
|
+
this.validate(prepared);
|
|
407
|
+
|
|
408
|
+
await new Promise((resolve, reject) => {
|
|
409
|
+
this.modelNode.get(id).put(prepared, (ack) => {
|
|
410
|
+
if (ack.err) {
|
|
411
|
+
reject(new ModelError(`Failed to update document: ${ack.err}`));
|
|
412
|
+
} else {
|
|
413
|
+
resolve();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await this._runHooks('post:update', prepared);
|
|
419
|
+
this.logger.info('Document updated', { id, model: this.name });
|
|
420
|
+
return prepared;
|
|
421
|
+
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.logger.error('Failed to update document', { error: error.message, id, updateData });
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Delete a document by ID
|
|
430
|
+
* @param {string} id - Document ID
|
|
431
|
+
* @returns {Promise<boolean>} Success status
|
|
432
|
+
*/
|
|
433
|
+
async delete(id) {
|
|
434
|
+
this.logger.debug('Deleting document', { id });
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
await this._runHooks('pre:delete', { id });
|
|
438
|
+
|
|
439
|
+
await new Promise((resolve, reject) => {
|
|
440
|
+
this.modelNode.get(id).put(null, (ack) => {
|
|
441
|
+
if (ack.err) {
|
|
442
|
+
reject(new ModelError(`Failed to delete document: ${ack.err}`));
|
|
443
|
+
} else {
|
|
444
|
+
resolve();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await this._runHooks('post:delete', { id });
|
|
450
|
+
this.logger.info('Document deleted', { id, model: this.name });
|
|
451
|
+
return true;
|
|
452
|
+
|
|
453
|
+
} catch (error) {
|
|
454
|
+
this.logger.error('Failed to delete document', { error: error.message, id });
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Count documents matching query
|
|
461
|
+
* @param {Object} query - Query object
|
|
462
|
+
* @returns {Promise<number>} Document count
|
|
463
|
+
*/
|
|
464
|
+
async count(query = {}) {
|
|
465
|
+
const results = await this.find(query, { limit: 10000 });
|
|
466
|
+
return results.length;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Insert multiple documents
|
|
471
|
+
* @param {Array<Object>} documents - Array of documents to insert
|
|
472
|
+
* @returns {Promise<Array<Object>>} Saved documents
|
|
473
|
+
*/
|
|
474
|
+
async insertMany(documents) {
|
|
475
|
+
if (!Array.isArray(documents)) {
|
|
476
|
+
throw new ModelError('insertMany requires an array of documents');
|
|
477
|
+
}
|
|
478
|
+
const results = [];
|
|
479
|
+
const errors = [];
|
|
480
|
+
for (const doc of documents) {
|
|
481
|
+
try {
|
|
482
|
+
results.push(await this.save(doc));
|
|
483
|
+
} catch (error) {
|
|
484
|
+
errors.push({ document: doc, error: error.message });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (errors.length > 0) {
|
|
488
|
+
this.logger.warn('Some documents failed to insert', {
|
|
489
|
+
total: documents.length, succeeded: results.length, failed: errors.length
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return { inserted: results, errors };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Update multiple documents matching a query
|
|
497
|
+
* @param {Object} query - Query to match documents
|
|
498
|
+
* @param {Object} updateData - Data to update on each matched document
|
|
499
|
+
* @returns {Promise<Object>} Update results
|
|
500
|
+
*/
|
|
501
|
+
async updateMany(query, updateData) {
|
|
502
|
+
const docs = await this.find(query, { limit: 100000 });
|
|
503
|
+
const results = [];
|
|
504
|
+
const errors = [];
|
|
505
|
+
for (const doc of docs) {
|
|
506
|
+
const id = doc.id || doc._key;
|
|
507
|
+
if (!id) continue;
|
|
508
|
+
try {
|
|
509
|
+
results.push(await this.update(id, updateData));
|
|
510
|
+
} catch (error) {
|
|
511
|
+
errors.push({ id, error: error.message });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
this.logger.info('updateMany completed', {
|
|
515
|
+
matched: docs.length, updated: results.length, failed: errors.length
|
|
516
|
+
});
|
|
517
|
+
return { matched: docs.length, updated: results, errors };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Delete multiple documents matching a query
|
|
522
|
+
* @param {Object} query - Query to match documents for deletion
|
|
523
|
+
* @returns {Promise<Object>} Deletion results
|
|
524
|
+
*/
|
|
525
|
+
async deleteMany(query) {
|
|
526
|
+
const docs = await this.find(query, { limit: 100000 });
|
|
527
|
+
let deleted = 0;
|
|
528
|
+
const errors = [];
|
|
529
|
+
for (const doc of docs) {
|
|
530
|
+
const id = doc.id || doc._key;
|
|
531
|
+
if (!id) continue;
|
|
532
|
+
try {
|
|
533
|
+
await this.delete(id);
|
|
534
|
+
deleted++;
|
|
535
|
+
} catch (error) {
|
|
536
|
+
errors.push({ id, error: error.message });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
this.logger.info('deleteMany completed', {
|
|
540
|
+
matched: docs.length, deleted, failed: errors.length
|
|
541
|
+
});
|
|
542
|
+
return { matched: docs.length, deleted, errors };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Migrate all documents in this model to the current schema version.
|
|
547
|
+
* @returns {Promise<Object>} Migration results { migrated, errors }
|
|
548
|
+
*/
|
|
549
|
+
async migrateAll() {
|
|
550
|
+
if (!this.migration) {
|
|
551
|
+
throw new ModelError('No migration configured for this model. Call setMigration() first.');
|
|
552
|
+
}
|
|
553
|
+
const docs = await this.find({}, { limit: 100000 });
|
|
554
|
+
let migrated = 0;
|
|
555
|
+
const errors = [];
|
|
556
|
+
for (const doc of docs) {
|
|
557
|
+
if (this.migration.needsMigration(doc)) {
|
|
558
|
+
try {
|
|
559
|
+
const updated = await this.migration.migrate(doc);
|
|
560
|
+
await this.save(updated);
|
|
561
|
+
migrated++;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
errors.push({ id: doc.id || doc._key, error: error.message });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
this.logger.info('migrateAll completed', {
|
|
568
|
+
total: docs.length, migrated, failed: errors.length
|
|
569
|
+
});
|
|
570
|
+
return { total: docs.length, migrated, errors };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Simple query matching (can be enhanced with MongoDB-like operators)
|
|
575
|
+
* @private
|
|
576
|
+
* @param {Object} document - Document to test
|
|
577
|
+
* @param {Object} query - Query object
|
|
578
|
+
* @returns {boolean} Whether document matches query
|
|
579
|
+
*/
|
|
580
|
+
matchesQuery(document, query) {
|
|
581
|
+
for (const [field, value] of Object.entries(query)) {
|
|
582
|
+
if (typeof value === 'object' && value !== null) {
|
|
583
|
+
// Handle operators like { age: { $gte: 18 } }
|
|
584
|
+
for (const [operator, operand] of Object.entries(value)) {
|
|
585
|
+
switch (operator) {
|
|
586
|
+
case '$gte':
|
|
587
|
+
if (!(document[field] >= operand)) return false;
|
|
588
|
+
break;
|
|
589
|
+
case '$lte':
|
|
590
|
+
if (!(document[field] <= operand)) return false;
|
|
591
|
+
break;
|
|
592
|
+
case '$gt':
|
|
593
|
+
if (!(document[field] > operand)) return false;
|
|
594
|
+
break;
|
|
595
|
+
case '$lt':
|
|
596
|
+
if (!(document[field] < operand)) return false;
|
|
597
|
+
break;
|
|
598
|
+
case '$ne':
|
|
599
|
+
if (document[field] === operand) return false;
|
|
600
|
+
break;
|
|
601
|
+
case '$in':
|
|
602
|
+
if (!Array.isArray(operand) || !operand.includes(document[field])) return false;
|
|
603
|
+
break;
|
|
604
|
+
case '$nin':
|
|
605
|
+
if (Array.isArray(operand) && operand.includes(document[field])) return false;
|
|
606
|
+
break;
|
|
607
|
+
case '$regex':
|
|
608
|
+
if (!new RegExp(operand).test(document[field])) return false;
|
|
609
|
+
break;
|
|
610
|
+
default:
|
|
611
|
+
// Unknown operator, treat as exact match
|
|
612
|
+
if (document[field] !== value) return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
// Exact match
|
|
617
|
+
if (document[field] !== value) return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Create an aggregation pipeline from query results.
|
|
625
|
+
* @param {Object} [query={}] - Optional query to filter documents before aggregation
|
|
626
|
+
* @returns {Promise<AggregationPipeline>} A new AggregationPipeline instance
|
|
627
|
+
*/
|
|
628
|
+
async aggregate(query = {}) {
|
|
629
|
+
const results = await this.find(query, { limit: 100000 });
|
|
630
|
+
return new AggregationPipeline(results);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* AggregationPipeline - Provides aggregate functions, grouping, time-series
|
|
636
|
+
* bucketing, and a fluent API for chaining operations on in-memory datasets.
|
|
637
|
+
*/
|
|
638
|
+
export class AggregationPipeline {
|
|
639
|
+
constructor(data) {
|
|
640
|
+
this.data = Array.isArray(data) ? data : [];
|
|
641
|
+
this.stages = [];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Filter/match stage - filter data before aggregation.
|
|
646
|
+
* Uses the same query operators as DataModel.matchesQuery.
|
|
647
|
+
* @param {Object} query - Query object with optional operators ($gte, $lte, etc.)
|
|
648
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
649
|
+
*/
|
|
650
|
+
match(query) {
|
|
651
|
+
this.stages.push({ type: 'match', query });
|
|
652
|
+
return this;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Group results by a field.
|
|
657
|
+
* @param {string} field - Field name to group by
|
|
658
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
659
|
+
*/
|
|
660
|
+
group(field) {
|
|
661
|
+
this.stages.push({ type: 'group', field });
|
|
662
|
+
return this;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Sum aggregate on a numeric field.
|
|
667
|
+
* @param {string} field - Field to sum
|
|
668
|
+
* @param {string} [alias] - Optional alias for the result key (default: `sum_<field>`)
|
|
669
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
670
|
+
*/
|
|
671
|
+
sum(field, alias) {
|
|
672
|
+
this.stages.push({ type: 'aggregate', op: 'sum', field, alias: alias || `sum_${field}` });
|
|
673
|
+
return this;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Average aggregate on a numeric field.
|
|
678
|
+
* @param {string} field - Field to average
|
|
679
|
+
* @param {string} [alias] - Optional alias for the result key (default: `avg_<field>`)
|
|
680
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
681
|
+
*/
|
|
682
|
+
avg(field, alias) {
|
|
683
|
+
this.stages.push({ type: 'aggregate', op: 'avg', field, alias: alias || `avg_${field}` });
|
|
684
|
+
return this;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Count aggregate.
|
|
689
|
+
* @param {string} [alias] - Optional alias for the result key (default: `count`)
|
|
690
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
691
|
+
*/
|
|
692
|
+
count(alias) {
|
|
693
|
+
this.stages.push({ type: 'aggregate', op: 'count', alias: alias || 'count' });
|
|
694
|
+
return this;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Min aggregate on a numeric field.
|
|
699
|
+
* @param {string} field - Field to find minimum of
|
|
700
|
+
* @param {string} [alias] - Optional alias for the result key (default: `min_<field>`)
|
|
701
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
702
|
+
*/
|
|
703
|
+
min(field, alias) {
|
|
704
|
+
this.stages.push({ type: 'aggregate', op: 'min', field, alias: alias || `min_${field}` });
|
|
705
|
+
return this;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Max aggregate on a numeric field.
|
|
710
|
+
* @param {string} field - Field to find maximum of
|
|
711
|
+
* @param {string} [alias] - Optional alias for the result key (default: `max_<field>`)
|
|
712
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
713
|
+
*/
|
|
714
|
+
max(field, alias) {
|
|
715
|
+
this.stages.push({ type: 'aggregate', op: 'max', field, alias: alias || `max_${field}` });
|
|
716
|
+
return this;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Time-series bucketing - groups data by time intervals.
|
|
721
|
+
* Acts like a group stage but groups by truncated timestamp buckets.
|
|
722
|
+
* @param {string} timestampField - Field containing a unix timestamp (seconds)
|
|
723
|
+
* @param {'minute'|'hour'|'day'|'week'|'month'} interval - Bucket interval
|
|
724
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
725
|
+
*/
|
|
726
|
+
bucket(timestampField, interval) {
|
|
727
|
+
this.stages.push({ type: 'bucket', timestampField, interval });
|
|
728
|
+
return this;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Sort results by a field.
|
|
733
|
+
* @param {string} field - Field to sort by
|
|
734
|
+
* @param {'asc'|'desc'} [order='asc'] - Sort order
|
|
735
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
736
|
+
*/
|
|
737
|
+
sort(field, order = 'asc') {
|
|
738
|
+
this.stages.push({ type: 'sort', field, order });
|
|
739
|
+
return this;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Limit the number of results returned.
|
|
744
|
+
* @param {number} n - Maximum number of results
|
|
745
|
+
* @returns {AggregationPipeline} this (for chaining)
|
|
746
|
+
*/
|
|
747
|
+
limit(n) {
|
|
748
|
+
this.stages.push({ type: 'limit', n });
|
|
749
|
+
return this;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Truncate a Date to the start of the given interval bucket.
|
|
754
|
+
* @private
|
|
755
|
+
* @param {Date} date
|
|
756
|
+
* @param {string} interval
|
|
757
|
+
* @returns {string} ISO string representing the bucket start
|
|
758
|
+
*/
|
|
759
|
+
_truncateDate(date, interval) {
|
|
760
|
+
const d = new Date(date);
|
|
761
|
+
switch (interval) {
|
|
762
|
+
case 'minute':
|
|
763
|
+
d.setUTCSeconds(0, 0);
|
|
764
|
+
break;
|
|
765
|
+
case 'hour':
|
|
766
|
+
d.setUTCMinutes(0, 0, 0);
|
|
767
|
+
break;
|
|
768
|
+
case 'day':
|
|
769
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
770
|
+
break;
|
|
771
|
+
case 'week': {
|
|
772
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
773
|
+
// Move back to Monday (ISO week start)
|
|
774
|
+
const day = d.getUTCDay();
|
|
775
|
+
const diff = (day === 0 ? 6 : day - 1);
|
|
776
|
+
d.setUTCDate(d.getUTCDate() - diff);
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case 'month':
|
|
780
|
+
d.setUTCDate(1);
|
|
781
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
782
|
+
break;
|
|
783
|
+
default:
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
return d.toISOString();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Simple query matching, mirroring DataModel.matchesQuery.
|
|
791
|
+
* @private
|
|
792
|
+
* @param {Object} document
|
|
793
|
+
* @param {Object} query
|
|
794
|
+
* @returns {boolean}
|
|
795
|
+
*/
|
|
796
|
+
_matchesQuery(document, query) {
|
|
797
|
+
for (const [field, value] of Object.entries(query)) {
|
|
798
|
+
if (typeof value === 'object' && value !== null) {
|
|
799
|
+
for (const [operator, operand] of Object.entries(value)) {
|
|
800
|
+
switch (operator) {
|
|
801
|
+
case '$gte':
|
|
802
|
+
if (!(document[field] >= operand)) return false;
|
|
803
|
+
break;
|
|
804
|
+
case '$lte':
|
|
805
|
+
if (!(document[field] <= operand)) return false;
|
|
806
|
+
break;
|
|
807
|
+
case '$gt':
|
|
808
|
+
if (!(document[field] > operand)) return false;
|
|
809
|
+
break;
|
|
810
|
+
case '$lt':
|
|
811
|
+
if (!(document[field] < operand)) return false;
|
|
812
|
+
break;
|
|
813
|
+
case '$ne':
|
|
814
|
+
if (document[field] === operand) return false;
|
|
815
|
+
break;
|
|
816
|
+
case '$in':
|
|
817
|
+
if (!Array.isArray(operand) || !operand.includes(document[field])) return false;
|
|
818
|
+
break;
|
|
819
|
+
case '$nin':
|
|
820
|
+
if (Array.isArray(operand) && operand.includes(document[field])) return false;
|
|
821
|
+
break;
|
|
822
|
+
case '$regex':
|
|
823
|
+
if (!new RegExp(operand).test(document[field])) return false;
|
|
824
|
+
break;
|
|
825
|
+
default:
|
|
826
|
+
if (document[field] !== value) return false;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
} else {
|
|
830
|
+
if (document[field] !== value) return false;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Compute a single aggregate value over an array of documents.
|
|
838
|
+
* @private
|
|
839
|
+
* @param {Array} docs - Array of documents
|
|
840
|
+
* @param {Object} stage - Aggregate stage descriptor
|
|
841
|
+
* @returns {number} Computed aggregate value
|
|
842
|
+
*/
|
|
843
|
+
_computeAggregate(docs, stage) {
|
|
844
|
+
const { op, field } = stage;
|
|
845
|
+
switch (op) {
|
|
846
|
+
case 'count':
|
|
847
|
+
return docs.length;
|
|
848
|
+
case 'sum': {
|
|
849
|
+
let total = 0;
|
|
850
|
+
for (const doc of docs) {
|
|
851
|
+
const v = Number(doc[field]);
|
|
852
|
+
if (!isNaN(v)) total += v;
|
|
853
|
+
}
|
|
854
|
+
return total;
|
|
855
|
+
}
|
|
856
|
+
case 'avg': {
|
|
857
|
+
let sum = 0;
|
|
858
|
+
let cnt = 0;
|
|
859
|
+
for (const doc of docs) {
|
|
860
|
+
const v = Number(doc[field]);
|
|
861
|
+
if (!isNaN(v)) {
|
|
862
|
+
sum += v;
|
|
863
|
+
cnt++;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return cnt > 0 ? sum / cnt : 0;
|
|
867
|
+
}
|
|
868
|
+
case 'min': {
|
|
869
|
+
let result = Infinity;
|
|
870
|
+
for (const doc of docs) {
|
|
871
|
+
const v = Number(doc[field]);
|
|
872
|
+
if (!isNaN(v) && v < result) result = v;
|
|
873
|
+
}
|
|
874
|
+
return result === Infinity ? 0 : result;
|
|
875
|
+
}
|
|
876
|
+
case 'max': {
|
|
877
|
+
let result = -Infinity;
|
|
878
|
+
for (const doc of docs) {
|
|
879
|
+
const v = Number(doc[field]);
|
|
880
|
+
if (!isNaN(v) && v > result) result = v;
|
|
881
|
+
}
|
|
882
|
+
return result === -Infinity ? 0 : result;
|
|
883
|
+
}
|
|
884
|
+
default:
|
|
885
|
+
return 0;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Execute the aggregation pipeline and return results.
|
|
891
|
+
*
|
|
892
|
+
* Processing order:
|
|
893
|
+
* 1. match stages filter the working dataset
|
|
894
|
+
* 2. group / bucket stages partition data into groups
|
|
895
|
+
* 3. aggregate stages compute values per group (or globally)
|
|
896
|
+
* 4. sort / limit are applied to the final result set
|
|
897
|
+
*
|
|
898
|
+
* @returns {Object|Array} Aggregation results
|
|
899
|
+
*/
|
|
900
|
+
execute() {
|
|
901
|
+
let workingData = [...this.data];
|
|
902
|
+
|
|
903
|
+
// Separate stages by type
|
|
904
|
+
const matchStages = [];
|
|
905
|
+
const aggregateStages = [];
|
|
906
|
+
let groupStage = null;
|
|
907
|
+
let bucketStage = null;
|
|
908
|
+
const postStages = []; // sort, limit
|
|
909
|
+
|
|
910
|
+
for (const stage of this.stages) {
|
|
911
|
+
switch (stage.type) {
|
|
912
|
+
case 'match':
|
|
913
|
+
matchStages.push(stage);
|
|
914
|
+
break;
|
|
915
|
+
case 'group':
|
|
916
|
+
groupStage = stage;
|
|
917
|
+
break;
|
|
918
|
+
case 'bucket':
|
|
919
|
+
bucketStage = stage;
|
|
920
|
+
break;
|
|
921
|
+
case 'aggregate':
|
|
922
|
+
aggregateStages.push(stage);
|
|
923
|
+
break;
|
|
924
|
+
case 'sort':
|
|
925
|
+
case 'limit':
|
|
926
|
+
postStages.push(stage);
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// 1. Apply match stages (filters)
|
|
932
|
+
for (const stage of matchStages) {
|
|
933
|
+
workingData = workingData.filter(doc => this._matchesQuery(doc, stage.query));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// 2. Determine grouping
|
|
937
|
+
let groups = null; // Map<string, Array>
|
|
938
|
+
|
|
939
|
+
if (bucketStage) {
|
|
940
|
+
// Bucket by time interval
|
|
941
|
+
groups = new Map();
|
|
942
|
+
const { timestampField, interval } = bucketStage;
|
|
943
|
+
for (const doc of workingData) {
|
|
944
|
+
const ts = doc[timestampField];
|
|
945
|
+
if (ts === undefined || ts === null) continue;
|
|
946
|
+
// Treat as unix seconds; convert to Date
|
|
947
|
+
const date = new Date(typeof ts === 'number' ? ts * 1000 : ts);
|
|
948
|
+
if (isNaN(date.getTime())) continue;
|
|
949
|
+
const bucketKey = this._truncateDate(date, interval);
|
|
950
|
+
if (!groups.has(bucketKey)) {
|
|
951
|
+
groups.set(bucketKey, []);
|
|
952
|
+
}
|
|
953
|
+
groups.get(bucketKey).push(doc);
|
|
954
|
+
}
|
|
955
|
+
} else if (groupStage) {
|
|
956
|
+
groups = new Map();
|
|
957
|
+
const { field } = groupStage;
|
|
958
|
+
for (const doc of workingData) {
|
|
959
|
+
const key = doc[field] !== undefined && doc[field] !== null
|
|
960
|
+
? String(doc[field])
|
|
961
|
+
: '__null__';
|
|
962
|
+
if (!groups.has(key)) {
|
|
963
|
+
groups.set(key, []);
|
|
964
|
+
}
|
|
965
|
+
groups.get(key).push(doc);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// 3. Compute aggregates
|
|
970
|
+
let results;
|
|
971
|
+
|
|
972
|
+
if (groups) {
|
|
973
|
+
// Grouped / bucketed results
|
|
974
|
+
results = [];
|
|
975
|
+
for (const [groupKey, docs] of groups) {
|
|
976
|
+
const row = { _id: groupKey === '__null__' ? null : groupKey };
|
|
977
|
+
for (const aggStage of aggregateStages) {
|
|
978
|
+
row[aggStage.alias] = this._computeAggregate(docs, aggStage);
|
|
979
|
+
}
|
|
980
|
+
results.push(row);
|
|
981
|
+
}
|
|
982
|
+
} else if (aggregateStages.length > 0) {
|
|
983
|
+
// Ungrouped global aggregation
|
|
984
|
+
const row = {};
|
|
985
|
+
for (const aggStage of aggregateStages) {
|
|
986
|
+
row[aggStage.alias] = this._computeAggregate(workingData, aggStage);
|
|
987
|
+
}
|
|
988
|
+
results = row;
|
|
989
|
+
} else {
|
|
990
|
+
// No aggregation or grouping -- return filtered data
|
|
991
|
+
results = workingData;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// 4. Apply post-processing (sort, limit) -- only meaningful for arrays
|
|
995
|
+
if (Array.isArray(results)) {
|
|
996
|
+
for (const stage of postStages) {
|
|
997
|
+
if (stage.type === 'sort') {
|
|
998
|
+
const { field, order } = stage;
|
|
999
|
+
const dir = order === 'desc' ? -1 : 1;
|
|
1000
|
+
results.sort((a, b) => {
|
|
1001
|
+
const va = a[field];
|
|
1002
|
+
const vb = b[field];
|
|
1003
|
+
if (va < vb) return -1 * dir;
|
|
1004
|
+
if (va > vb) return 1 * dir;
|
|
1005
|
+
return 0;
|
|
1006
|
+
});
|
|
1007
|
+
} else if (stage.type === 'limit') {
|
|
1008
|
+
results = results.slice(0, stage.n);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return results;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Main DataTransformer class
|
|
1019
|
+
*/
|
|
1020
|
+
export default class DataTransformer {
|
|
1021
|
+
constructor(gunInstance, options = {}) {
|
|
1022
|
+
this.gun = gunInstance;
|
|
1023
|
+
this.options = {
|
|
1024
|
+
logLevel: 'info',
|
|
1025
|
+
enableAutoTransform: true,
|
|
1026
|
+
...options
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
this.logger = new Logger({
|
|
1030
|
+
context: 'DataTransformer',
|
|
1031
|
+
level: this.options.logLevel
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
this.models = new Map();
|
|
1035
|
+
this.transformers = new Map();
|
|
1036
|
+
|
|
1037
|
+
this.logger.info('DataTransformer initialized', { options: this.options });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Register a data model
|
|
1042
|
+
* @param {string} name - Model name
|
|
1043
|
+
* @param {Object} schema - Model schema
|
|
1044
|
+
* @param {Object} options - Model options
|
|
1045
|
+
* @returns {DataModel} Created model
|
|
1046
|
+
*/
|
|
1047
|
+
model(name, schema, options = {}) {
|
|
1048
|
+
if (this.models.has(name)) {
|
|
1049
|
+
return this.models.get(name);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const model = new DataModel(name, schema, this.gun, this, options);
|
|
1053
|
+
this.models.set(name, model);
|
|
1054
|
+
|
|
1055
|
+
this.logger.info('Model registered', { name, schema });
|
|
1056
|
+
return model;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Register an event transformer
|
|
1061
|
+
* @param {string} eventName - Event name to transform
|
|
1062
|
+
* @param {string} modelName - Target model name
|
|
1063
|
+
* @param {Function} transformFn - Transformation function
|
|
1064
|
+
*/
|
|
1065
|
+
registerTransformer(eventName, modelName, transformFn) {
|
|
1066
|
+
if (!this.models.has(modelName)) {
|
|
1067
|
+
throw new ModelError(`Model '${modelName}' not found. Register the model first.`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
this.transformers.set(eventName, {
|
|
1071
|
+
modelName,
|
|
1072
|
+
transformFn
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
this.logger.info('Transformer registered', { eventName, modelName });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Transform and store an event
|
|
1080
|
+
* @param {Object} event - Blockchain event
|
|
1081
|
+
* @returns {Promise<Object|null>} Transformed data or null if no transformer
|
|
1082
|
+
*/
|
|
1083
|
+
async transformEvent(event) {
|
|
1084
|
+
if (!event || !event.eventName) {
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const transformer = this.transformers.get(event.eventName);
|
|
1089
|
+
if (!transformer) {
|
|
1090
|
+
this.logger.debug('No transformer found for event', { eventName: event.eventName });
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
try {
|
|
1095
|
+
const { modelName, transformFn } = transformer;
|
|
1096
|
+
const model = this.models.get(modelName);
|
|
1097
|
+
|
|
1098
|
+
// Transform the event data
|
|
1099
|
+
const transformedData = await transformFn(event);
|
|
1100
|
+
|
|
1101
|
+
// Save to the model
|
|
1102
|
+
const savedData = await model.save(transformedData);
|
|
1103
|
+
|
|
1104
|
+
this.logger.info('Event transformed and saved', {
|
|
1105
|
+
eventName: event.eventName,
|
|
1106
|
+
modelName,
|
|
1107
|
+
id: savedData.id
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
return savedData;
|
|
1111
|
+
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
this.logger.error('Failed to transform event', {
|
|
1114
|
+
error: error.message,
|
|
1115
|
+
eventName: event.eventName
|
|
1116
|
+
});
|
|
1117
|
+
throw error;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Get a registered model
|
|
1123
|
+
* @param {string} name - Model name
|
|
1124
|
+
* @returns {DataModel} Model instance
|
|
1125
|
+
*/
|
|
1126
|
+
getModel(name) {
|
|
1127
|
+
const model = this.models.get(name);
|
|
1128
|
+
if (!model) {
|
|
1129
|
+
throw new ModelError(`Model '${name}' not found`);
|
|
1130
|
+
}
|
|
1131
|
+
return model;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Get all registered models
|
|
1136
|
+
* @returns {Map<string, DataModel>} All models
|
|
1137
|
+
*/
|
|
1138
|
+
getModels() {
|
|
1139
|
+
return new Map(this.models);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Get transformation statistics
|
|
1144
|
+
* @returns {Object} Statistics
|
|
1145
|
+
*/
|
|
1146
|
+
getStats() {
|
|
1147
|
+
return {
|
|
1148
|
+
modelsCount: this.models.size,
|
|
1149
|
+
transformersCount: this.transformers.size,
|
|
1150
|
+
registeredModels: Array.from(this.models.keys()),
|
|
1151
|
+
registeredTransformers: Array.from(this.transformers.keys())
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Create an AggregationPipeline from raw data.
|
|
1157
|
+
* @param {Array} data - Array of documents to aggregate
|
|
1158
|
+
* @returns {AggregationPipeline} A new AggregationPipeline instance
|
|
1159
|
+
*/
|
|
1160
|
+
aggregate(data) {
|
|
1161
|
+
return new AggregationPipeline(data);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Create a SchemaMigration instance for a model.
|
|
1166
|
+
* @param {string} modelName - Name of the model
|
|
1167
|
+
* @returns {SchemaMigration} A new SchemaMigration instance
|
|
1168
|
+
*/
|
|
1169
|
+
migration(modelName) {
|
|
1170
|
+
return new SchemaMigration(modelName, this.gun);
|
|
1171
|
+
}
|
|
1172
|
+
}
|