nexabase-console 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -110,8 +110,10 @@ const listenLive = () => {
110
110
  };
111
111
  ```
112
112
 
113
- #### Menggunakan Atomic Write Batch (Sangat Disarankan untuk >100 Data/Hari)
114
- Jika Anda harus memasukkan data dalam jumlah besar (misal: 500 resi pengiriman), hindari memanggil `setDoc` satu per satu di dalam _looping_ (looping 500 kali = 500 koneksi = berpotensi menimbulkan lag di sisi klien yang sedang berlangganan `onSnapshot`). Gunakan fitur `writeBatch` yang membundel seluruh operasi Anda dalam satu pengiriman jaringan (hingga 500 operasi per eksekusi).
113
+ #### Menggunakan Atomic Write Batch (Untuk Upload File / Excel Massal)
114
+ Jika Anda mengunggah data sekaligus dalam jumlah besar dari file (misal: import 500 resi dari Excel), hindari memanggil `setDoc` satu per satu di dalam _looping_. Gunakan fitur `writeBatch` yang membundel seluruh operasi Anda dalam satu pengiriman jaringan (hingga 500 operasi per eksekusi).
115
+
116
+ **Catatan:** Jika alur kerjanya adalah **kasir/admin melakukan scan barcode satu-per-satu lalu menekan enter**, maka Anda **TIDAK PERLU** menggunakan `writeBatch`. Menyimpan data satu-per-satu (`setDoc` / `addDoc`) setiap kali dienter adalah **cara yang paling benar dan normal** untuk kebutuhan _realtime_. Database dapat dengan mudah menangani 500 scan yang terjadi secara bertahap sepanjang hari.
115
117
 
116
118
  ```javascript
117
119
  import { doc, writeBatch } from 'nexabase-console';
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface NexaConfig {
3
3
  projectId: string;
4
4
  apiKey?: string;
5
5
  endpoint?: string;
6
+ enablePersistence?: boolean;
7
+ cacheStrategy?: 'network-first' | 'cache-first';
6
8
  }
