nexabase-console 1.1.1 → 1.1.3

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.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,64 +31,167 @@ 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;
54
+ // Do not set default Content-Type globally to let Axios auto-detect FormData
39
55
  this.client = axios_1.default.create({
40
- baseURL: this.endpoint,
41
- headers: {
42
- 'Content-Type': 'application/json'
43
- }
56
+ baseURL: this.endpoint
44
57
  });
45
- // Request interceptor to attach bearer token and automatically resolve the axios boundary issue for file uploads
58
+ // Request interceptor to attach bearer/x-api-key token and boundary fix
46
59
  this.client.interceptors.request.use((req) => {
47
60
  if (this.token) {
61
+ // Send both Authorization Bearer and x-api-key for full server compatibility
48
62
  req.headers.Authorization = `Bearer ${this.token}`;
63
+ req.headers['x-api-key'] = 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
- if (req.headers && req.headers['Content-Type'] === 'multipart/form-data') {
53
- delete req.headers['Content-Type'];
65
+ // If data is FormData, remove Content-Type so Axios/Browser can format multipart boundary dynamically
66
+ if (typeof FormData !== 'undefined' && req.data instanceof FormData) {
67
+ if (req.headers) {
68
+ delete req.headers['Content-Type'];
69
+ delete req.headers['content-type'];
70
+ }
71
+ }
72
+ else {
73
+ // Otherwise default to application/json for non-FormData payload requests
74
+ if (req.data && req.headers && !req.headers['Content-Type'] && !req.headers['content-type']) {
75
+ req.headers['Content-Type'] = 'application/json';
76
+ }
54
77
  }
55
78
  return req;
56
79
  });
80
+ // Initialize Cross-Tab Sync via BroadcastChannel
81
+ this._initCrossTabSync();
57
82
  }
58
- // Real-time listener connection via Server-Sent Events (SSE)
83
+ // Cross-Tab Engine via BroadcastChannel API
84
+ _initCrossTabSync() {
85
+ if (typeof window !== 'undefined' && 'BroadcastChannel' in window && !this._broadcastChannel) {
86
+ try {
87
+ this._broadcastChannel = new BroadcastChannel(`nexabase_sync_${this.projectId}`);
88
+ this._broadcastChannel.onmessage = (event) => {
89
+ const { type, docPath, data, action } = event.data || {};
90
+ if (type === 'cross_tab_mutation' && docPath) {
91
+ if (action === 'set' || action === 'update' || action === 'patch') {
92
+ if (action === 'patch' || action === 'update') {
93
+ const existing = this._firestoreCache[docPath] || {};
94
+ this._firestoreCache[docPath] = { ...existing, ...data };
95
+ }
96
+ else {
97
+ this._firestoreCache[docPath] = data;
98
+ }
99
+ if (this.enablePersistence && this._idb) {
100
+ this._setCache(docPath, this._firestoreCache[docPath]);
101
+ }
102
+ this._notifySnapshotCallbacks(docPath, 'document_written', this._firestoreCache[docPath]);
103
+ }
104
+ else if (action === 'delete') {
105
+ delete this._firestoreCache[docPath];
106
+ if (this.enablePersistence && this._idb) {
107
+ this._deleteCache(docPath);
108
+ }
109
+ this._notifySnapshotCallbacks(docPath, 'document_deleted', null);
110
+ }
111
+ else if (action === 'bulk_update') {
112
+ this._notifySnapshotCallbacks('*', 'bulk_update', null);
113
+ }
114
+ }
115
+ };
116
+ }
117
+ catch (e) {
118
+ // BroadcastChannel fallback silently if disabled by security context
119
+ }
120
+ }
121
+ }
122
+ _broadcastLocalMutation(docPath, action, data) {
123
+ if (this._broadcastChannel) {
124
+ try {
125
+ this._broadcastChannel.postMessage({
126
+ type: 'cross_tab_mutation',
127
+ docPath,
128
+ action,
129
+ data,
130
+ timestamp: Date.now()
131
+ });
132
+ }
133
+ catch (e) { }
134
+ }
135
+ }
136
+ // Cross-Device Realtime Connection via SSE with Auto-Reconnect & Heartbeat
59
137
  connectRealtime() {
60
138
  if (this.sse)
61
139
  return this.sse;
62
140
  const url = `${this.endpoint}/api/firestore/${this.projectId}/listen`;
63
141
  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));
