@telestack/db-sdk 1.0.1

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/dist/index.js ADDED
@@ -0,0 +1,848 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Transaction = exports.DocumentSnapshot = exports.WriteBatch = exports.DocumentReference = exports.CollectionReference = exports.Query = exports.TelestackClient = exports.TelestackError = exports.IndexedDBPersistence = void 0;
4
+ const centrifuge_1 = require("centrifuge");
5
+ /**
6
+ * IndexedDB implementation of PersistenceEngine
7
+ */
8
+ class IndexedDBPersistence {
9
+ db = null;
10
+ dbName = 'TelestackDB_Cache';
11
+ async init() {
12
+ return new Promise((resolve, reject) => {
13
+ const request = indexedDB.open(this.dbName, 1);
14
+ request.onupgradeneeded = () => {
15
+ const db = request.result;
16
+ if (!db.objectStoreNames.contains('documents'))
17
+ db.createObjectStore('documents', { keyPath: 'path' });
18
+ if (!db.objectStoreNames.contains('queue'))
19
+ db.createObjectStore('queue', { keyPath: 'id', autoIncrement: true });
20
+ };
21
+ request.onsuccess = () => {
22
+ this.db = request.result;
23
+ resolve();
24
+ };
25
+ request.onerror = () => reject(request.error);
26
+ });
27
+ }
28
+ async get(table, path) {
29
+ if (!this.db)
30
+ await this.init();
31
+ return new Promise((resolve, reject) => {
32
+ const tx = this.db.transaction(table, 'readonly');
33
+ const store = tx.objectStore(table);
34
+ const req = store.get(path);
35
+ req.onsuccess = () => resolve(req.result);
36
+ req.onerror = () => reject(req.error);
37
+ });
38
+ }
39
+ async put(table, id, data) {
40
+ if (!this.db)
41
+ await this.init();
42
+ return new Promise((resolve, reject) => {
43
+ const tx = this.db.transaction(table, 'readwrite');
44
+ const store = tx.objectStore(table);
45
+ const req = store.put({ ...data, path: id });
46
+ req.onsuccess = () => resolve();
47
+ req.onerror = () => reject(req.error);
48
+ });
49
+ }
50
+ async delete(table, id) {
51
+ if (!this.db)
52
+ await this.init();
53
+ return new Promise((resolve, reject) => {
54
+ const tx = this.db.transaction(table, 'readwrite');
55
+ const store = tx.objectStore(table);
56
+ const req = store.delete(id);
57
+ req.onsuccess = () => resolve();
58
+ req.onerror = () => reject(req.error);
59
+ });
60
+ }
61
+ async getAll(table) {
62
+ if (!this.db)
63
+ await this.init();
64
+ return new Promise((resolve, reject) => {
65
+ const tx = this.db.transaction(table, 'readonly');
66
+ const store = tx.objectStore(table);
67
+ const req = store.getAll();
68
+ req.onsuccess = () => resolve(req.result);
69
+ req.onerror = () => reject(req.error);
70
+ });
71
+ }
72
+ async clear(table) {
73
+ if (!this.db)
74
+ await this.init();
75
+ return new Promise((resolve, reject) => {
76
+ const tx = this.db.transaction(table, 'readwrite');
77
+ tx.objectStore(table).clear();
78
+ tx.oncomplete = () => resolve();
79
+ tx.onerror = () => reject(tx.error);
80
+ });
81
+ }
82
+ }
83
+ exports.IndexedDBPersistence = IndexedDBPersistence;
84
+ /** Custom Error class for Telestack operations */
85
+ class TelestackError extends Error {
86
+ message;
87
+ code;
88
+ constructor(message, code) {
89
+ super(message);
90
+ this.message = message;
91
+ this.code = code;
92
+ this.name = 'TelestackError';
93
+ }
94
+ }
95
+ exports.TelestackError = TelestackError;
96
+ /**
97
+ * Main Telestack Client
98
+ */
99
+ class TelestackClient {
100
+ config;
101
+ centrifuge = null;
102
+ isProcessingQueue = false;
103
+ workspaceId;
104
+ lastVersion = 0;
105
+ token = null;
106
+ persistence = null;
107
+ constructor(config) {
108
+ this.config = config;
109
+ this.config.endpoint = config.endpoint || 'https://telestack-realtime-db.codeforgebyaravinth.workers.dev';
110
+ this.config.centrifugoUrl = config.centrifugoUrl || 'wss://telestack-centrifugo.onrender.com/connection/websocket';
111
+ this.workspaceId = config.workspaceId || 'default';
112
+ if (config.enablePersistence) {
113
+ this.persistence = new IndexedDBPersistence();
114
+ }
115
+ if (config.centrifugoUrl) {
116
+ this.centrifuge = new centrifuge_1.Centrifuge(config.centrifugoUrl, {
117
+ getToken: () => this.getToken()
118
+ });
119
+ this.centrifuge.on('connected', () => {
120
+ console.log("Telestack: Connected to real-time via JWT. Syncing...");
121
+ this.sync();
122
+ this.processQueue(); // Process queue immediately on reconnect
123
+ });
124
+ this.centrifuge.on('error', (ctx) => {
125
+ console.error("Telestack: Centrifuge error:", ctx);
126
+ });
127
+ this.centrifuge.on('disconnected', (ctx) => {
128
+ console.warn("Telestack: Centrifuge disconnected:", ctx);
129
+ });
130
+ this.centrifuge.connect();
131
+ }
132
+ this.startBackgroundWorkers();
133
+ }
134
+ /** Ensure we have a valid JWT token */
135
+ async getToken() {
136
+ if (this.token)
137
+ return this.token;
138
+ const res = await fetch(`${this.config.endpoint}/documents/auth/token`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ userId: this.config.userId })
142
+ });
143
+ if (!res.ok)
144
+ throw new TelestackError("Auth failed: " + await res.text());
145
+ const { token } = await res.json();
146
+ this.token = token;
147
+ return token;
148
+ }
149
+ /** Helper for authenticated fetch */
150
+ async authFetch(url, options = {}) {
151
+ const token = await this.getToken();
152
+ const headers = {
153
+ ...options.headers,
154
+ 'Authorization': `Bearer ${token}`,
155
+ 'workspaceId': this.workspaceId
156
+ };
157
+ return fetch(url.toString(), { ...options, headers });
158
+ }
159
+ /** Incremental sync to fetch changes since last version */
160
+ async sync() {
161
+ try {
162
+ const url = new URL(`${this.config.endpoint}/documents/sync`);
163
+ url.searchParams.set('workspaceId', this.workspaceId);
164
+ url.searchParams.set('since', this.lastVersion.toString());
165
+ const res = await this.authFetch(url);
166
+ if (!res.ok)
167
+ throw new TelestackError(await res.text());
168
+ const { changes } = await res.json();
169
+ if (changes && changes.length > 0) {
170
+ this.lastVersion = Math.max(this.lastVersion, ...changes.map((c) => c.version));
171
+ return changes;
172
+ }
173
+ return [];
174
+ }
175
+ catch (e) {
176
+ console.error("Telestack: Sync failed", e);
177
+ return [];
178
+ }
179
+ }
180
+ /** Get a reference to a collection */
181
+ collection(path) {
182
+ return new CollectionReference(this, path);
183
+ }
184
+ /** Get a reference to a document */
185
+ doc(path) {
186
+ const parts = path.split('/');
187
+ if (parts.length % 2 !== 0) {
188
+ throw new TelestackError("Invalid document path. Must have an even number of segments.");
189
+ }
190
+ const collectionPath = parts.slice(0, -1).join('/');
191
+ const id = parts[parts.length - 1];
192
+ return new DocumentReference(this, collectionPath, id);
193
+ }
194
+ /** Get a new write batch */
195
+ batch() {
196
+ return new WriteBatch(this);
197
+ }
198
+ /** Get presence information for a channel */
199
+ async getPresence(channel) {
200
+ if (!this.centrifuge)
201
+ throw new TelestackError("Realtime not connected");
202
+ return this.centrifuge.presence(channel);
203
+ }
204
+ /** Get presence stats for a channel */
205
+ async getPresenceStats(channel) {
206
+ if (!this.centrifuge)
207
+ throw new TelestackError("Realtime not connected");
208
+ return this.centrifuge.presenceStats(channel);
209
+ }
210
+ /** Run an atomic transaction with automatic retries (OCC) */
211
+ async runTransaction(updateFunction, maxRetries = 10) {
212
+ let retries = 0;
213
+ while (retries < maxRetries) {
214
+ const transaction = new Transaction(this);
215
+ try {
216
+ const result = await updateFunction(transaction);
217
+ await transaction.commit();
218
+ return result;
219
+ }
220
+ catch (e) {
221
+ if (e.message.includes("Conflict") || (e.status === 409)) {
222
+ retries++;
223
+ if (retries >= maxRetries)
224
+ break;
225
+ // Full Jitter Backoff
226
+ const baseDelay = Math.min(100 * Math.pow(1.5, retries), 2000);
227
+ const delay = Math.random() * baseDelay;
228
+ console.warn(`Telestack: Transaction conflict, retrying (${retries}/${maxRetries}) in ${Math.round(delay)}ms...`);
229
+ await new Promise(resolve => setTimeout(resolve, delay));
230
+ continue;
231
+ }
232
+ throw e;
233
+ }
234
+ }
235
+ throw new TelestackError(`Transaction failed after ${maxRetries} retries due to persistent conflicts.`);
236
+ }
237
+ getCentrifuge() { return this.centrifuge; }
238
+ getPersistence() { return this.persistence; }
239
+ getLastVersion() { return this.lastVersion; }
240
+ updateLastVersion(v) { this.lastVersion = Math.max(this.lastVersion, v); }
241
+ /** Background process to sync offline writes */
242
+ async processQueue() {
243
+ if (!this.persistence || this.isProcessingQueue)
244
+ return;
245
+ const queue = await this.persistence.getAll('queue');
246
+ if (queue.length === 0)
247
+ return;
248
+ this.isProcessingQueue = true;
249
+ console.log(`Telestack: Processing ${queue.length} queued offline writes...`);
250
+ try {
251
+ for (const item of queue) {
252
+ try {
253
+ const docRef = this.doc(item.path);
254
+ if (item.type === 'SET') {
255
+ await docRef.set(item.data);
256
+ }
257
+ else if (item.type === 'UPDATE') {
258
+ await docRef.update(item.data);
259
+ }
260
+ else if (item.type === 'DELETE') {
261
+ await docRef.delete();
262
+ }
263
+ // If success, remove from queue and update doc version (to clear pending write flag)
264
+ await this.persistence.delete('queue', item.path);
265
+ if (item.type !== 'DELETE') {
266
+ const snap = await docRef.getSnapshot();
267
+ await this.persistence.put('documents', item.path, { data: snap.data(), version: snap.version });
268
+ }
269
+ console.log(`✓ Synced ${item.path}`);
270
+ }
271
+ catch (e) {
272
+ console.warn(`✗ Failed to sync ${item.path}, will retry later.`, e.message);
273
+ break; // Stop processing queue if one fails (likely network still down)
274
+ }
275
+ }
276
+ }
277
+ finally {
278
+ this.isProcessingQueue = false;
279
+ }
280
+ }
281
+ /** Start periodic sync and queue processing */
282
+ startBackgroundWorkers() {
283
+ setInterval(() => this.sync(), 30000); // Incremental sync every 30s
284
+ setInterval(() => this.processQueue(), 5000); // Process queue every 5s
285
+ }
286
+ }
287
+ exports.TelestackClient = TelestackClient;
288
+ /**
289
+ * Query class for collection-level queries
290
+ */
291
+ class Query {
292
+ client;
293
+ path;
294
+ filters = [];
295
+ limitCount;
296
+ orderByField;
297
+ orderDirection = 'asc';
298
+ docsCache = [];
299
+ debounceTimer = null;
300
+ constructor(client, path) {
301
+ this.client = client;
302
+ this.path = path;
303
+ }
304
+ /** Add a filter to the query */
305
+ where(field, op, value) {
306
+ this.filters.push({ field, op, value });
307
+ return this;
308
+ }
309
+ /** Limit the number of documents */
310
+ limit(n) {
311
+ this.limitCount = n;
312
+ return this;
313
+ }
314
+ /** Order the documents */
315
+ orderBy(field, direction = 'asc') {
316
+ this.orderByField = field;
317
+ this.orderDirection = direction;
318
+ return this;
319
+ }
320
+ /** Convert filters to SQL-like where clause for backend query */
321
+ buildWhereClause() {
322
+ if (this.filters.length === 0)
323
+ return '1=1';
324
+ return this.filters.map(f => {
325
+ let val = f.value;
326
+ let sqlOp = f.op === '==' ? '=' : f.op;
327
+ if (f.op === 'array-contains') {
328
+ return `EXISTS (SELECT 1 FROM json_each(json_extract(data, '$.${f.field}')) WHERE json_each.value = ${typeof val === 'string' ? `'${val}'` : val})`;
329
+ }
330
+ const fieldExpr = `json_extract(data, '$.${f.field}')`;
331
+ if (f.op === 'in') {
332
+ const list = val.map(v => typeof v === 'string' ? `'${v}'` : v).join(', ');
333
+ return `${fieldExpr} IN (${list})`;
334
+ }
335
+ const formattedVal = typeof val === 'string' ? `'${val}'` : val;
336
+ return `${fieldExpr} ${sqlOp} ${formattedVal}`;
337
+ }).join(' AND ');
338
+ }
339
+ /** Fetch documents matching the query */
340
+ async get() {
341
+ const persistence = this.client.getPersistence();
342
+ const url = new URL(`${this.client.config.endpoint}/documents/query`);
343
+ url.searchParams.set('workspaceId', this.client.workspaceId);
344
+ url.searchParams.set('filters', JSON.stringify(this.filters));
345
+ if (this.orderByField) {
346
+ url.searchParams.set('orderByField', this.orderByField);
347
+ url.searchParams.set('orderDirection', this.orderDirection);
348
+ }
349
+ if (this.limitCount)
350
+ url.searchParams.set('limit', this.limitCount.toString());
351
+ try {
352
+ const res = await this.client.authFetch(url.toString());
353
+ if (!res.ok)
354
+ throw new TelestackError(await res.text());
355
+ const data = await res.json();
356
+ const docs = data.map((d) => ({ id: d.id, ...d.data }));
357
+ // Cache results locally if persistence enabled
358
+ if (persistence) {
359
+ for (const d of data) {
360
+ await persistence.put('documents', `${this.path}/${d.id}`, { data: d.data, version: d.version });
361
+ }
362
+ }
363
+ return docs;
364
+ }
365
+ catch (e) {
366
+ if (persistence) {
367
+ console.warn(`Telestack: Offline query for ${this.path}, serving from cache.`);
368
+ const allDocs = await persistence.getAll('documents');
369
+ // Filter docs that belong to this collection and match filters
370
+ const filtered = allDocs
371
+ .filter(d => d.path.startsWith(this.path) && this.matches(d.data))
372
+ .map(d => ({ id: d.path.split('/').pop(), ...d.data, metadata: { fromCache: true, hasPendingWrites: true } }));
373
+ // Sort locally if needed
374
+ if (this.orderByField) {
375
+ filtered.sort((a, b) => {
376
+ const valA = a[this.orderByField];
377
+ const valB = b[this.orderByField];
378
+ if (this.orderDirection === 'asc')
379
+ return valA > valB ? 1 : -1;
380
+ return valA < valB ? 1 : -1;
381
+ });
382
+ }
383
+ if (this.limitCount)
384
+ return filtered.slice(0, this.limitCount);
385
+ return filtered;
386
+ }
387
+ throw e;
388
+ }
389
+ }
390
+ /** Check if a document matches the local filters */
391
+ matches(doc) {
392
+ for (const filter of this.filters) {
393
+ const docValue = doc[filter.field];
394
+ switch (filter.op) {
395
+ case '==':
396
+ if (docValue !== filter.value)
397
+ return false;
398
+ break;
399
+ case '!=':
400
+ if (docValue === filter.value)
401
+ return false;
402
+ break;
403
+ case '>':
404
+ if (!(docValue > filter.value))
405
+ return false;
406
+ break;
407
+ case '<':
408
+ if (!(docValue < filter.value))
409
+ return false;
410
+ break;
411
+ case '>=':
412
+ if (!(docValue >= filter.value))
413
+ return false;
414
+ break;
415
+ case '<=':
416
+ if (!(docValue <= filter.value))
417
+ return false;
418
+ break;
419
+ case 'in':
420
+ if (!Array.isArray(filter.value) || !filter.value.includes(docValue))
421
+ return false;
422
+ break;
423
+ case 'array-contains':
424
+ if (!Array.isArray(docValue) || !docValue.includes(filter.value))
425
+ return false;
426
+ break;
427
+ }
428
+ }
429
+ return true;
430
+ }
431
+ /** Subscribe to realtime updates for this query */
432
+ onSnapshot(callback) {
433
+ const centrifuge = this.client.getCentrifuge();
434
+ if (!centrifuge)
435
+ return () => { };
436
+ const channel = `collection:${this.path.replace(/\//g, '_')}`;
437
+ const sub = centrifuge.newSubscription(channel);
438
+ const debouncedCallback = () => {
439
+ if (this.debounceTimer)
440
+ clearTimeout(this.debounceTimer);
441
+ this.debounceTimer = setTimeout(() => callback([...this.docsCache]), 50);
442
+ };
443
+ sub.on('publication', async (ctx) => {
444
+ const event = ctx.data;
445
+ console.log(`📡 Received publication on ${channel}:`, event.type, event.id || (event.doc && event.doc.id));
446
+ if (event.version)
447
+ this.client.updateLastVersion(event.version);
448
+ let changed = false;
449
+ const docId = event.id || (event.doc && event.doc.id);
450
+ if (event.type === 'CREATED' || event.type === 'SET') {
451
+ if (this.matches(event.doc.data)) {
452
+ if (this.limitCount || this.orderByField) {
453
+ const docs = await this.get();
454
+ this.docsCache = docs;
455
+ callback(docs);
456
+ }
457
+ else {
458
+ this.docsCache = [...this.docsCache.filter(d => d.id !== docId), { id: docId, ...event.doc.data }];
459
+ changed = true;
460
+ }
461
+ }
462
+ }
463
+ else if (event.type === 'UPDATED') {
464
+ const docData = event.doc ? event.doc.data : (event.patch ? event.patch : {});
465
+ const matches = this.matches(docData);
466
+ if (this.limitCount || this.orderByField) {
467
+ const docs = await this.get();
468
+ this.docsCache = docs;
469
+ callback(docs);
470
+ }
471
+ else if (matches) {
472
+ this.docsCache = this.docsCache.map(d => d.id === docId ? { ...d, ...docData } : d);
473
+ changed = true;
474
+ }
475
+ else {
476
+ this.docsCache = this.docsCache.filter(d => d.id !== docId);
477
+ changed = true;
478
+ }
479
+ }
480
+ else if (event.type === 'DELETED') {
481
+ this.docsCache = this.docsCache.filter(d => d.id !== docId);
482
+ changed = true;
483
+ }
484
+ if (changed && !(this.limitCount || this.orderByField)) {
485
+ debouncedCallback();
486
+ }
487
+ });
488
+ sub.subscribe();
489
+ // Initial fetch
490
+ this.get().then(docs => {
491
+ this.docsCache = docs;
492
+ callback(docs);
493
+ });
494
+ return () => {
495
+ sub.unsubscribe();
496
+ sub.removeAllListeners();
497
+ if (this.debounceTimer)
498
+ clearTimeout(this.debounceTimer);
499
+ };
500
+ }
501
+ /** Subscribe to presence events (Join/Leave) for this collection */
502
+ onPresence(callback) {
503
+ const centrifuge = this.client.getCentrifuge();
504
+ if (!centrifuge)
505
+ return () => { };
506
+ const channel = `collection:${this.path.replace(/\//g, '_')}`;
507
+ const sub = centrifuge.newSubscription(channel);
508
+ sub.on('join', (ctx) => {
509
+ callback({ action: 'join', user: ctx.info.user, clientId: ctx.info.client });
510
+ });
511
+ sub.on('leave', (ctx) => {
512
+ callback({ action: 'leave', user: ctx.info.user, clientId: ctx.info.client });
513
+ });
514
+ sub.subscribe();
515
+ return () => {
516
+ sub.unsubscribe();
517
+ sub.removeAllListeners();
518
+ };
519
+ }
520
+ }
521
+ exports.Query = Query;
522
+ /**
523
+ * CollectionReference extends Query with add() and doc() methods
524
+ */
525
+ class CollectionReference extends Query {
526
+ doc(id) {
527
+ return new DocumentReference(this.client, this.path, id);
528
+ }
529
+ /** Add a new document to this collection */
530
+ async add(data) {
531
+ const collectionName = this.path.split('/').pop();
532
+ const parentPath = this.path.includes('/') ? this.path.split('/').slice(0, -1).join('/') : undefined;
533
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/${collectionName}`, {
534
+ method: 'POST',
535
+ body: JSON.stringify({
536
+ data,
537
+ userId: this.client.config.userId,
538
+ workspaceId: this.client.workspaceId,
539
+ parentPath
540
+ })
541
+ });
542
+ if (!res.ok)
543
+ throw new TelestackError(await res.text());
544
+ const result = await res.json();
545
+ if (result.version)
546
+ this.client.updateLastVersion(result.version);
547
+ return result;
548
+ }
549
+ }
550
+ exports.CollectionReference = CollectionReference;
551
+ /**
552
+ * DocumentReference allows CRUD operations and realtime subscription on a single document
553
+ */
554
+ class DocumentReference {
555
+ client;
556
+ collectionPath;
557
+ id;
558
+ constructor(client, collectionPath, id) {
559
+ this.client = client;
560
+ this.collectionPath = collectionPath;
561
+ this.id = id;
562
+ }
563
+ get path() { return `${this.collectionPath}/${this.id}`; }
564
+ /** Access a nested collection */
565
+ collection(name) {
566
+ return new CollectionReference(this.client, `${this.path}/${name}`);
567
+ }
568
+ /** Fetch this document */
569
+ async get() {
570
+ const snap = await this.getSnapshot();
571
+ return snap.exists() ? snap.data() : null;
572
+ }
573
+ /** Fetch document snapshot (includes version for transactions) */
574
+ async asyncGetSnapshot() {
575
+ const persistence = this.client.getPersistence();
576
+ const collectionName = this.collectionPath.split('/').pop();
577
+ try {
578
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/${collectionName}/${this.id}`);
579
+ if (res.status === 404)
580
+ return new DocumentSnapshot(this.id, this.path, null, 0);
581
+ if (!res.ok)
582
+ throw new TelestackError(await res.text());
583
+ const data = await res.json();
584
+ if (data.version) {
585
+ this.client.updateLastVersion(data.version);
586
+ if (persistence)
587
+ await persistence.put('documents', this.path, { data: data.data, version: data.version });
588
+ }
589
+ return new DocumentSnapshot(this.id, this.path, data.data, data.version);
590
+ }
591
+ catch (e) {
592
+ if (persistence) {
593
+ const cached = await persistence.get('documents', this.path);
594
+ if (cached)
595
+ return new DocumentSnapshot(this.id, this.path, cached.data, cached.version, { fromCache: true, hasPendingWrites: true });
596
+ }
597
+ throw e;
598
+ }
599
+ }
600
+ /** Compatibility alias for existing codebase */
601
+ async getSnapshot() {
602
+ return this.asyncGetSnapshot();
603
+ }
604
+ /** Replace or create this document */
605
+ async set(data) {
606
+ const persistence = this.client.getPersistence();
607
+ const collectionName = this.collectionPath.split('/').pop();
608
+ const parentPath = this.collectionPath.includes('/') ? this.collectionPath.split('/').slice(0, -1).join('/') : undefined;
609
+ // Optimistic UI: Update local cache immediately
610
+ if (persistence)
611
+ await persistence.put('documents', this.path, { data, version: -1 }); // -1 indicates local-only for now
612
+ try {
613
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/${collectionName}/${this.id}`, {
614
+ method: 'PUT',
615
+ body: JSON.stringify({
616
+ data,
617
+ userId: this.client.config.userId,
618
+ workspaceId: this.client.workspaceId,
619
+ parentPath
620
+ })
621
+ });
622
+ if (!res.ok)
623
+ throw new TelestackError(await res.text());
624
+ const result = await res.json();
625
+ if (result.version) {
626
+ this.client.updateLastVersion(result.version);
627
+ if (persistence)
628
+ await persistence.put('documents', this.path, { data, version: result.version });
629
+ }
630
+ return result;
631
+ }
632
+ catch (e) {
633
+ if (persistence) {
634
+ console.warn(`Telestack: Offline, queueing SET for ${this.path}`);
635
+ await persistence.put('queue', this.path, { type: 'SET', path: this.path, data, collectionName, parentPath });
636
+ return { version: -1 };
637
+ }
638
+ throw e;
639
+ }
640
+ }
641
+ /** Update part of this document */
642
+ async update(data) {
643
+ const persistence = this.client.getPersistence();
644
+ const collectionName = this.collectionPath.split('/').pop();
645
+ const parentPath = this.collectionPath.includes('/') ? this.collectionPath.split('/').slice(0, -1).join('/') : undefined;
646
+ // Optimistic UI: Apply patch to local cache
647
+ if (persistence) {
648
+ const cached = await persistence.get('documents', this.path);
649
+ const newData = cached ? { ...cached.data, ...data } : data;
650
+ await persistence.put('documents', this.path, { data: newData, version: cached ? cached.version : -1 });
651
+ }
652
+ try {
653
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/${collectionName}/${this.id}`, {
654
+ method: 'PATCH',
655
+ body: JSON.stringify({
656
+ data,
657
+ userId: this.client.config.userId,
658
+ workspaceId: this.client.workspaceId,
659
+ parentPath
660
+ })
661
+ });
662
+ if (!res.ok)
663
+ throw new TelestackError(await res.text());
664
+ const result = await res.json();
665
+ if (result.version) {
666
+ this.client.updateLastVersion(result.version);
667
+ if (persistence) {
668
+ const snap = await this.getSnapshot();
669
+ await persistence.put('documents', this.path, { data: snap.data(), version: result.version });
670
+ }
671
+ }
672
+ return result;
673
+ }
674
+ catch (e) {
675
+ if (persistence) {
676
+ console.warn(`Telestack: Offline, queueing UPDATE for ${this.path}`);
677
+ await persistence.put('queue', this.path, { type: 'UPDATE', path: this.path, data, collectionName, parentPath });
678
+ return { version: -1 };
679
+ }
680
+ throw e;
681
+ }
682
+ }
683
+ /** Delete this document */
684
+ async delete() {
685
+ const persistence = this.client.getPersistence();
686
+ const collectionName = this.collectionPath.split('/').pop();
687
+ // Optimistic UI: Remove from local cache
688
+ if (persistence)
689
+ await persistence.delete('documents', this.path);
690
+ try {
691
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/${collectionName}/${this.id}`, {
692
+ method: 'DELETE'
693
+ });
694
+ if (!res.ok)
695
+ throw new TelestackError(await res.text());
696
+ }
697
+ catch (e) {
698
+ if (persistence) {
699
+ console.warn(`Telestack: Offline, queueing DELETE for ${this.path}`);
700
+ await persistence.put('queue', this.path, { type: 'DELETE', path: this.path, collectionName });
701
+ return;
702
+ }
703
+ throw e;
704
+ }
705
+ }
706
+ /** Subscribe to realtime changes on this document */
707
+ onSnapshot(callback) {
708
+ const centrifuge = this.client.getCentrifuge();
709
+ if (!centrifuge)
710
+ return () => { };
711
+ // Production refinement: Subscribe to path namespace
712
+ const channel = `path:${this.path.replace(/\//g, '_')}`;
713
+ const sub = centrifuge.newSubscription(channel);
714
+ sub.on('publication', (ctx) => {
715
+ const event = ctx.data;
716
+ if (event.version)
717
+ this.client.updateLastVersion(event.version);
718
+ if (event.type === 'DELETED')
719
+ callback(null);
720
+ else {
721
+ this.get().then(callback);
722
+ }
723
+ });
724
+ sub.on('subscribed', (ctx) => console.log(`Telestack: Subscribed to document channel ${channel}`, ctx));
725
+ sub.on('error', (ctx) => console.error(`Telestack: Document subscription error on ${channel}`, ctx));
726
+ sub.subscribe();
727
+ this.get().then(callback);
728
+ return () => {
729
+ sub.unsubscribe();
730
+ sub.removeAllListeners();
731
+ };
732
+ }
733
+ }
734
+ exports.DocumentReference = DocumentReference;
735
+ /**
736
+ * WriteBatch allows multiple write operations to be committed atomically
737
+ */
738
+ class WriteBatch {
739
+ client;
740
+ operations = [];
741
+ constructor(client) {
742
+ this.client = client;
743
+ }
744
+ set(docRef, data) {
745
+ this.operations.push({ type: 'SET', path: docRef.path, data });
746
+ return this;
747
+ }
748
+ update(docRef, data) {
749
+ this.operations.push({ type: 'UPDATE', path: docRef.path, data });
750
+ return this;
751
+ }
752
+ delete(docRef) {
753
+ this.operations.push({ type: 'DELETE', path: docRef.path });
754
+ return this;
755
+ }
756
+ async commit() {
757
+ if (this.operations.length === 0)
758
+ return;
759
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/batch`, {
760
+ method: 'POST',
761
+ body: JSON.stringify({ operations: this.operations })
762
+ });
763
+ if (!res.ok)
764
+ throw new TelestackError(await res.text());
765
+ const result = await res.json();
766
+ if (result.version)
767
+ this.client.updateLastVersion(result.version);
768
+ }
769
+ }
770
+ exports.WriteBatch = WriteBatch;
771
+ /**
772
+ * DocumentSnapshot contains document data and its database version
773
+ */
774
+ class DocumentSnapshot {
775
+ id;
776
+ path;
777
+ _data;
778
+ version;
779
+ metadata;
780
+ constructor(id, path, _data, version, metadata = { fromCache: false, hasPendingWrites: false }) {
781
+ this.id = id;
782
+ this.path = path;
783
+ this._data = _data;
784
+ this.version = version;
785
+ this.metadata = metadata;
786
+ }
787
+ exists() { return this._data !== null; }
788
+ data() { return this._data; }
789
+ }
790
+ exports.DocumentSnapshot = DocumentSnapshot;
791
+ /**
792
+ * Transaction allows read-modify-write operations with OCC
793
+ */
794
+ class Transaction {
795
+ client;
796
+ operations = [];
797
+ constructor(client) {
798
+ this.client = client;
799
+ }
800
+ async get(docRef) {
801
+ return docRef.getSnapshot();
802
+ }
803
+ set(docRef, data, snapshot) {
804
+ this.operations.push({
805
+ type: 'SET',
806
+ path: docRef.path,
807
+ data,
808
+ expectedVersion: snapshot?.version
809
+ });
810
+ return this;
811
+ }
812
+ update(docRef, data, snapshot) {
813
+ this.operations.push({
814
+ type: 'UPDATE',
815
+ path: docRef.path,
816
+ data,
817
+ expectedVersion: snapshot?.version
818
+ });
819
+ return this;
820
+ }
821
+ delete(docRef, snapshot) {
822
+ this.operations.push({
823
+ type: 'DELETE',
824
+ path: docRef.path,
825
+ expectedVersion: snapshot?.version
826
+ });
827
+ return this;
828
+ }
829
+ async commit() {
830
+ if (this.operations.length === 0)
831
+ return;
832
+ const res = await this.client.authFetch(`${this.client.config.endpoint}/documents/batch`, {
833
+ method: 'POST',
834
+ body: JSON.stringify({ operations: this.operations })
835
+ });
836
+ if (res.status === 409) {
837
+ const err = new TelestackError("Conflict");
838
+ err.status = 409;
839
+ throw err;
840
+ }
841
+ if (!res.ok)
842
+ throw new TelestackError(await res.text());
843
+ const result = await res.json();
844
+ if (result.version)
845
+ this.client.updateLastVersion(result.version);
846
+ }
847
+ }
848
+ exports.Transaction = Transaction;