@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.
@@ -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
+ }