142
+ try {
143
+ this.sse = new EventSource(listenUrl);
144
+ this.sse.onmessage = async (event) => {
145
+ try {
146
+ const payload = JSON.parse(event.data);
147
+ if (payload.type === 'connected') {
148
+ this._reconnectAttempts = 0; // Reset backoff on stable connection
149
+ return;
150
+ }
151
+ if (payload.type && (payload.type.includes('document_') || payload.type === 'bulk_update')) {
152
+ this._sseCallbacks.firestore_changed.forEach((cb) => cb(payload));
153
+ }
154
+ else {
155
+ this._sseCallbacks.data_changed.forEach((cb) => cb(payload));
156
+ }
72
157
  }
73
- else {
74
- this._sseCallbacks.data_changed.forEach((cb) => cb(payload));
158
+ catch (e) {
159
+ // Ignore JSON parse errors on ping
160
+ }
161
+ };
162
+ this.sse.onerror = () => {
163
+ if (this.sse) {
164
+ this.sse.close();
165
+ this.sse = null;
75
166
  }
167
+ if (this._reconnectTimer)
168
+ clearTimeout(this._reconnectTimer);
169
+ // Exponential backoff reconnect: 1s, 2s, 4s, ... max 30s
170
+ this._reconnectAttempts++;
171
+ const backoffMs = Math.min(1000 * Math.pow(2, this._reconnectAttempts), 30000);
172
+ this._reconnectTimer = setTimeout(() => {
173
+ this.connectRealtime();
174
+ }, backoffMs);
175
+ };
176
+ }
177
+ catch (e) {
178
+ // Return dummy EventSource if SSE fails in environment
179
+ }
180
+ return this.sse;
181
+ }
182
+ _notifySnapshotCallbacks(docPath, eventType, data) {
183
+ Object.keys(this._snapshotCallbacks).forEach((key) => {
184
+ const cb = this._snapshotCallbacks[key];
185
+ if (docPath === '*') {
186
+ cb({ type: eventType });
187
+ return;
76
188
  }
77
- catch (e) {
78
- // Silent catch JSON parsing errors
189
+ const parts = key.split('_');
190
+ const pathKey = parts.slice(2).join('_');
191
+ if (pathKey === docPath || docPath.startsWith(pathKey + '/')) {
192
+ cb({ type: eventType, docPath, data });
79
193
  }
80
- };
81
- return this.sse;
194
+ });
82
195
  }
83
196
  // -----------------------------------------------------------------
84
197
  // Realtime Key-Value Database API
@@ -213,7 +326,6 @@ class NexaApp {
213
326
  formData.append('fileId', actualOptions.fileId);
214
327
  }
215
328
  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