7
9
  export interface User {
8
10
  id: string;
@@ -26,6 +28,7 @@ export interface DocumentReference<T = any> {
26
28
  type: 'document';
27
29
  path: string;
28
30
  db: NexaApp;
31
+ patch?: (data: Partial<T>) => Promise<any>;
29
32
  }
30
33
  export interface CollectionReference<T = any> {
31
34
  type: 'collection';
@@ -90,21 +93,27 @@ export interface StorageReference {
90
93
  export interface WriteBatch {
91
94
  set: (docRef: DocumentReference, data: any) => WriteBatch;
92
95
  update: (docRef: DocumentReference, data: any) => WriteBatch;
96
+ patch: (docRef: DocumentReference, data: any) => WriteBatch;
93
97
  delete: (docRef: DocumentReference) => WriteBatch;
94
98
  commit: () => Promise<any>;
95
99
  }
96
100
  interface OfflineJob {
97
101
  id: string;
98
- type: 'set' | 'update' | 'delete';
102
+ type: 'set' | 'update' | 'patch' | 'delete';
99
103
  path: string;
100
104
  data?: any;
105
+ idempotencyKey?: string;
106
+ timestamp?: number;
101
107
  }
108
+ export declare function generateIdempotencyKey(): string;
102
109
  export declare class NexaApp {
103
110
  projectId: string;
104
111
  token: string | null;
105
112
  endpoint: string;
106
113
  client: AxiosInstance;
107
114
  sse: EventSource | null;
115
+ enablePersistence: boolean;
116
+ cacheStrategy: 'network-first' | 'cache-first';
108
117
  _firestoreCache: Record<string, any>;
109
118
  _snapshotCallbacks: Record<string, (payload?: any) => void>;
110
119
  _sseCallbacks: {
@@ -116,8 +125,14 @@ export declare class NexaApp {
116
125
  _idb: any;
117
126
  _firestoreListening: boolean;
118
127
  _initPromise: Promise<void> | null;
128
+ _broadcastChannel: any;
129
+ _reconnectAttempts: number;
130
+ _reconnectTimer: any;
119
131
  constructor(config: NexaConfig);
132
+ _initCrossTabSync(): void;
133
+ _broadcastLocalMutation(docPath: string, action: 'set' | 'update' | 'patch' | 'delete' | 'bulk_update', data?: any): void;
120
134
  connectRealtime(): EventSource;
135
+ _notifySnapshotCallbacks(docPath: string, eventType: string, data?: any): void;
121
136
  database(): {
122
137
  ref: (path?: string) => DatabaseReference;
123
138
  };
@@ -146,6 +161,10 @@ export declare class NexaApp {
146
161
  _syncOfflineQueue(): Promise<void>;
147
162
  _ensureFirestoreSSE(): void;
148
163
  }
164
+ export declare const NexaBase: {
165
+ init: (config: NexaConfig) => NexaApp;
166
+ initializeApp: (config: NexaConfig) => NexaApp;
167
+ };
149
168
  export declare const initializeApp: (config: NexaConfig) => NexaApp;
150
169
  export declare const getFirestore: (app: NexaApp) => NexaApp;
151
170
  export declare const collection: <T = any>(db: NexaApp, collectionPath: string) => CollectionReference<T>;
@@ -158,9 +177,18 @@ export declare const getCachedDocs: <T = any>(queryOrCollection: Query<T> | Coll
158
177
  export declare const getCachedDoc: <T = any>(docRef: DocumentReference<T>) => DocumentSnapshot<T>;
159
178
  export declare const getDocs: <T = any>(queryOrCollection: Query<T> | CollectionReference<T>) => Promise<QuerySnapshot<T>>;
160
179
  export declare const getDoc: <T = any>(docRef: DocumentReference<T>) => Promise<DocumentSnapshot<T>>;
161
- export declare const setDoc: (docRef: DocumentReference, data: any) => Promise<any>;
162
- export declare const updateDoc: (docRef: DocumentReference, data: any) => Promise<any>;
163
- export declare const deleteDoc: (docRef: DocumentReference) => Promise<any>;
180
+ export declare const setDoc: (docRef: DocumentReference, data: any, options?: {
181
+ idempotencyKey?: string;
182
+ }) => Promise<any>;
183
+ export declare const patchDoc: (docRef: DocumentReference, data: any, options?: {
184
+ idempotencyKey?: string;
185
+ }) => Promise<any>;
186
+ export declare const updateDoc: (docRef: DocumentReference, data: any, options?: {
187
+ idempotencyKey?: string;
188
+ }) => Promise<any>;
189
+ export declare const deleteDoc: (docRef: DocumentReference, options?: {
190
+ idempotencyKey?: string;
191
+ }) => Promise<any>;
164
192
  export declare const onSnapshot: (ref: CollectionReference | DocumentReference | Query, callback: (snapshot: any) => void) => (() => void);
165
193
  export declare const enableIndexedDbPersistence: (db: NexaApp) => Promise<void>;
166
194
  export declare const enableOfflinePersistence: (db: NexaApp) => Promise<void>;
@@ -169,6 +197,7 @@ export interface Transaction {
169
197
  get: <T = any>(docRef: DocumentReference<T>) => Promise<DocumentSnapshot<T>>;
170
198
  set: (docRef: DocumentReference, data: any) => Transaction;
171
199
  update: (docRef: DocumentReference, data: any) => Transaction;
200
+ patch: (docRef: DocumentReference, data: any) => Transaction;
172
201
  delete: (docRef: DocumentReference) => Transaction;
173
202
  }
174
203
  export declare const runTransaction: <T = any>(db: NexaApp, updateFunction: (transaction: Transaction) => Promise<T>, options?: {
package/dist/index.js CHANGED
@@ -3,9 +3,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.runTransaction = exports.writeBatch = exports.enableOfflinePersistence = exports.enableIndexedDbPersistence = exports.onSnapshot = exports.deleteDoc = exports.updateDoc = exports.setDoc = exports.getDoc = exports.getDocs = exports.getCachedDoc = exports.getCachedDocs = exports.limit = exports.orderBy = exports.where = exports.query = exports.doc = exports.collection = exports.getFirestore = exports.initializeApp = exports.NexaApp = void 0;
6
+ exports.runTransaction = exports.writeBatch = exports.enableOfflinePersistence = exports.enableIndexedDbPersistence = exports.onSnapshot = exports.deleteDoc = exports.updateDoc = exports.patchDoc = exports.setDoc = exports.getDoc = exports.getDocs = exports.getCachedDoc = exports.getCachedDocs = exports.limit = exports.orderBy = exports.where = exports.query = exports.doc = exports.collection = exports.getFirestore = exports.initializeApp = exports.NexaBase = exports.NexaApp = void 0;
7
+ exports.generateIdempotencyKey = generateIdempotencyKey;
7
8
  const axios_1 = __importDefault(require("axios"));
8
9
  // -----------------------------------------------------------------
10
+ // Idempotency Utility
11
+ // -----------------------------------------------------------------
12
+ function generateIdempotencyKey() {
13
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
14
+ return crypto.randomUUID();
15
+ }
16
+ return `nexa_idemp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}_${Math.floor(Math.random() * 1000000)}`;
17
+ }
18
+ // -----------------------------------------------------------------
9
19
  // Main NexaApp SDK Class
10
20
  // -----------------------------------------------------------------
11
21
  class NexaApp {
@@ -21,18 +31,23 @@ class NexaApp {
21
31
  this._idb = null;
22
32
  this._firestoreListening = false;
23
33
  this._initPromise = null;
34
+ // Real-time Engine Utilities (Cross-Tab & Cross-Device)
35
+ this._broadcastChannel = null;
36
+ this._reconnectAttempts = 0;
37
+ this._reconnectTimer = null;
24
38
  this.projectId = config.projectId;
25
39
  this.token = config.apiKey || null;
40
+ this.enablePersistence = config.enablePersistence !== false;
41
+ this.cacheStrategy = config.cacheStrategy || 'cache-first';
26
42
  if (typeof window !== 'undefined' && window.localStorage) {
27
43
  const savedToken = localStorage.getItem(`nexa_token_${this.projectId}`);
28
44
  if (savedToken) {
29
45
  this.token = savedToken;
30
46
  }
31
47
  }
32
- // 3. Smart Endpoint Resolution: Auto-detect domain/host if none is specified
48
+ // Auto-detect endpoint
33
49
  let defaultEndpoint = 'https://db.nexabase.id';
34
50
  if (typeof window !== 'undefined' && window.location) {
35
- // Default to the current page origin in dev/proxy preview environments so it auto-points to /api/*
36
51
  defaultEndpoint = window.location.origin;
37
52
  }
38
53
  this.endpoint = config.endpoint || defaultEndpoint;
@@ -42,43 +57,131 @@ class NexaApp {
42
57
  'Content-Type': 'application/json'
43
58
  }
44
59
  });
45
- // Request interceptor to attach bearer token and automatically resolve the axios boundary issue for file uploads
60
+ // Request interceptor to attach bearer token and boundary fix
46
61
  this.client.interceptors.request.use((req) => {
47
62
  if (this.token) {
48
63
  req.headers.Authorization = `Bearer ${this.token}`;
49
64
  }
50
- // 2. Fix Axios Multipart Boundary on File Upload
51
- // If Content-Type is multipart/form-data, delete it so axios/browser automatically appends the boundary header
52
65
  if (req.headers && req.headers['Content-Type'] === 'multipart/form-data') {
53
66
  delete req.headers['Content-Type'];
54
67
  }
55
68
  return req;
56
69
  });
70
+ // Initialize Cross-Tab Sync via BroadcastChannel
71
+ this._initCrossTabSync();
72
+ }
73
+ // Cross-Tab Engine via BroadcastChannel API
74
+ _initCrossTabSync() {
75
+ if (typeof window !== 'undefined' && 'BroadcastChannel' in window && !this._broadcastChannel) {
76
+ try {
77
+ this._broadcastChannel = new BroadcastChannel(`nexabase_sync_${this.projectId}`);
78
+ this._broadcastChannel.onmessage = (event) => {
79
+ const { type, docPath, data, action } = event.data || {};
80
+ if (type === 'cross_tab_mutation' && docPath) {
81
+ if (action === 'set' || action === 'update' || action === 'patch') {
82
+ if (action === 'patch' || action === 'update') {
83
+ const existing = this._firestoreCache[docPath] || {};
84
+ this._firestoreCache[docPath] = { ...existing, ...data };
85
+ }
86
+ else {
87
+ this._firestoreCache[docPath] = data;
88
+ }
89
+ if (this.enablePersistence && this._idb) {
90
+ this._setCache(docPath, this._firestoreCache[docPath]);
91
+ }
92
+ this._notifySnapshotCallbacks(docPath, 'document_written', this._firestoreCache[docPath]);
93
+ }
94
+ else if (action === 'delete') {
95
+ delete this._firestoreCache[docPath];
96
+ if (this.enablePersistence && this._idb) {
97
+ this._deleteCache(docPath);
98
+ }
99
+ this._notifySnapshotCallbacks(docPath, 'document_deleted', null);
100
+ }
101
+ else if (action === 'bulk_update') {
102
+ this._notifySnapshotCallbacks('*', 'bulk_update', null);
103
+ }
104
+ }
105
+ };
106
+ }
107
+ catch (e) {
108
+ // BroadcastChannel fallback silently if disabled by security context
109
+ }
110
+ }
111
+ }
112
+ _broadcastLocalMutation(docPath, action, data) {
113
+ if (this._broadcastChannel) {
114
+ try {
115
+ this._broadcastChannel.postMessage({
116
+ type: 'cross_tab_mutation',
117
+ docPath,
118
+ action,
119
+ data,
120
+ timestamp: Date.now()
121
+ });
122
+ }
123
+ catch (e) { }
124
+ }
57
125
  }
58
- // Real-time listener connection via Server-Sent Events (SSE)
126
+ // Cross-Device Realtime Connection via SSE with Auto-Reconnect & Heartbeat
59
127
  connectRealtime() {
60
128
  if (this.sse)
61
129
  return this.sse;
62
130
  const url = `${this.endpoint}/api/firestore/${this.projectId}/listen`;
63
131
  const listenUrl = this.token ? `${url}?token=${this.token}` : url;
64
- this.sse = new EventSource(listenUrl);
65
- this.sse.onmessage = async (event) => {
66
- try {
67
- const payload = JSON.parse(event.data);
68
- if (payload.type === 'connected')
69
- return;
70
- if (payload.type && (payload.type.includes('document_') || payload.type === 'bulk_update')) {
71
- this._sseCallbacks.firestore_changed.forEach((cb) => cb(payload));
132
+ try {
133
+ this.sse = new EventSource(listenUrl);
134
+ this.sse.onmessage = async (event) => {
135
+ try {
136
+ const payload = JSON.parse(event.data);
137
+ if (payload.type === 'connected') {
138
+ this._reconnectAttempts = 0; // Reset backoff on stable connection
139
+ return;
140
+ }
141
+ if (payload.type && (payload.type.includes('document_') || payload.type === 'bulk_update')) {
142
+ this._sseCallbacks.firestore_changed.forEach((cb) => cb(payload));
143
+ }
144
+ else {
145
+ this._sseCallbacks.data_changed.forEach((cb) => cb(payload));
146
+ }
72
147
  }
73
- else {
74
- this._sseCallbacks.data_changed.forEach((cb) => cb(payload));
148
+ catch (e) {
149
+ // Ignore JSON parse errors on ping
150
+ }
151
+ };
152
+ this.sse.onerror = () => {
153
+ if (this.sse) {
154
+ this.sse.close();
155
+ this.sse = null;
75
156
  }
157
+ if (this._reconnectTimer)
158
+ clearTimeout(this._reconnectTimer);
159
+ // Exponential backoff reconnect: 1s, 2s, 4s, ... max 30s
160
+ this._reconnectAttempts++;
161
+ const backoffMs = Math.min(1000 * Math.pow(2, this._reconnectAttempts), 30000);
162
+ this._reconnectTimer = setTimeout(() => {
163
+ this.connectRealtime();
164
+ }, backoffMs);
165
+ };
166
+ }
167
+ catch (e) {
168
+ // Return dummy EventSource if SSE fails in environment
169
+ }
170
+ return this.sse;
171
+ }
172
+ _notifySnapshotCallbacks(docPath, eventType, data) {
173
+ Object.keys(this._snapshotCallbacks).forEach((key) => {
174
+ const cb = this._snapshotCallbacks[key];
175
+ if (docPath === '*') {
176
+ cb({ type: eventType });
177
+ return;
76
178
  }
77
- catch (e) {
78
- // Silent catch JSON parsing errors
179
+ const parts = key.split('_');
180
+ const pathKey = parts.slice(2).join('_');
181
+ if (pathKey === docPath || docPath.startsWith(pathKey + '/')) {
182
+ cb({ type: eventType, docPath, data });
79
183
  }
80
- };
81
- return this.sse;
184
+ });
82
185
  }
83
186
  // -----------------------------------------------------------------
84
187
  // Realtime Key-Value Database API
@@ -213,7 +316,6 @@ class NexaApp {
213
316
  formData.append('fileId', actualOptions.fileId);
214
317
  }
215
318
  const response = await this.client.post(`/api/project/${this.projectId}/storage/upload`, formData, {
216
- // We omit Content-Type headers here (interceptor strips it anyway) so the browser naturally generates the boundary
217
319
  onUploadProgress: (progressEvent) => {
218
320
  if (actualOnProgress && progressEvent.total) {
219
321
  const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
@@ -247,6 +349,8 @@ class NexaApp {
247
349
  // Offline Synchronization & Local IndexedDB Cache Engine
248
350
  // -----------------------------------------------------------------
249
351
  async _initFirestoreState() {
352
+ if (!this.enablePersistence)
353
+ return; // Respect enablePersistence ON / OFF option
250
354
  if (this._offlineQueueInitialized)
251
355
  return;
252
356
  this._offlineQueueInitialized = true;
@@ -255,29 +359,33 @@ class NexaApp {
255
359
  return;
256
360
  }
257
361
  const dbName = `nexa_idb_${this.projectId}`;
258
- this._idb = await new Promise((resolve, reject) => {
259
- const req = indexedDB.open(dbName, 1);
260
- req.onupgradeneeded = (e) => {
261
- const db = e.target.result;
262
- if (!db.objectStoreNames.contains('offline_queue')) {
263
- db.createObjectStore('offline_queue', { keyPath: 'id' });
264
- }
265
- if (!db.objectStoreNames.contains('offline_cache')) {
266
- db.createObjectStore('offline_cache', { keyPath: 'path' });
267
- }
268
- };
269
- req.onsuccess = () => resolve(req.result);
270
- req.onerror = () => reject(req.error);
271
- });
272
- await this._loadCache();
273
- // Trigger queue sync periodically when browser returns online
274
- if (typeof window !== 'undefined') {
275
- window.addEventListener('online', () => this._syncOfflineQueue());
276
- setTimeout(() => this._syncOfflineQueue(), 1000);
362
+ try {
363
+ this._idb = await new Promise((resolve, reject) => {
364
+ const req = indexedDB.open(dbName, 1);
365
+ req.onupgradeneeded = (e) => {
366
+ const db = e.target.result;
367
+ if (!db.objectStoreNames.contains('offline_queue')) {
368
+ db.createObjectStore('offline_queue', { keyPath: 'id' });
369
+ }
370
+ if (!db.objectStoreNames.contains('offline_cache')) {
371
+ db.createObjectStore('offline_cache', { keyPath: 'path' });
372
+ }
373
+ };
374
+ req.onsuccess = () => resolve(req.result);
375
+ req.onerror = () => reject(req.error);
376
+ });
377
+ await this._loadCache();
378
+ if (typeof window !== 'undefined') {
379
+ window.addEventListener('online', () => this._syncOfflineQueue());
380
+ setTimeout(() => this._syncOfflineQueue(), 1000);
381
+ }
382
+ }
383
+ catch (err) {
384
+ console.warn('[NexaBase SDK] IndexedDB persistence unavailable, falling back to memory cache.');
277
385
  }
278
386
  }
279
387
  async _getOfflineQueue() {
280
- if (!this._idb)
388
+ if (!this._idb || !this.enablePersistence)
281
389
  return [];
282
390
  return new Promise((resolve, reject) => {
283
391
  const tx = this._idb.transaction('offline_queue', 'readonly');
@@ -288,7 +396,7 @@ class NexaApp {
288
396
  });
289
397
  }
290
398
  async _saveOfflineQueue(queue) {
291
- if (!this._idb)
399
+ if (!this._idb || !this.enablePersistence)
292
400
  return;
293
401
  return new Promise((resolve, reject) => {
294
402
  const tx = this._idb.transaction('offline_queue', 'readwrite');
@@ -300,7 +408,7 @@ class NexaApp {
300
408
  });
301
409
  }
302
410
  async _addOfflineJob(job) {
303
- if (!this._idb)
411
+ if (!this._idb || !this.enablePersistence)
304
412
  return;
305
413
  return new Promise((resolve, reject) => {
306
414
  const tx = this._idb.transaction('offline_queue', 'readwrite');
@@ -317,7 +425,7 @@ class NexaApp {
317
425
  }
318
426
  async _setCache(path, data) {
319
427
  this._firestoreCache[path] = data;
320
- if (!this._idb)
428
+ if (!this._idb || !this.enablePersistence)
321
429
  return;
322
430
  return new Promise((resolve, reject) => {
323
431
  const tx = this._idb.transaction('offline_cache', 'readwrite');
@@ -329,7 +437,7 @@ class NexaApp {
329
437
  }
330
438
  async _deleteCache(path) {
331
439
  delete this._firestoreCache[path];
332
- if (!this._idb)
440
+ if (!this._idb || !this.enablePersistence)
333
441
  return;
334
442
  return new Promise((resolve, reject) => {
335
443
  const tx = this._idb.transaction('offline_cache', 'readwrite');
@@ -340,7 +448,7 @@ class NexaApp {
340
448
  });
341
449
  }
342
450
  async _loadCache() {
343
- if (!this._idb)
451
+ if (!this._idb || !this.enablePersistence)
344
452
  return;
345
453
  return new Promise((resolve, reject) => {
346
454
  const tx = this._idb.transaction('offline_cache', 'readonly');
@@ -355,8 +463,9 @@ class NexaApp {
355
463
  req.onerror = () => reject(req.error);
356
464
  });
357
465
  }
466
+ // Outbox Pattern Offline Queue Flush with Idempotency Key
358
467
  async _syncOfflineQueue() {
359
- if (this._isSyncing)
468
+ if (this._isSyncing || !this.enablePersistence)
360
469
  return;
361
470
  this._isSyncing = true;
362
471
  const queue = await this._getOfflineQueue();
@@ -366,26 +475,25 @@ class NexaApp {
366
475
  }
367
476
  for (const job of [...queue]) {
368
477
  try {
478
+ const headers = job.idempotencyKey ? { 'X-Idempotency-Key': job.idempotencyKey } : {};
369
479
  if (job.type === 'set') {
370
- await this.client.post(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data });
480
+ await this.client.post(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data, idempotencyKey: job.idempotencyKey }, { headers });
371
481
  }
372
- else if (job.type === 'update') {
373
- await this.client.patch(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data });
482
+ else if (job.type === 'update' || job.type === 'patch') {
483
+ await this.client.patch(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data, idempotencyKey: job.idempotencyKey }, { headers });
374
484
  }
375
485
  else if (job.type === 'delete') {
376
- await this.client.delete(`/api/firestore/${this.projectId}/document?docPath=${encodeURIComponent(job.path)}`);
486
+ await this.client.delete(`/api/firestore/${this.projectId}/document?docPath=${encodeURIComponent(job.path)}`, { headers });
377
487
  }
378
488
  const currentQueue = await this._getOfflineQueue();
379
489
  await this._saveOfflineQueue(currentQueue.filter((q) => q.id !== job.id));
380
490
  }
381
491
  catch (err) {
382
- break; // Stop syncing on network exceptions
492
+ break; // Stop syncing on network exception
383
493
  }
384
494
  }
385
495
  this._isSyncing = false;
386
- if (this._snapshotCallbacks) {
387
- Object.values(this._snapshotCallbacks).forEach((cb) => cb());
388
- }
496
+ this._notifySnapshotCallbacks('*', 'bulk_update', null);
389
497
  }
390
498
  _ensureFirestoreSSE() {
391
499
  this.connectRealtime();
@@ -394,7 +502,7 @@ class NexaApp {
394
502
  this._firestoreListening = true;
395
503
  this._sseCallbacks.firestore_changed.push(async (change) => {
396
504
  if (change.type === 'bulk_update') {
397
- Object.values(this._snapshotCallbacks).forEach((cb) => cb());
505
+ this._notifySnapshotCallbacks('*', 'bulk_update', null);
398
506
  return;
399
507
  }
400
508
  if (change.type === 'document_written') {
@@ -403,27 +511,37 @@ class NexaApp {
403
511
  else if (change.type === 'document_deleted') {
404
512
  await this._deleteCache(change.docPath);
405
513
  }
406
- Object.keys(this._snapshotCallbacks).forEach((key) => {
407
- const cb = this._snapshotCallbacks[key];
408
- const parts = key.split('_');
409
- const pathKey = parts.slice(2).join('_');
410
- if (pathKey === change.docPath || change.docPath.startsWith(pathKey + '/')) {
411
- cb(change);
412
- }
413
- });
514
+ this._notifySnapshotCallbacks(change.docPath, change.type, change.data);
414
515
  });
415
516
  }
416
517
  }
417
518
  exports.NexaApp = NexaApp;
418
519
  // -----------------------------------------------------------------
419
- // Modular Firestore-like APIs (Firebase Modular pattern match)
520
+ // Static NexaBase Initialization Helper
521
+ // -----------------------------------------------------------------
522
+ exports.NexaBase = {
523
+ init: (config) => {
524
+ const app = new NexaApp(config);
525
+ if (app.enablePersistence) {
526
+ app._initPromise = app._initFirestoreState();
527
+ }
528
+ return app;
529
+ },
530
+ initializeApp: (config) => {
531
+ return exports.NexaBase.init(config);
532
+ }
533
+ };
534
+ // -----------------------------------------------------------------
535
+ // Modular Firestore-like APIs (Firebase Modular pattern)
420
536
  // -----------------------------------------------------------------
421
537
  const initializeApp = (config) => {
422
- return new NexaApp(config);
538
+ return exports.NexaBase.init(config);
423
539
  };
424
540
  exports.initializeApp = initializeApp;
425
541
  const getFirestore = (app) => {
426
- app._initPromise = app._initFirestoreState();
542
+ if (app.enablePersistence) {
543
+ app._initPromise = app._initFirestoreState();
544
+ }
427
545
  return app;
428
546
  };
429
547
  exports.getFirestore = getFirestore;
@@ -432,10 +550,15 @@ const collection = (db, collectionPath) => {
432
550
  };
433
551
  exports.collection = collection;
434
552
  const doc = (dbOrCollection, ...pathSegments) => {
553
+ let docRef;
435
554
  if ('type' in dbOrCollection && dbOrCollection.type === 'collection') {
436
- return { type: 'document', path: `${dbOrCollection.path}/${pathSegments.join('/')}`, db: dbOrCollection.db };
555
+ docRef = { type: 'document', path: `${dbOrCollection.path}/${pathSegments.join('/')}`, db: dbOrCollection.db };
437
556
  }
438
- return { type: 'document', path: pathSegments.join('/'), db: dbOrCollection };
557
+ else {
558
+ docRef = { type: 'document', path: pathSegments.join('/'), db: dbOrCollection };
559
+ }
560
+ docRef.patch = (data) => (0, exports.patchDoc)(docRef, data);
561
+ return docRef;
439
562
  };
440
563
  exports.doc = doc;
441
564
  const query = (queryObject, ...queryConstraints) => {
@@ -461,7 +584,7 @@ const limit = (limitValue) => {
461
584
  return { type: 'limit', limitValue };
462
585
  };
463
586
  exports.limit = limit;
464
- // 1. Helper to retrieve docs from the local Offline cache (Cache-First)
587
+ // Helper to retrieve docs from local cache
465
588
  const getCachedDocs = (queryOrCollection) => {
466
589
  const db = queryOrCollection.db;
467
590
  const collectionPath = queryOrCollection.path;
@@ -480,7 +603,6 @@ const getCachedDocs = (queryOrCollection) => {
480
603
  });
481
604
  }
482
605
  });
483
- // Simple offline filters
484
606
  let filtered = results;
485
607
  for (const c of constraints) {
486
608
  if (c.type === 'where' && c.fieldPath && c.opStr) {
@@ -507,11 +629,10 @@ const getCachedDocs = (queryOrCollection) => {
507
629
  forEach: (cb) => filtered.forEach(cb),
508
630
  empty: filtered.length === 0,
509
631
  size: filtered.length,
510
- docChanges: () => filtered.map(doc => ({ type: 'added', doc }))
632
+ docChanges: () => filtered.map((d) => ({ type: 'added', doc: d }))
511
633
  };
512
634
  };
513
635
  exports.getCachedDocs = getCachedDocs;
514
- // 1. Helper to retrieve single doc from the local Offline cache (Cache-First)
515
636
  const getCachedDoc = (docRef) => {
516
637
  const db = docRef.db;
517
638
  const segments = docRef.path.split('/');
@@ -554,7 +675,7 @@ const getDocs = async (queryOrCollection) => {
554
675
  forEach: (cb) => docsArray.forEach(cb),
555
676
  empty: docsArray.length === 0,
556
677
  size: docsArray.length,
557
- docChanges: () => docsArray.map(doc => ({ type: 'added', doc }))
678
+ docChanges: () => docsArray.map((d) => ({ type: 'added', doc: d }))
558
679
  };
559
680
  }
560
681
  catch (err) {
@@ -590,87 +711,87 @@ const getDoc = async (docRef) => {
590
711
  }
591
712
  };
592
713
  exports.getDoc = getDoc;
593
- const setDoc = async (docRef, data) => {
714
+ // -----------------------------------------------------------------
715
+ // Atomic Document Mutation APIs with Idempotency & Partial Patching
716
+ // -----------------------------------------------------------------
717
+ const setDoc = async (docRef, data, options) => {
594
718
  const db = docRef.db;
595
719
  if (db._initPromise)
596
720
  await db._initPromise;
597
721
  await db._setCache(docRef.path, data);
598
- // Trigger snapshot listeners
599
- Object.keys(db._snapshotCallbacks).forEach((key) => {
600
- const cb = db._snapshotCallbacks[key];
601
- const parts = key.split('_');
602
- const pathKey = parts.slice(2).join('_');
603
- if (pathKey === docRef.path || docRef.path.startsWith(pathKey + '/')) {
604
- cb({ type: 'document_written', docPath: docRef.path, data });
605
- }
606
- });
722
+ // Broadcast cross-tab and notify snapshot listeners
723
+ db._broadcastLocalMutation(docRef.path, 'set', data);
724
+ db._notifySnapshotCallbacks(docRef.path, 'document_written', data);
725
+ const key = options?.idempotencyKey || generateIdempotencyKey();
607
726
  const jobId = Math.random().toString(36).substring(2, 9);
608
727
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
609
728
  if (!isOnline) {
610
- await db._addOfflineJob({ id: jobId, type: 'set', path: docRef.path, data });
611
- return { success: true, message: 'Offline: Tersimpan di cache lokal IndexedDB' };
729
+ await db._addOfflineJob({ id: jobId, type: 'set', path: docRef.path, data, idempotencyKey: key });
730
+ return { success: true, message: 'Offline: Tersimpan di cache lokal' };
612
731
  }
613
732
  else {
614
733
  try {
615
- return (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data })).data;
734
+ return (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key }, { headers: { 'X-Idempotency-Key': key } })).data;
616
735
  }
617
736
  catch (error) {
618
- await db._addOfflineJob({ id: jobId, type: 'set', path: docRef.path, data });
619
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline IndexedDB' };
737
+ await db._addOfflineJob({ id: jobId, type: 'set', path: docRef.path, data, idempotencyKey: key });
738
+ return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
620
739
  }
621
740
  }
622
741
  };
623
742
  exports.setDoc = setDoc;
624
- const updateDoc = async (docRef, data) => {
743
+ // Partial Update (UpdateDoc) & Atomic Patch (PatchDoc)
744
+ const patchDoc = async (docRef, data, options) => {
625
745
  const db = docRef.db;
626
746
  if (db._initPromise)
627
747
  await db._initPromise;
628
- const jobId = Math.random().toString(36).substring(2, 9);
629
748
  const existingData = db._firestoreCache[docRef.path] || {};
630
749
  const updatedData = { ...existingData, ...data };
631
750
  await db._setCache(docRef.path, updatedData);
751
+ db._broadcastLocalMutation(docRef.path, 'patch', data);
752
+ db._notifySnapshotCallbacks(docRef.path, 'document_written', updatedData);
753
+ const key = options?.idempotencyKey || generateIdempotencyKey();
754
+ const jobId = Math.random().toString(36).substring(2, 9);
632
755
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
633
756
  if (!isOnline) {
634
- await db._addOfflineJob({ id: jobId, type: 'update', path: docRef.path, data });
635
- return { success: true, message: 'Offline: Tersimpan di cache lokal IndexedDB' };
757
+ await db._addOfflineJob({ id: jobId, type: 'patch', path: docRef.path, data, idempotencyKey: key });
758
+ return { success: true, message: 'Offline: Tersimpan di cache lokal' };
636
759
  }
637
760
  else {
638
761
  try {
639
- return (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data })).data;
762
+ return (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key }, { headers: { 'X-Idempotency-Key': key } })).data;
640
763
  }
641
764
  catch (error) {
642
- await db._addOfflineJob({ id: jobId, type: 'update', path: docRef.path, data });
643
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline IndexedDB' };
765
+ await db._addOfflineJob({ id: jobId, type: 'patch', path: docRef.path, data, idempotencyKey: key });
766
+ return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
644
767
  }
645
768
  }
646
769
  };
770
+ exports.patchDoc = patchDoc;
771
+ const updateDoc = async (docRef, data, options) => {
772
+ return (0, exports.patchDoc)(docRef, data, options);
773
+ };
647
774
  exports.updateDoc = updateDoc;
648
- const deleteDoc = async (docRef) => {
775
+ const deleteDoc = async (docRef, options) => {
649
776
  const db = docRef.db;
650
777
  if (db._initPromise)
651
778
  await db._initPromise;
652
779
  await db._deleteCache(docRef.path);
653
- // Trigger snapshot listeners
654
- Object.keys(db._snapshotCallbacks).forEach((key) => {
655
- const cb = db._snapshotCallbacks[key];
656
- const parts = key.split('_');
657
- const pathKey = parts.slice(2).join('_');
658
- if (pathKey === docRef.path || docRef.path.startsWith(pathKey + '/')) {
659
- cb({ type: 'document_deleted', docPath: docRef.path });
660
- }
661
- });
780
+ db._broadcastLocalMutation(docRef.path, 'delete');
781
+ db._notifySnapshotCallbacks(docRef.path, 'document_deleted', null);
782
+ const key = options?.idempotencyKey || generateIdempotencyKey();
662
783
  const jobId = Math.random().toString(36).substring(2, 9);
663
784
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
664
785
  if (!isOnline) {
665
- await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path });
786
+ await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path, idempotencyKey: key });
666
787
  return { success: true };
667
788
  }
668
789
  else {
669
790
  try {
670
- return (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`)).data;
791
+ return (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`, { headers: { 'X-Idempotency-Key': key } })).data;
671
792
  }
672
793
  catch (error) {
673
- await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path });
794
+ await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path, idempotencyKey: key });
674
795
  return { success: true };
675
796
  }
676
797
  }
@@ -683,7 +804,7 @@ const onSnapshot = (ref, callback) => {
683
804
  await db._initPromise;
684
805
  db._ensureFirestoreSSE();
685
806
  const pathKey = ref.path;
686
- // 1. Native Offline Cache/Persistence Priority: Emits cached snapshot IMMEDIATELY to trigger instant optimistic rendering!
807
+ // Fast cache emit
687
808
  if (ref.type === 'collection' || ref.type === 'query') {
688
809
  const cached = (0, exports.getCachedDocs)(ref);
689
810
  if (cached && cached.docs.length > 0) {
@@ -702,7 +823,6 @@ const onSnapshot = (ref, callback) => {
702
823
  clearTimeout(debounceTimer);
703
824
  debounceTimer = setTimeout(async () => {
704
825
  if (ref.type === 'collection' || ref.type === 'query') {
705
- // Immediately serve from cache for UI responsiveness
706
826
  callback((0, exports.getCachedDocs)(ref));
707
827
  try {
708
828
  const docs = await (0, exports.getDocs)(ref);
@@ -718,9 +838,8 @@ const onSnapshot = (ref, callback) => {
718
838
  }
719
839
  catch (e) { }
720
840
  }
721
- }, 800); // Debounce to batch rapid changes (e.g. 500 inserts)
841
+ }, 50); // Debounce to batch rapid updates
722
842
  };
723
- // 2. Fetch the latest live server data and update cache + trigger the callback again
724
843
  triggerCallback();
725
844
  const listenerId = Math.random().toString(36).substring(2, 9);
726
845
  db._snapshotCallbacks[`${ref.type}_${listenerId}_${pathKey}`] = triggerCallback;
@@ -735,13 +854,14 @@ const onSnapshot = (ref, callback) => {
735
854
  return () => unsubscribe();
736
855
  };
737
856
  exports.onSnapshot = onSnapshot;
738
- // Supporting standard Firebase modular options functions
739
857
  const enableIndexedDbPersistence = async (db) => {
858
+ db.enablePersistence = true;
740
859
  if (db._initPromise)
741
860
  await db._initPromise;
742
861
  };
743
862
  exports.enableIndexedDbPersistence = enableIndexedDbPersistence;
744
863
  const enableOfflinePersistence = async (db) => {
864
+ db.enablePersistence = true;
745
865
  if (db._initPromise)
746
866
  await db._initPromise;
747
867
  };
@@ -757,6 +877,10 @@ const writeBatch = (db) => {
757
877
  operations.push({ type: 'update', path: docRef.path, data });
758
878
  return batchObj;
759
879
  },
880
+ patch: (docRef, data) => {
881
+ operations.push({ type: 'update', path: docRef.path, data });
882
+ return batchObj;
883
+ },
760
884
  delete: (docRef) => {
761
885
  operations.push({ type: 'delete', path: docRef.path });
762
886
  return batchObj;
@@ -766,31 +890,20 @@ const writeBatch = (db) => {
766
890
  await db._initPromise;
767
891
  if (operations.length === 0)
768
892
  return;
893
+ const idempotencyKey = generateIdempotencyKey();
769
894
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
770
895
  // Update local cache optimistically
771
896
  for (const op of operations) {
772
897
  if (op.type === 'set' || op.type === 'update') {
773
898
  const newData = { ...db._firestoreCache[op.path], ...op.data };
774
899
  await db._setCache(op.path, newData);
775
- Object.keys(db._snapshotCallbacks).forEach((key) => {
776
- const cb = db._snapshotCallbacks[key];
777
- const parts = key.split('_');
778
- const pathKey = parts.slice(2).join('_');
779
- if (pathKey === op.path || op.path.startsWith(pathKey + '/')) {
780
- cb({ type: 'document_written', docPath: op.path, data: db._firestoreCache[op.path] });
781
- }
782
- });
900
+ db._broadcastLocalMutation(op.path, op.type === 'set' ? 'set' : 'patch', op.data);
901
+ db._notifySnapshotCallbacks(op.path, 'document_written', db._firestoreCache[op.path]);
783
902
  }
784
903
  else if (op.type === 'delete') {
785
904
  await db._deleteCache(op.path);
786
- Object.keys(db._snapshotCallbacks).forEach((key) => {
787
- const cb = db._snapshotCallbacks[key];
788
- const parts = key.split('_');
789
- const pathKey = parts.slice(2).join('_');
790
- if (pathKey === op.path || op.path.startsWith(pathKey + '/')) {
791
- cb({ type: 'document_deleted', docPath: op.path });
792
- }
793
- });
905
+ db._broadcastLocalMutation(op.path, 'delete');
906
+ db._notifySnapshotCallbacks(op.path, 'document_deleted', null);
794
907
  }
795
908
  }
796
909
  if (!isOnline) {
@@ -799,14 +912,15 @@ const writeBatch = (db) => {
799
912
  id: Math.random().toString(36).substring(2, 9),
800
913
  type: op.type,
801
914
  path: op.path,
802
- data: op.data
915
+ data: op.data,
916
+ idempotencyKey
803
917
  });
804
918
  }
805
919
  return { success: true, message: 'Offline: Batch operations queued in IndexedDB' };
806
920
  }
807
921
  else {
808
922
  try {
809
- return (await db.client.post(`/api/firestore/${db.projectId}/batch`, { operations })).data;
923
+ return (await db.client.post(`/api/firestore/${db.projectId}/batch`, { operations, idempotencyKey }, { headers: { 'X-Idempotency-Key': idempotencyKey } })).data;
810
924
  }
811
925
  catch (error) {
812
926
  for (const op of operations) {
@@ -814,10 +928,11 @@ const writeBatch = (db) => {
814
928
  id: Math.random().toString(36).substring(2, 9),
815
929
  type: op.type,
816
930
  path: op.path,
817
- data: op.data
931
+ data: op.data,
932
+ idempotencyKey
818
933
  });
819
934
  }
820
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline IndexedDB' };
935
+ return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
821
936
  }
822
937
  }
823
938
  }
@@ -834,6 +949,7 @@ const runTransaction = async (db, updateFunction, options) => {
834
949
  attempt++;
835
950
  const reads = [];
836
951
  const operations = [];
952
+ const idempotencyKey = generateIdempotencyKey();
837
953
  const transaction = {
838
954
  get: async (docRef) => {
839
955
  const segments = docRef.path.split('/');
@@ -856,6 +972,10 @@ const runTransaction = async (db, updateFunction, options) => {
856
972
  operations.push({ type: 'update', path: docRef.path, data });
857
973
  return transaction;
858
974
  },
975
+ patch: (docRef, data) => {
976
+ operations.push({ type: 'update', path: docRef.path, data });
977
+ return transaction;
978
+ },
859
979
  delete: (docRef) => {
860
980
  operations.push({ type: 'delete', path: docRef.path });
861
981
  return transaction;
@@ -864,34 +984,19 @@ const runTransaction = async (db, updateFunction, options) => {
864
984
  try {
865
985
  const result = await updateFunction(transaction);
866
986
  if (operations.length > 0 || reads.length > 0) {
867
- await db.client.post(`/api/firestore/${db.projectId}/transaction`, {
868
- reads,
869
- operations
870
- });
987
+ await db.client.post(`/api/firestore/${db.projectId}/transaction`, { reads, operations, idempotencyKey }, { headers: { 'X-Idempotency-Key': idempotencyKey } });
871
988
  }
872
989
  for (const op of operations) {
873
990
  if (op.type === 'set' || op.type === 'update') {
874
991
  const newData = { ...db._firestoreCache[op.path], ...op.data };
875
992
  await db._setCache(op.path, newData);
876
- Object.keys(db._snapshotCallbacks).forEach((key) => {
877
- const cb = db._snapshotCallbacks[key];
878
- const parts = key.split('_');
879
- const pathKey = parts.slice(2).join('_');
880
- if (pathKey === op.path || op.path.startsWith(pathKey + '/')) {
881
- cb({ type: 'document_written', docPath: op.path, data: db._firestoreCache[op.path] });
882
- }
883
- });
993
+ db._broadcastLocalMutation(op.path, op.type === 'set' ? 'set' : 'patch', op.data);
994
+ db._notifySnapshotCallbacks(op.path, 'document_written', db._firestoreCache[op.path]);
884
995
  }
885
996
  else if (op.type === 'delete') {
886
997
  await db._deleteCache(op.path);
887
- Object.keys(db._snapshotCallbacks).forEach((key) => {
888
- const cb = db._snapshotCallbacks[key];
889
- const parts = key.split('_');
890
- const pathKey = parts.slice(2).join('_');
891
- if (pathKey === op.path || op.path.startsWith(pathKey + '/')) {
892
- cb({ type: 'document_deleted', docPath: op.path });
893
- }
894
- });
998
+ db._broadcastLocalMutation(op.path, 'delete');
999
+ db._notifySnapshotCallbacks(op.path, 'document_deleted', null);
895
1000
  }
896
1001
  }
897
1002
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexabase-console",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "SDK Client resmi untuk NexaBase: Platform Sinkronisasi NoSQL, Realtime, File Storage, & Autentikasi Offline-First.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",