bindra 2.0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +229 -0
  4. package/dist/core/Container.d.ts +88 -0
  5. package/dist/core/Container.d.ts.map +1 -0
  6. package/dist/core/Container.js +185 -0
  7. package/dist/core/Container.js.map +1 -0
  8. package/dist/core/DataSource.d.ts +272 -0
  9. package/dist/core/DataSource.d.ts.map +1 -0
  10. package/dist/core/DataSource.js +903 -0
  11. package/dist/core/DataSource.js.map +1 -0
  12. package/dist/core/Dispatcher.d.ts +6 -0
  13. package/dist/core/Dispatcher.d.ts.map +1 -0
  14. package/dist/core/Dispatcher.js +44 -0
  15. package/dist/core/Dispatcher.js.map +1 -0
  16. package/dist/core/EventEmitter.d.ts +11 -0
  17. package/dist/core/EventEmitter.d.ts.map +1 -0
  18. package/dist/core/EventEmitter.js +34 -0
  19. package/dist/core/EventEmitter.js.map +1 -0
  20. package/dist/core/MiddlewareManager.d.ts +47 -0
  21. package/dist/core/MiddlewareManager.d.ts.map +1 -0
  22. package/dist/core/MiddlewareManager.js +86 -0
  23. package/dist/core/MiddlewareManager.js.map +1 -0
  24. package/dist/core/Observable.d.ts +12 -0
  25. package/dist/core/Observable.d.ts.map +1 -0
  26. package/dist/core/Observable.js +43 -0
  27. package/dist/core/Observable.js.map +1 -0
  28. package/dist/core/errors.d.ts +124 -0
  29. package/dist/core/errors.d.ts.map +1 -0
  30. package/dist/core/errors.js +149 -0
  31. package/dist/core/errors.js.map +1 -0
  32. package/dist/core/validation.d.ts +100 -0
  33. package/dist/core/validation.d.ts.map +1 -0
  34. package/dist/core/validation.js +217 -0
  35. package/dist/core/validation.js.map +1 -0
  36. package/dist/examples.d.ts +52 -0
  37. package/dist/examples.d.ts.map +1 -0
  38. package/dist/examples.js +242 -0
  39. package/dist/examples.js.map +1 -0
  40. package/dist/index.d.ts +13 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +14 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/utils/performance.d.ts +49 -0
  45. package/dist/utils/performance.d.ts.map +1 -0
  46. package/dist/utils/performance.js +94 -0
  47. package/dist/utils/performance.js.map +1 -0
  48. package/package.json +64 -0