329
  onUploadProgress: (progressEvent) => {
218
330
  if (actualOnProgress && progressEvent.total) {
219
331
  const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
@@ -247,6 +359,8 @@ class NexaApp {
247
359
  // Offline Synchronization & Local IndexedDB Cache Engine
248
360
  // -----------------------------------------------------------------
249
361
  async _initFirestoreState() {
362
+ if (!this.enablePersistence)
363
+ return; // Respect enablePersistence ON / OFF option
250
364
  if (this._offlineQueueInitialized)
251
365
  return;
252
366
  this._offlineQueueInitialized = true;
@@ -255,29 +369,33 @@ class NexaApp {
255
369
  return;
256
370
  }
257
371
  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);
372
+ try {
373
+ this._idb = await new Promise((resolve, reject) => {
374
+ const req = indexedDB.open(dbName, 1);
375
+ req.onupgradeneeded = (e) => {
376
+ const db = e.target.result;
377
+ if (!db.objectStoreNames.contains('offline_queue')) {
378
+ db.createObjectStore('offline_queue', { keyPath: 'id' });
379
+ }
380
+ if (!db.objectStoreNames.contains('offline_cache')) {
381
+ db.createObjectStore('offline_cache', { keyPath: 'path' });
382
+ }
383
+ };
384
+ req.onsuccess = () => resolve(req.result);
385
+ req.onerror = () => reject(req.error);
386
+ });
387
+ await this._loadCache();
388
+ if (typeof window !== 'undefined') {
389
+ window.addEventListener('online', () => this._syncOfflineQueue());
390
+ setTimeout(() => this._syncOfflineQueue(), 1000);
391
+ }
392
+ }
393
+ catch (err) {
394
+ console.warn('[NexaBase SDK] IndexedDB persistence unavailable, falling back to memory cache.');
277
395
  }
278
396
  }
279
397
  async _getOfflineQueue() {
280
- if (!this._idb)
398
+ if (!this._idb || !this.enablePersistence)
281
399
  return [];
282
400
  return new Promise((resolve, reject) => {
283
401
  const tx = this._idb.transaction('offline_queue', 'readonly');
@@ -288,7 +406,7 @@ class NexaApp {
288
406
  });
289
407
  }
290
408
  async _saveOfflineQueue(queue) {
291
- if (!this._idb)
409
+ if (!this._idb || !this.enablePersistence)
292
410
  return;
293
411
  return new Promise((resolve, reject) => {
294
412
  const tx = this._idb.transaction('offline_queue', 'readwrite');
@@ -300,7 +418,7 @@ class NexaApp {
300
418
  });
301
419
  }
302
420
  async _addOfflineJob(job) {
303
- if (!this._idb)
421
+ if (!this._idb || !this.enablePersistence)
304
422
  return;
305
423
  return new Promise((resolve, reject) => {
306
424
  const tx = this._idb.transaction('offline_queue', 'readwrite');
@@ -317,7 +435,7 @@ class NexaApp {
317
435
  }
318
436
  async _setCache(path, data) {
319
437
  this._firestoreCache[path] = data;
320
- if (!this._idb)
438
+ if (!this._idb || !this.enablePersistence)
321
439
  return;
322
440
  return new Promise((resolve, reject) => {
323
441
  const tx = this._idb.transaction('offline_cache', 'readwrite');
@@ -329,7 +447,7 @@ class NexaApp {
329
447
  }
330
448
  async _deleteCache(path) {
331
449
  delete this._firestoreCache[path];
332
- if (!this._idb)
450
+ if (!this._idb || !this.enablePersistence)
333
451
  return;
334
452
  return new Promise((resolve, reject) => {
335
453
  const tx = this._idb.transaction('offline_cache', 'readwrite');
@@ -340,7 +458,7 @@ class NexaApp {
340
458
  });
341
459
  }
342
460
  async _loadCache() {
343
- if (!this._idb)
461
+ if (!this._idb || !this.enablePersistence)
344
462
  return;
345
463
  return new Promise((resolve, reject) => {
346
464
  const tx = this._idb.transaction('offline_cache', 'readonly');
@@ -355,8 +473,9 @@ class NexaApp {
355
473
  req.onerror = () => reject(req.error);
356
474
  });
357
475
  }
476
+ // Outbox Pattern Offline Queue Flush with Idempotency Key
358
477
  async _syncOfflineQueue() {
359
- if (this._isSyncing)
478
+ if (this._isSyncing || !this.enablePersistence)
360
479
  return;
361
480
  this._isSyncing = true;
362
481
  const queue = await this._getOfflineQueue();
@@ -366,26 +485,25 @@ class NexaApp {
366
485
  }
367
486
  for (const job of [...queue]) {
368
487
  try {
488
+ const headers = job.idempotencyKey ? { 'X-Idempotency-Key': job.idempotencyKey } : {};
369
489
  if (job.type === 'set') {
370
- await this.client.post(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data });
490
+ await this.client.post(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data, idempotencyKey: job.idempotencyKey }, { headers });
371
491
  }
372
- else if (job.type === 'update') {
373
- await this.client.patch(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data });
492
+ else if (job.type === 'update' || job.type === 'patch') {
493
+ await this.client.patch(`/api/firestore/${this.projectId}/document`, { docPath: job.path, data: job.data, idempotencyKey: job.idempotencyKey }, { headers });
374
494
  }
375
495
  else if (job.type === 'delete') {
376
- await this.client.delete(`/api/firestore/${this.projectId}/document?docPath=${encodeURIComponent(job.path)}`);
496
+ await this.client.delete(`/api/firestore/${this.projectId}/document?docPath=${encodeURIComponent(job.path)}`, { headers });
377
497
  }
378
498
  const currentQueue = await this._getOfflineQueue();
379
499
  await this._saveOfflineQueue(currentQueue.filter((q) => q.id !== job.id));
380
500
  }
381
501
  catch (err) {
382
- break; // Stop syncing on network exceptions
502
+ break; // Stop syncing on network exception
383
503
  }
384
504
  }
385
505
  this._isSyncing = false;
386
- if (this._snapshotCallbacks) {
387
- Object.values(this._snapshotCallbacks).forEach((cb) => cb());
388
- }
506
+ this._notifySnapshotCallbacks('*', 'bulk_update', null);
389
507
  }
390
508
  _ensureFirestoreSSE() {
391
509
  this.connectRealtime();
@@ -394,7 +512,7 @@ class NexaApp {
394
512
  this._firestoreListening = true;
395
513
  this._sseCallbacks.firestore_changed.push(async (change) => {
396
514
  if (change.type === 'bulk_update') {
397
- Object.values(this._snapshotCallbacks).forEach((cb) => cb());
515
+ this._notifySnapshotCallbacks('*', 'bulk_update', null);
398
516
  return;
399
517
  }
400
518
  if (change.type === 'document_written') {
@@ -403,27 +521,37 @@ class NexaApp {
403
521
  else if (change.type === 'document_deleted') {
404
522
  await this._deleteCache(change.docPath);
405
523
  }
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
- });
524
+ this._notifySnapshotCallbacks(change.docPath, change.type, change.data);
414
525
  });
415
526
  }
416
527
  }
417
528
  exports.NexaApp = NexaApp;
418
529
  // -----------------------------------------------------------------
419
- // Modular Firestore-like APIs (Firebase Modular pattern match)
530
+ // Static NexaBase Initialization Helper
531
+ // -----------------------------------------------------------------
532
+ exports.NexaBase = {
533
+ init: (config) => {
534
+ const app = new NexaApp(config);
535
+ if (app.enablePersistence) {
536
+ app._initPromise = app._initFirestoreState();
537
+ }
538
+ return app;
539
+ },
540
+ initializeApp: (config) => {
541
+ return exports.NexaBase.init(config);
542
+ }
543
+ };
544
+ // -----------------------------------------------------------------
545
+ // Modular Firestore-like APIs (Firebase Modular pattern)
420
546
  // -----------------------------------------------------------------
421
547
  const initializeApp = (config) => {
422
- return new NexaApp(config);
548
+ return exports.NexaBase.init(config);
423
549
  };
424
550
  exports.initializeApp = initializeApp;
425
551
  const getFirestore = (app) => {
426
- app._initPromise = app._initFirestoreState();
552
+ if (app.enablePersistence) {
553
+ app._initPromise = app._initFirestoreState();
554
+ }
427
555
  return app;
428
556
  };
429
557
  exports.getFirestore = getFirestore;
@@ -432,10 +560,15 @@ const collection = (db, collectionPath) => {
432
560
  };
433
561
  exports.collection = collection;
434
562
  const doc = (dbOrCollection, ...pathSegments) => {
563
+ let docRef;
435
564
  if ('type' in dbOrCollection && dbOrCollection.type === 'collection') {
436
- return { type: 'document', path: `${dbOrCollection.path}/${pathSegments.join('/')}`, db: dbOrCollection.db };
565
+ docRef = { type: 'document', path: `${dbOrCollection.path}/${pathSegments.join('/')}`, db: dbOrCollection.db };
566
+ }
567
+ else {
568
+ docRef = { type: 'document', path: pathSegments.join('/'), db: dbOrCollection };
437
569
  }
438
- return { type: 'document', path: pathSegments.join('/'), db: dbOrCollection };
570
+ docRef.patch = (data) => (0, exports.patchDoc)(docRef, data);
571
+ return docRef;
439
572
  };
440
573
  exports.doc = doc;
441
574
  const query = (queryObject, ...queryConstraints) => {
@@ -461,7 +594,7 @@ const limit = (limitValue) => {
461
594
  return { type: 'limit', limitValue };
462
595
  };
463
596
  exports.limit = limit;
464
- // 1. Helper to retrieve docs from the local Offline cache (Cache-First)
597
+ // Helper to retrieve docs from local cache
465
598
  const getCachedDocs = (queryOrCollection) => {
466
599
  const db = queryOrCollection.db;
467
600
  const collectionPath = queryOrCollection.path;
@@ -480,7 +613,6 @@ const getCachedDocs = (queryOrCollection) => {
480
613
  });
481
614
  }
482
615
  });
483
- // Simple offline filters
484
616
  let filtered = results;
485
617
  for (const c of constraints) {
486
618
  if (c.type === 'where' && c.fieldPath && c.opStr) {
@@ -507,11 +639,10 @@ const getCachedDocs = (queryOrCollection) => {
507
639
  forEach: (cb) => filtered.forEach(cb),
508
640
  empty: filtered.length === 0,
509
641
  size: filtered.length,
510
- docChanges: () => filtered.map(doc => ({ type: 'added', doc }))
642
+ docChanges: () => filtered.map((d) => ({ type: 'added', doc: d }))
511
643
  };
512
644
  };
513
645
  exports.getCachedDocs = getCachedDocs;
514
- // 1. Helper to retrieve single doc from the local Offline cache (Cache-First)
515
646
  const getCachedDoc = (docRef) => {
516
647
  const db = docRef.db;
517
648
  const segments = docRef.path.split('/');
@@ -554,7 +685,7 @@ const getDocs = async (queryOrCollection) => {
554
685
  forEach: (cb) => docsArray.forEach(cb),
555
686
  empty: docsArray.length === 0,
556
687
  size: docsArray.length,
557
- docChanges: () => docsArray.map(doc => ({ type: 'added', doc }))
688
+ docChanges: () => docsArray.map((d) => ({ type: 'added', doc: d }))
558
689
  };
559
690
  }
560
691
  catch (err) {
@@ -590,87 +721,87 @@ const getDoc = async (docRef) => {
590
721
  }
591
722
  };
592
723
  exports.getDoc = getDoc;
593
- const setDoc = async (docRef, data) => {
724
+ // -----------------------------------------------------------------
725
+ // Atomic Document Mutation APIs with Idempotency & Partial Patching
726
+ // -----------------------------------------------------------------
727
+ const setDoc = async (docRef, data, options) => {
594
728
  const db = docRef.db;
595
729
  if (db._initPromise)
596
730
  await db._initPromise;
597
731
  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
- });
732
+ // Broadcast cross-tab and notify snapshot listeners
733
+ db._broadcastLocalMutation(docRef.path, 'set', data);
734
+ db._notifySnapshotCallbacks(docRef.path, 'document_written', data);
735
+ const key = options?.idempotencyKey || generateIdempotencyKey();
607
736
  const jobId = Math.random().toString(36).substring(2, 9);
608
737
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
609
738
  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' };
739
+ await db._addOfflineJob({ id: jobId, type: 'set', path: docRef.path, data, idempotencyKey: key });
740
+ return { success: true, message: 'Offline: Tersimpan di cache lokal' };
612
741
  }
613
742
  else {
614
743
  try {
615
- return (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data })).data;
744
+ return (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key }, { headers: { 'X-Idempotency-Key': key } })).data;
616
745
  }
617
746
  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' };
747
+ await db._addOfflineJob({ id: jobId, type: 'set', path: docRef.path, data, idempotencyKey: key });
748
+ return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
620
749
  }
621
750
  }
622
751
  };
623
752
  exports.setDoc = setDoc;
624
- const updateDoc = async (docRef, data) => {
753
+ // Partial Update (UpdateDoc) & Atomic Patch (PatchDoc)
754
+ const patchDoc = async (docRef, data, options) => {
625
755
  const db = docRef.db;
626
756
  if (db._initPromise)
627
757
  await db._initPromise;
628
- const jobId = Math.random().toString(36).substring(2, 9);
629
758
  const existingData = db._firestoreCache[docRef.path] || {};
630
759
  const updatedData = { ...existingData, ...data };
631
760
  await db._setCache(docRef.path, updatedData);
761
+ db._broadcastLocalMutation(docRef.path, 'patch', data);
762
+ db._notifySnapshotCallbacks(docRef.path, 'document_written', updatedData);
763
+ const key = options?.idempotencyKey || generateIdempotencyKey();
764
+ const jobId = Math.random().toString(36).substring(2, 9);
632
765
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
633
766
  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' };
767
+ await db._addOfflineJob({ id: jobId, type: 'patch', path: docRef.path, data, idempotencyKey: key });
768
+ return { success: true, message: 'Offline: Tersimpan di cache lokal' };
636
769
  }
637
770
  else {
638
771
  try {
639
- return (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data })).data;
772
+ return (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key }, { headers: { 'X-Idempotency-Key': key } })).data;
640
773
  }
