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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/core/Container.d.ts +88 -0
- package/dist/core/Container.d.ts.map +1 -0
- package/dist/core/Container.js +185 -0
- package/dist/core/Container.js.map +1 -0
- package/dist/core/DataSource.d.ts +272 -0
- package/dist/core/DataSource.d.ts.map +1 -0
- package/dist/core/DataSource.js +903 -0
- package/dist/core/DataSource.js.map +1 -0
- package/dist/core/Dispatcher.d.ts +6 -0
- package/dist/core/Dispatcher.d.ts.map +1 -0
- package/dist/core/Dispatcher.js +44 -0
- package/dist/core/Dispatcher.js.map +1 -0
- package/dist/core/EventEmitter.d.ts +11 -0
- package/dist/core/EventEmitter.d.ts.map +1 -0
- package/dist/core/EventEmitter.js +34 -0
- package/dist/core/EventEmitter.js.map +1 -0
- package/dist/core/MiddlewareManager.d.ts +47 -0
- package/dist/core/MiddlewareManager.d.ts.map +1 -0
- package/dist/core/MiddlewareManager.js +86 -0
- package/dist/core/MiddlewareManager.js.map +1 -0
- package/dist/core/Observable.d.ts +12 -0
- package/dist/core/Observable.d.ts.map +1 -0
- package/dist/core/Observable.js +43 -0
- package/dist/core/Observable.js.map +1 -0
- package/dist/core/errors.d.ts +124 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +149 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/validation.d.ts +100 -0
- package/dist/core/validation.d.ts.map +1 -0
- package/dist/core/validation.js +217 -0
- package/dist/core/validation.js.map +1 -0
- package/dist/examples.d.ts +52 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +242 -0
- package/dist/examples.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/performance.d.ts +49 -0
- package/dist/utils/performance.d.ts.map +1 -0
- package/dist/utils/performance.js +94 -0
- package/dist/utils/performance.js.map +1 -0
- 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, '&')
|
|
824
|
+
.replace(/</g, '<')
|
|
825
|
+
.replace(/>/g, '>')
|
|
826
|
+
.replace(/"/g, '"')
|
|
827
|
+
.replace(/'/g, ''')
|
|
828
|
+
.replace(/\//g, '/');
|
|
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
|