@@ -0,0 +1,903 @@
1
+ // bindra/core/DataSource.ts
2
+ import { EventEmitter } from './EventEmitter';
3
+ import { MiddlewareManager } from './MiddlewareManager';
4
+ import { reactive, createSignal } from './Observable';
5
+ import { NetworkError, PermissionError, ConfigurationError, ValidationError } from './errors';
6
+ import { Validator } from './validation';
7
+ /**
8
+ * DataSource - Type-safe reactive data management with CRUD operations
9
+ *
10
+ * @template T - The type of records managed by this DataSource
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * interface User {
15
+ * id: number;
16
+ * name: string;
17
+ * email: string;
18
+ * }
19
+ *
20
+ * const ds = new DataSource<User>({
21
+ * data: [{ id: 1, name: 'Alice', email: 'alice@example.com' }]
22
+ * });
23
+ * ```
24
+ */
25
+ export class DataSource extends EventEmitter {
26
+ constructor({ url = null, data = null, retry, fields, cache, pageSize, realtime, security } = {}) {
27
+ super();
28
+ this.url = url;
29
+ this.data = null;
30
+ this.fields = [];
31
+ this.fieldConfigs = fields || []; // NEW: Store field configs
32
+ this.validator = fields ? new Validator(fields) : null; // NEW: Create validator
33
+ this.permissions = {};
34
+ this.middleware = new MiddlewareManager();
35
+ this.retryConfig = {
36
+ maxRetries: retry?.maxRetries ?? 3,
37
+ retryDelay: retry?.retryDelay ?? 1000,
38
+ backoffMultiplier: retry?.backoffMultiplier ?? 2
39
+ };
40
+ // Cache configuration
41
+ this.cache = new Map();
42
+ this.cacheConfig = cache || { enabled: false };
43
+ // Real-time configuration
44
+ this.realtimeConfig = realtime || { enabled: false };
45
+ // Security configuration
46
+ this.securityConfig = security || {};
47
+ // Navigation signals
48
+ this.currentIndex = createSignal(0);
49
+ this.currentRecord = createSignal(null);
50
+ // Pagination signal
51
+ this.pagination = createSignal({
52
+ pageSize: pageSize || 10,
53
+ currentPage: 1
54
+ });
55
+ // Determine data mode
56
+ this.isLocal = Array.isArray(data);
57
+ if (this.isLocal) {
58
+ this._initLocal(data);
59
+ }
60
+ else if (url) {
61
+ this._initRemote(url);
62
+ }
63
+ else {
64
+ throw new ConfigurationError('Either "url" or "data" must be provided', { url, data });
65
+ }
66
+ // Initialize WebSocket if configured
67
+ if (this.realtimeConfig.enabled) {
68
+ this.connectWebSocket();
69
+ }
70
+ }
71
+ async _initRemote(url) {
72
+ const configUrl = `${url}/config`;
73
+ try {
74
+ const res = await this._fetchWithRetry(configUrl, { method: 'GET' });
75
+ if (!res.ok) {
76
+ throw new NetworkError(`Failed to load config from ${configUrl}`, res.status, await res.text());
77
+ }
78
+ const config = await res.json();
79
+ this.fields = config.fields || [];
80
+ this.permissions = config.permissions || {};
81
+ }
82
+ catch (error) {
83
+ this.emit('error', error);
84
+ throw error;
85
+ }
86
+ }
87
+ _initLocal(data) {
88
+ if (!Array.isArray(data) || !data.every(x => typeof x === 'object')) {
89
+ throw new ConfigurationError('Local data must be an array of objects', { data });
90
+ }
91
+ // Make reactive data store
92
+ this.data = reactive(structuredClone(data), () => {
93
+ this.emit('dataChanged', this.data);
94
+ });
95
+ this.fields = Object.keys(this.data[0] || {}).map(k => ({
96
+ name: k,
97
+ type: typeof this.data[0][k]
98
+ }));
99
+ this.permissions = { allowInsert: true, allowUpdate: true, allowDelete: true };
100
+ if (this.data.length > 0) {
101
+ this.currentRecord.set(this.data[0]);
102
+ }
103
+ }
104
+ // ------------------------------
105
+ // CRUD OPERATIONS
106
+ // ------------------------------
107
+ /**
108
+ * Create a new record
109
+ *
110
+ * @param record - The record to create (can be partial)
111
+ * @returns Promise resolving to the created record
112
+ *
113
+ * @example
114
+ * ```typescript
115
+ * const newUser = await ds.create({
116
+ * name: 'Alice',
117
+ * email: 'alice@example.com'
118
+ * });
119
+ * ```
120
+ */
121
+ async create(record) {
122
+ try {
123
+ // Check permissions
124
+ if (this.permissions.allowInsert === false) {
125
+ throw new PermissionError('Insert operation not permitted', 'create');
126
+ }
127
+ // Apply defaults
128
+ if (this.validator) {
129
+ record = this.validator.applyDefaults(record);
130
+ }
131
+ // Sanitize record for security
132
+ record = this.sanitizeRecord(record);
133
+ // Validate record
134
+ if (this.validator) {
135
+ const errors = await this.validator.validateRecord(record);
136
+ if (errors) {
137
+ throw new ValidationError('Validation failed', errors);
138
+ }
139
+ }
140
+ await this.middleware.runBefore('create', { record, ds: this });
141
+ let created;
142
+ if (this.isLocal) {
143
+ this.data.push(record);
144
+ created = record;
145
+ }
146
+ else {
147
+ const res = await this._fetchWithRetry(this.url, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify(record)
151
+ });
152
+ if (!res.ok) {
153
+ throw new NetworkError(`Create failed: ${res.statusText}`, res.status, await res.text());
154
+ }
155
+ created = await res.json();
156
+ }
157
+ this.emit('created', created);
158
+ this.emit('dataChanged', this.data);
159
+ await this.middleware.runAfter('create', { record: created, ds: this });
160
+ // Update cache
161
+ if (created.id !== undefined) {
162
+ this.setCache(created.id, created);
163
+ }
164
+ return created;
165
+ }
166
+ catch (error) {
167
+ this.emit('error', error);
168
+ throw error;
169
+ }
170
+ }
171
+ /**
172
+ * Update an existing record
173
+ *
174
+ * @param key - The key/id of the record to update
175
+ * @param changes - Partial changes to apply
176
+ * @returns Promise resolving to the updated record
177
+ */
178
+ async update(key, changes) {
179
+ try {
180
+ // Check permissions
181
+ if (this.permissions.allowUpdate === false) {
182
+ throw new PermissionError('Update operation not permitted', 'update');
183
+ }
184
+ // Validate changes
185
+ if (this.validator) {
186
+ const errors = await this.validator.validateRecord(changes);
187
+ if (errors) {
188
+ throw new ValidationError('Validation failed', errors);
189
+ }
190
+ }
191
+ // Sanitize changes for security
192
+ changes = this.sanitizeRecord(changes);
193
+ await this.middleware.runBefore('update', { key, changes, ds: this });
194
+ let updated;
195
+ if (this.isLocal) {
196
+ const idx = this.data.findIndex(r => r.id === key);
197
+ if (idx < 0)
198
+ throw new Error('Record not found.');
199
+ Object.assign(this.data[idx], changes);
200
+ updated = this.data[idx];
201
+ }
202
+ else {
203
+ const res = await this._fetchWithRetry(`${this.url}/${key}`, {
204
+ method: 'PUT',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify(changes)
207
+ });
208
+ if (!res.ok) {
209
+ throw new NetworkError(`Update failed: ${res.statusText}`, res.status, await res.text());
210
+ }
211
+ updated = await res.json();
212
+ }
213
+ this.emit('updated', updated);
214
+ this.emit('dataChanged', this.data);
215
+ await this.middleware.runAfter('update', { record: updated, ds: this });
216
+ return updated;
217
+ }
218
+ catch (error) {
219
+ this.emit('error', error);
220
+ throw error;
221
+ }
222
+ }
223
+ /**
224
+ * Delete a record
225
+ *
226
+ * @param key - The key/id of the record to delete
227
+ * @returns Promise resolving to the deleted record
228
+ */
229
+ async delete(key) {
230
+ try {
231
+ // Check permissions
232
+ if (this.permissions.allowDelete === false) {
233
+ throw new PermissionError('Delete operation not permitted', 'delete');
234
+ }
235
+ await this.middleware.runBefore('delete', { key, ds: this });
236
+ let deleted;
237
+ if (this.isLocal) {
238
+ const idx = this.data.findIndex(r => r.id === key);
239
+ if (idx < 0) {
240
+ throw new NetworkError(`Record with key ${key} not found`, 404);
241
+ }
242
+ deleted = this.data.splice(idx, 1)[0];
243
+ }
244
+ else {
245
+ const res = await this._fetchWithRetry(`${this.url}/${key}`, {
246
+ method: 'DELETE'
247
+ });
248
+ if (!res.ok) {
249
+ throw new NetworkError(`Delete failed: ${res.statusText}`, res.status, await res.text());
250
+ }
251
+ deleted = await res.json();
252
+ }
253
+ this.emit('deleted', deleted);
254
+ this.emit('dataChanged', this.data);
255
+ await this.middleware.runAfter('delete', { record: deleted, ds: this });
256
+ return deleted;
257
+ }
258
+ catch (error) {
259
+ this.emit('error', error);
260
+ throw error;
261
+ }
262
+ }
263
+ // ------------------------------
264
+ // BATCH OPERATIONS
265
+ // ------------------------------
266
+ /**
267
+ * Create multiple records in a single operation
268
+ *
269
+ * @param records - Array of partial records to create
270
+ * @returns Promise resolving to array of created records
271
+ */
272
+ async createBatch(records) {
273
+ try {
274
+ if (this.permissions.allowInsert === false) {
275
+ throw new PermissionError('Insert operation not permitted', 'createBatch');
276
+ }
277
+ // Validate all records
278
+ if (this.validator) {
279
+ for (const record of records) {
280
+ const errors = await this.validator.validateRecord(record);
281
+ if (errors) {
282
+ throw new ValidationError('Validation failed in batch create', errors);
283
+ }
284
+ }
285
+ }
286
+ if (this.isLocal) {
287
+ const created = records.map(r => ({ ...r }));
288
+ this.data.push(...created);
289
+ this.emit('dataChanged', this.data);
290
+ created.forEach(rec => this.emit('created', rec));
291
+ return created;
292
+ }
293
+ else {
294
+ const res = await this._fetchWithRetry(`${this.url}/batch`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify(records)
298
+ });
299
+ if (!res.ok) {
300
+ throw new NetworkError(`Batch create failed: ${res.statusText}`, res.status);
301
+ }
302
+ const created = await res.json();
303
+ this.emit('dataChanged', null);
304
+ created.forEach(rec => this.emit('created', rec));
305
+ return created;
306
+ }
307
+ }
308
+ catch (error) {
309
+ this.emit('error', error);
310
+ throw error;
311
+ }
312
+ }
313
+ /**
314
+ * Update multiple records in a single operation
315
+ *
316
+ * @param updates - Array of { key, changes } objects
317
+ * @returns Promise resolving to array of updated records
318
+ */
319
+ async updateBatch(updates) {
320
+ try {
321
+ if (this.permissions.allowUpdate === false) {
322
+ throw new PermissionError('Update operation not permitted', 'updateBatch');
323
+ }
324
+ // Validate all changes
325
+ if (this.validator) {
326
+ for (const { changes } of updates) {
327
+ const errors = await this.validator.validateRecord(changes);
328
+ if (errors) {
329
+ throw new ValidationError('Validation failed in batch update', errors);
330
+ }
331
+ }
332
+ }
333
+ if (this.isLocal) {
334
+ const updated = [];
335
+ for (const { key, changes } of updates) {
336
+ const idx = this.data.findIndex(r => r.id === key);
337
+ if (idx >= 0) {
338
+ Object.assign(this.data[idx], changes);
339
+ updated.push(this.data[idx]);
340
+ this.emit('updated', this.data[idx]);
341
+ }
342
+ }
343
+ this.emit('dataChanged', this.data);
344
+ return updated;
345
+ }
346
+ else {
347
+ const res = await this._fetchWithRetry(`${this.url}/batch`, {
348
+ method: 'PUT',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify(updates)
351
+ });
352
+ if (!res.ok) {
353
+ throw new NetworkError(`Batch update failed: ${res.statusText}`, res.status);
354
+ }
355
+ const updated = await res.json();
356
+ this.emit('dataChanged', null);
357
+ updated.forEach(rec => this.emit('updated', rec));
358
+ return updated;
359
+ }
360
+ }
361
+ catch (error) {
362
+ this.emit('error', error);
363
+ throw error;
364
+ }
365
+ }
366
+ /**
367
+ * Delete multiple records in a single operation
368
+ *
369
+ * @param keys - Array of keys/ids to delete
370
+ * @returns Promise resolving to array of deleted records
371
+ */
372
+ async deleteBatch(keys) {
373
+ try {
374
+ if (this.permissions.allowDelete === false) {
375
+ throw new PermissionError('Delete operation not permitted', 'deleteBatch');
376
+ }
377
+ if (this.isLocal) {
378
+ const deleted = [];
379
+ for (const key of keys) {
380
+ const idx = this.data.findIndex(r => r.id === key);
381
+ if (idx >= 0) {
382
+ const [record] = this.data.splice(idx, 1);
383
+ deleted.push(record);
384
+ this.emit('deleted', record);
385
+ }
386
+ }
387
+ this.emit('dataChanged', this.data);
388
+ return deleted;
389
+ }
390
+ else {
391
+ const res = await this._fetchWithRetry(`${this.url}/batch`, {
392
+ method: 'DELETE',
393
+ headers: { 'Content-Type': 'application/json' },
394
+ body: JSON.stringify({ keys })
395
+ });
396
+ if (!res.ok) {
397
+ throw new NetworkError(`Batch delete failed: ${res.statusText}`, res.status);
398
+ }
399
+ const deleted = await res.json();
400
+ this.emit('dataChanged', null);
401
+ deleted.forEach(rec => this.emit('deleted', rec));
402
+ return deleted;
403
+ }
404
+ }
405
+ catch (error) {
406
+ this.emit('error', error);
407
+ throw error;
408
+ }
409
+ }
410
+ /**
411
+ * Optimistic update - immediately update UI, rollback on error
412
+ *
413
+ * @param key - The key/id of the record to update
414
+ * @param changes - Partial changes to apply
415
+ * @returns Promise resolving to the updated record
416
+ */
417
+ async updateOptimistic(key, changes) {
418
+ let original = null;
419
+ let idx = -1;
420
+ // Store original state
421
+ if (this.isLocal) {
422
+ idx = this.data.findIndex(r => r.id === key);
423
+ if (idx >= 0) {
424
+ original = { ...this.data[idx] };
425
+ // Apply changes immediately
426
+ Object.assign(this.data[idx], changes);
427
+ this.emit('updated', this.data[idx]);
428
+ this.emit('dataChanged', this.data);
429
+ }
430
+ }
431
+ try {
432
+ // Make actual update request
433
+ const updated = await this.update(key, changes);
434
+ // Update cache
435
+ this.setCache(key, updated);
436
+ return updated;
437
+ }
438
+ catch (error) {
439
+ // Rollback on error
440
+ if (original && this.isLocal && idx >= 0) {
441
+ this.data[idx] = original;
442
+ this.emit('updated', original);
443
+ this.emit('dataChanged', this.data);
444
+ }
445
+ throw error;
446
+ }
447
+ }
448
+ // ------------------------------
449
+ // QUERY API
450
+ // ------------------------------
451
+ /**
452
+ * Query records with filtering, sorting, and limiting
453
+ *
454
+ * @param options - Query options
455
+ * @returns Promise resolving to array of matching records
456
+ */
457
+ async query({ filter = null, sort = null, limit = null } = {}) {
458
+ try {
459
+ if (this.isLocal) {
460
+ let result = [...this.data];
461
+ if (typeof filter === 'function') {
462
+ result = result.filter(filter);
463
+ }
464
+ if (typeof sort === 'string') {
465
+ result.sort((a, b) => (a[sort] > b[sort] ? 1 : -1));
466
+ }
467
+ if (typeof sort === 'function') {
468
+ result.sort(sort);
469
+ }
470
+ if (typeof limit === 'number') {
471
+ result = result.slice(0, limit);
472
+ }
473
+ return result;
474
+ }
475
+ else {
476
+ const params = new URLSearchParams();
477
+ if (filter)
478
+ params.set('filter', JSON.stringify(filter));
479
+ if (sort)
480
+ params.set('sort', typeof sort === 'string' ? sort : JSON.stringify(sort));
481
+ if (limit)
482
+ params.set('limit', limit.toString());
483
+ const res = await this._fetchWithRetry(`${this.url}?${params.toString()}`, {
484
+ method: 'GET'
485
+ });
486
+ if (!res.ok) {
487
+ throw new NetworkError(`Query failed: ${res.statusText}`, res.status, await res.text());
488
+ }
489
+ return await res.json();
490
+ }
491
+ }
492
+ catch (error) {
493
+ this.emit('error', error);
494
+ throw error;
495
+ }
496
+ }
497
+ /**
498
+ * Find a record by its key (local mode only)
499
+ *
500
+ * @param key - The key/id to search for
501
+ * @returns The found record or undefined
502
+ */
503
+ findByKey(key) {
504
+ if (!this.isLocal)
505
+ throw new Error('findByKey only supported in local mode.');
506
+ return this.data.find(r => r.id === key);
507
+ }
508
+ // ------------------------------
509
+ // INTERNAL HELPERS
510
+ // ------------------------------
511
+ /**
512
+ * Fetch with automatic retry and exponential backoff
513
+ *
514
+ * @param url - The URL to fetch
515
+ * @param options - Fetch options
516
+ * @returns Promise resolving to Response
517
+ */
518
+ async _fetchWithRetry(url, options) {
519
+ const { maxRetries, retryDelay, backoffMultiplier } = this.retryConfig;
520
+ let lastError;
521
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
522
+ try {
523
+ // Merge security headers with provided headers
524
+ const securityHeaders = this.getSecurityHeaders();
525
+ const headers = {
526
+ ...securityHeaders,
527
+ ...(options.headers || {})
528
+ };
529
+ return await fetch(url, { ...options, headers });
530
+ }
531
+ catch (error) {
532
+ lastError = error;
533
+ // Don't retry on last attempt
534
+ if (attempt === maxRetries) {
535
+ break;
536
+ }
537
+ // Calculate delay with exponential backoff
538
+ const delay = retryDelay * Math.pow(backoffMultiplier, attempt);
539
+ // Emit retry event
540
+ this.emit('retrying', {
541
+ attempt: attempt + 1,
542
+ maxRetries,
543
+ delay,
544
+ error: lastError
545
+ });
546
+ // Wait before retrying
547
+ await new Promise(resolve => setTimeout(resolve, delay));
548
+ }
549
+ }
550
+ // All retries exhausted
551
+ throw new NetworkError(`Network request failed after ${maxRetries} retries: ${lastError?.message || 'Unknown error'}`, undefined, { originalError: lastError, url, options });
552
+ }
553
+ // ------------------------------
554
+ // NAVIGATION
555
+ // ------------------------------
556
+ next() {
557
+ const idx = this.currentIndex.get();
558
+ if (idx < this.data.length - 1) {
559
+ const newIndex = idx + 1;
560
+ this.currentIndex.set(newIndex);
561
+ this.currentRecord.set(this.data[newIndex]);
562
+ this.emit('navigated', this.currentRecord.get());
563
+ }
564
+ }
565
+ prev() {
566
+ const idx = this.currentIndex.get();
567
+ if (idx > 0) {
568
+ const newIndex = idx - 1;
569
+ this.currentIndex.set(newIndex);
570
+ this.currentRecord.set(this.data[newIndex]);
571
+ this.emit('navigated', this.currentRecord.get());
572
+ }
573
+ }
574
+ goto(index) {
575
+ if (index < 0 || index >= this.data.length)
576
+ return;
577
+ this.currentIndex.set(index);
578
+ this.currentRecord.set(this.data[index]);
579
+ this.emit('navigated', this.currentRecord.get());
580
+ }
581
+ // ------------------------------
582
+ // CACHE MANAGEMENT
583
+ // ------------------------------
584
+ getCacheKey(key) {
585
+ return `record_${key}`;
586
+ }
587
+ /**
588
+ * Get a record from cache (internal use)
589
+ * @internal
590
+ */
591
+ getFromCache(key) {
592
+ if (!this.cacheConfig.enabled)
593
+ return null;
594
+ const cacheKey = this.getCacheKey(key);
595
+ const cached = this.cache.get(cacheKey);
596
+ if (!cached)
597
+ return null;
598
+ // Check TTL
599
+ if (this.cacheConfig.ttl) {
600
+ const age = Date.now() - cached.timestamp;
601
+ if (age > this.cacheConfig.ttl) {
602
+ this.cache.delete(cacheKey);
603
+ return null;
604
+ }
605
+ }
606
+ return cached.data;
607
+ }
608
+ setCache(key, data) {
609
+ if (!this.cacheConfig.enabled)
610
+ return;
611
+ const cacheKey = this.getCacheKey(key);
612
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
613
+ // Evict oldest if max size reached
614
+ if (this.cacheConfig.maxSize && this.cache.size > this.cacheConfig.maxSize) {
615
+ const firstKey = this.cache.keys().next().value;
616
+ if (firstKey !== undefined) {
617
+ this.cache.delete(firstKey);
618
+ }
619
+ }
620
+ }
621
+ /**
622
+ * Clear all cached records
623
+ */
624
+ clearCache() {
625
+ this.cache.clear();
626
+ }
627
+ /**
628
+ * Invalidate a specific cached record
629
+ */
630
+ invalidateCache(key) {
631
+ const cacheKey = this.getCacheKey(key);
632
+ this.cache.delete(cacheKey);
633
+ }
634
+ // ------------------------------
635
+ // PAGINATION METHODS
636
+ // ------------------------------
637
+ /**
638
+ * Load a specific page of data
639
+ *
640
+ * @param page - Page number (1-indexed)
641
+ * @returns Array of records for the requested page
642
+ */
643
+ async loadPage(page) {
644
+ const paginationState = this.pagination.get();
645
+ if (this.isLocal) {
646
+ // Local pagination
647
+ const start = (page - 1) * paginationState.pageSize;
648
+ const end = start + paginationState.pageSize;
649
+ const pageData = this.data.slice(start, end);
650
+ this.pagination.set({
651
+ ...paginationState,
652
+ currentPage: page,
653
+ totalRecords: this.data.length,
654
+ totalPages: Math.ceil(this.data.length / paginationState.pageSize)
655
+ });
656
+ return pageData;
657
+ }
658
+ else {
659
+ // Remote pagination
660
+ const params = new URLSearchParams({
661
+ page: page.toString(),
662
+ pageSize: paginationState.pageSize.toString()
663
+ });
664
+ const res = await this._fetchWithRetry(`${this.url}?${params}`, {
665
+ method: 'GET'
666
+ });
667
+ if (!res.ok) {
668
+ throw new NetworkError(`Failed to load page: ${res.statusText}`, res.status);
669
+ }
670
+ const data = await res.json();
671
+ // Expect: { data: T[], total: number }
672
+ this.pagination.set({
673
+ ...paginationState,
674
+ currentPage: page,
675
+ totalRecords: data.total,
676
+ totalPages: Math.ceil(data.total / paginationState.pageSize)
677
+ });
678
+ return data.data;
679
+ }
680
+ }
681
+ /**
682
+ * Load the next page
683
+ */
684
+ async nextPage() {
685
+ const { currentPage, totalPages } = this.pagination.get();
686
+ if (totalPages && currentPage >= totalPages) {
687
+ return [];
688
+ }
689
+ return this.loadPage(currentPage + 1);
690
+ }
691
+ /**
692
+ * Load the previous page
693
+ */
694
+ async prevPage() {
695
+ const { currentPage } = this.pagination.get();
696
+ if (currentPage <= 1) {
697
+ return [];
698
+ }
699
+ return this.loadPage(currentPage - 1);
700
+ }
701
+ /**
702
+ * Set page size
703
+ */
704
+ setPageSize(size) {
705
+ const current = this.pagination.get();
706
+ this.pagination.set({
707
+ ...current,
708
+ pageSize: size,
709
+ currentPage: 1 // Reset to first page
710
+ });
711
+ }
712
+ // ------------------------------
713
+ // REAL-TIME WEBSOCKET SUPPORT
714
+ // ------------------------------
715
+ /**
716
+ * Connect to WebSocket for real-time updates
717
+ */
718
+ connectWebSocket() {
719
+ if (!this.realtimeConfig.enabled || !this.realtimeConfig.url) {
720
+ return;
721
+ }
722
+ try {
723
+ this.ws = new WebSocket(this.realtimeConfig.url);
724
+ this.ws.onopen = () => {
725
+ this.emit('realtime:connected');
726
+ };
727
+ this.ws.onmessage = (event) => {
728
+ try {
729
+ const message = JSON.parse(event.data);
730
+ this.handleRealtimeUpdate(message);
731
+ }
732
+ catch (error) {
733
+ this.emit('error', new Error('Failed to parse WebSocket message'));
734
+ }
735
+ };
736
+ this.ws.onclose = () => {
737
+ this.emit('realtime:disconnected');
738
+ if (this.realtimeConfig.reconnect) {
739
+ setTimeout(() => this.connectWebSocket(), this.realtimeConfig.reconnectInterval || 5000);
740
+ }
741
+ };
742
+ this.ws.onerror = () => {
743
+ this.emit('error', new Error('WebSocket error'));
744
+ };
745
+ }
746
+ catch (error) {
747
+ this.emit('error', error);
748
+ }
749
+ }
750
+ /**
751
+ * Handle incoming WebSocket messages
752
+ */
753
+ handleRealtimeUpdate(message) {
754
+ const { type, data } = message;
755
+ switch (type) {
756
+ case 'created':
757
+ if (this.isLocal && data) {
758
+ this.data.push(data);
759
+ }
760
+ this.emit('created', data);
761
+ this.emit('dataChanged', this.data);
762
+ break;
763
+ case 'updated':
764
+ if (this.isLocal && data) {
765
+ const idx = this.data.findIndex(r => r.id === data.id);
766
+ if (idx >= 0) {
767
+ Object.assign(this.data[idx], data);
768
+ this.emit('updated', data);
769
+ this.emit('dataChanged', this.data);
770
+ }
771
+ }
772
+ else {
773
+ this.emit('updated', data);
774
+ this.emit('dataChanged', null);
775
+ }
776
+ break;
777
+ case 'deleted':
778
+ if (this.isLocal && data) {
779
+ const idx = this.data.findIndex(r => r.id === data.id);
780
+ if (idx >= 0) {
781
+ this.data.splice(idx, 1);
782
+ this.emit('deleted', data);
783
+ this.emit('dataChanged', this.data);
784
+ }
785
+ }
786
+ else {
787
+ this.emit('deleted', data);
788
+ this.emit('dataChanged', null);
789
+ }
790
+ break;
791
+ default:
792
+ this.emit('realtime:message', message);
793
+ }
794
+ }
795
+ /**
796
+ * Disconnect WebSocket
797
+ */
798
+ disconnectWebSocket() {
799
+ if (this.ws) {
800
+ this.ws.close();
801
+ this.ws = undefined;
802
+ }
803
+ }
804
+ /**
805
+ * Send a message through WebSocket
806
+ */
807
+ sendWebSocketMessage(message) {
808
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
809
+ this.ws.send(JSON.stringify(message));
810
+ }
811
+ else {
812
+ throw new Error('WebSocket is not connected');
813
+ }
814
+ }
815
+ // ------------------------------
816
+ // SECURITY HELPERS
817
+ // ------------------------------
818
+ /**
819
+ * Sanitize string to prevent XSS attacks
820
+ */
821
+ sanitize(value) {
822
+ return value
823
+ .replace(/&/g, '&amp;')
824
+ .replace(/</g, '&lt;')
825
+ .replace(/>/g, '&gt;')
826
+ .replace(/"/g, '&quot;')
827
+ .replace(/'/g, '&#x27;')
828
+ .replace(/\//g, '&#x2F;');
829
+ }
830
+ /**
831
+ * Sanitize a record's fields that are marked for sanitization
832
+ */
833
+ sanitizeRecord(record) {
834
+ if (!this.securityConfig.sanitizeFields || this.securityConfig.sanitizeFields.length === 0) {
835
+ return record;
836
+ }
837
+ const sanitized = { ...record };
838
+ for (const field of this.securityConfig.sanitizeFields) {
839
+ const value = sanitized[field];
840
+ if (typeof value === 'string') {
841
+ sanitized[field] = this.sanitize(value);
842
+ }
843
+ }
844
+ return sanitized;
845
+ }
846
+ /**
847
+ * Get headers with CSRF token if configured
848
+ */
849
+ getSecurityHeaders() {
850
+ const headers = {
851
+ 'Content-Type': 'application/json'
852
+ };
853
+ if (this.securityConfig.csrfToken && this.securityConfig.csrfHeader) {
854
+ headers[this.securityConfig.csrfHeader] = this.securityConfig.csrfToken;
855
+ }
856
+ return headers;
857
+ }
858
+ // ------------------------------
859
+ // VALIDATION HELPERS
860
+ // ------------------------------
861
+ /**
862
+ * Validate a single field
863
+ *
864
+ * @param fieldName - Name of the field to validate
865
+ * @param value - Value to validate
866
+ * @param record - Optional full record for context
867
+ * @returns Error message if invalid, null if valid
868
+ */
869
+ async validateField(fieldName, value, record) {
870
+ if (!this.validator) {
871
+ return null;
872
+ }
873
+ return this.validator.validateField(fieldName, value, record);
874
+ }
875
+ /**
876
+ * Validate a complete record
877
+ *
878
+ * @param record - Record to validate
879
+ * @returns Object with field errors, or null if valid
880
+ */
881
+ async validateRecord(record) {
882
+ if (!this.validator) {
883
+ return null;
884
+ }
885
+ return this.validator.validateRecord(record);
886
+ }
887
+ /**
888
+ * Get field configuration by name
889
+ *
890
+ * @param name - Field name
891
+ * @returns Field configuration or undefined
892
+ */
893
+ getFieldConfig(name) {
894
+ return this.validator?.getField(name);
895
+ }
896
+ /**
897
+ * Check if validation is enabled
898
+ */
899
+ hasValidation() {
900
+ return this.validator !== null;
901
+ }
902
+ }
903
+ //# sourceMappingURL=DataSource.js.map