641
774
  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' };
775
+ await db._addOfflineJob({ id: jobId, type: 'patch', path: docRef.path, data, idempotencyKey: key });
776
+ return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
644
777
  }
645
778
  }
646
779
  };
780
+ exports.patchDoc = patchDoc;
781
+ const updateDoc = async (docRef, data, options) => {
782
+ return (0, exports.patchDoc)(docRef, data, options);
783
+ };
647
784
  exports.updateDoc = updateDoc;
648
- const deleteDoc = async (docRef) => {
785
+ const deleteDoc = async (docRef, options) => {
649
786
  const db = docRef.db;
650
787
  if (db._initPromise)
651
788
  await db._initPromise;
652
789
  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
- });
790
+ db._broadcastLocalMutation(docRef.path, 'delete');
791
+ db._notifySnapshotCallbacks(docRef.path, 'document_deleted', null);
792
+ const key = options?.idempotencyKey || generateIdempotencyKey();
662
793
  const jobId = Math.random().toString(36).substring(2, 9);
663
794
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
664
795
  if (!isOnline) {
665
- await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path });
796
+ await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path, idempotencyKey: key });
666
797
  return { success: true };
667
798
  }
668
799
  else {
669
800
  try {
670
- return (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`)).data;
801
+ return (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`, { headers: { 'X-Idempotency-Key': key } })).data;
671
802
  }
672
803
  catch (error) {
673
- await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path });
804
+ await db._addOfflineJob({ id: jobId, type: 'delete', path: docRef.path, idempotencyKey: key });
674
805
  return { success: true };
675
806
  }
676
807
  }
@@ -683,7 +814,7 @@ const onSnapshot = (ref, callback) => {
683
814
  await db._initPromise;
684
815
  db._ensureFirestoreSSE();
685
816
  const pathKey = ref.path;
686
- // 1. Native Offline Cache/Persistence Priority: Emits cached snapshot IMMEDIATELY to trigger instant optimistic rendering!
817
+ // Fast cache emit
687
818
  if (ref.type === 'collection' || ref.type === 'query') {
688
819
  const cached = (0, exports.getCachedDocs)(ref);
689
820
  if (cached && cached.docs.length > 0) {
@@ -702,7 +833,6 @@ const onSnapshot = (ref, callback) => {
702
833
  clearTimeout(debounceTimer);
703
834
  debounceTimer = setTimeout(async () => {
704
835
  if (ref.type === 'collection' || ref.type === 'query') {
705
- // Immediately serve from cache for UI responsiveness
706
836
  callback((0, exports.getCachedDocs)(ref));
707
837
  try {
708
838
  const docs = await (0, exports.getDocs)(ref);
@@ -718,9 +848,8 @@ const onSnapshot = (ref, callback) => {
718
848
  }
719
849
  catch (e) { }
720
850
  }
721
- }, 50); // Debounce to batch rapid changes (fast enough for manual scans)
851
+ }, 50); // Debounce to batch rapid updates
722
852
  };
723
- // 2. Fetch the latest live server data and update cache + trigger the callback again
724
853
  triggerCallback();
725
854
  const listenerId = Math.random().toString(36).substring(2, 9);
726
855
  db._snapshotCallbacks[`${ref.type}_${listenerId}_${pathKey}`] = triggerCallback;
@@ -735,13 +864,14 @@ const onSnapshot = (ref, callback) => {
735
864
  return () => unsubscribe();
736
865
  };
737
866
  exports.onSnapshot = onSnapshot;
738
- // Supporting standard Firebase modular options functions
739
867
  const enableIndexedDbPersistence = async (db) => {
868
+ db.enablePersistence = true;
740
869
  if (db._initPromise)
741
870
  await db._initPromise;
742
871
  };
743
872
  exports.enableIndexedDbPersistence = enableIndexedDbPersistence;
744
873
  const enableOfflinePersistence = async (db) => {
874
+ db.enablePersistence = true;
745
875
  if (db._initPromise)
746
876
  await db._initPromise;
747
877
  };
@@ -757,6 +887,10 @@ const writeBatch = (db) => {
757
887
  operations.push({ type: 'update', path: docRef.path, data });
758
888
  return batchObj;
759
889
  },
890
+ patch: (docRef, data) => {
891
+ operations.push({ type: 'update', path: docRef.path, data });
892
+ return batchObj;
893
+ },
760
894
  delete: (docRef) => {
761
895
  operations.push({ type: 'delete', path: docRef.path });
762
896
  return batchObj;
@@ -766,31 +900,20 @@ const writeBatch = (db) => {
766
900
  await db._initPromise;
767
901
  if (operations.length === 0)
768
902
  return;
903
+ const idempotencyKey = generateIdempotencyKey();
769
904
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
770
905
  // Update local cache optimistically
771
906
  for (const op of operations) {
772
907
  if (op.type === 'set' || op.type === 'update') {
773
908
  const newData = { ...db._firestoreCache[op.path], ...op.data };
774
909
  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
- });
910
+ db._broadcastLocalMutation(op.path, op.type === 'set' ? 'set' : 'patch', op.data);
911
+ db._notifySnapshotCallbacks(op.path, 'document_written', db._firestoreCache[op.path]);
783
912
  }
784
913
  else if (op.type === 'delete') {
785
914
  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
- });
915
+ db._broadcastLocalMutation(op.path, 'delete');
916
+ db._notifySnapshotCallbacks(op.path, 'document_deleted', null);
794
917
  }
795
918
  }
796
919
  if (!isOnline) {
@@ -799,14 +922,15 @@ const writeBatch = (db) => {
799
922
  id: Math.random().toString(36).substring(2, 9),
800
923
  type: op.type,
801
924
  path: op.path,
802
- data: op.data
925
+ data: op.data,
926
+ idempotencyKey
803
927
  });
804
928
  }
805
929
  return { success: true, message: 'Offline: Batch operations queued in IndexedDB' };
806
930
  }
807
931
  else {
808
932
  try {
809
- return (await db.client.post(`/api/firestore/${db.projectId}/batch`, { operations })).data;
933
+ return (await db.client.post(`/api/firestore/${db.projectId}/batch`, { operations, idempotencyKey }, { headers: { 'X-Idempotency-Key': idempotencyKey } })).data;
810
934
  }
811
935
  catch (error) {
812
936
  for (const op of operations) {
@@ -814,10 +938,11 @@ const writeBatch = (db) => {
814
938
  id: Math.random().toString(36).substring(2, 9),
815
939
  type: op.type,
816
940
  path: op.path,
817
- data: op.data
941
+ data: op.data,
942
+ idempotencyKey
818
943
  });
819
944
  }
820
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline IndexedDB' };
945
+ return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
821
946
  }
822
947
  }
823
948
  }
@@ -834,6 +959,7 @@ const runTransaction = async (db, updateFunction, options) => {
834
959
  attempt++;
835
960
  const reads = [];
836
961
  const operations = [];
962
+ const idempotencyKey = generateIdempotencyKey();
837
963
  const transaction = {
838
964
  get: async (docRef) => {
839
965
  const segments = docRef.path.split('/');
@@ -856,6 +982,10 @@ const runTransaction = async (db, updateFunction, options) => {
856
982
  operations.push({ type: 'update', path: docRef.path, data });
857
983
  return transaction;
858
984
  },
985
+ patch: (docRef, data) => {
986
+ operations.push({ type: 'update', path: docRef.path, data });
987
+ return transaction;
988
+ },
859
989
  delete: (docRef) => {
860
990
  operations.push({ type: 'delete', path: docRef.path });
861
991
  return transaction;
@@ -864,34 +994,19 @@ const runTransaction = async (db, updateFunction, options) => {
864
994
  try {
865
995
  const result = await updateFunction(transaction);
866
996
  if (operations.length > 0 || reads.length > 0) {
867
- await db.client.post(`/api/firestore/${db.projectId}/transaction`, {
868
- reads,
869
- operations
870
- });
997
+ await db.client.post(`/api/firestore/${db.projectId}/transaction`, { reads, operations, idempotencyKey }, { headers: { 'X-Idempotency-Key': idempotencyKey } });
871
998
  }
872
999
  for (const op of operations) {
873
1000
  if (op.type === 'set' || op.type === 'update') {
874
1001
  const newData = { ...db._firestoreCache[op.path], ...op.data };
875
1002
  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
- });
1003
+ db._broadcastLocalMutation(op.path, op.type === 'set' ? 'set' : 'patch', op.data);
1004
+ db._notifySnapshotCallbacks(op.path, 'document_written', db._firestoreCache[op.path]);
884
1005
  }
885
1006
  else if (op.type === 'delete') {
886
1007
  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
- });
1008
+ db._broadcastLocalMutation(op.path, 'delete');
1009
+ db._notifySnapshotCallbacks(op.path, 'document_deleted', null);
895
1010
  }
896
1011
  }
897
1012
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexabase-console",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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",