nexabase-console 2.0.7 → 2.0.9

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.
@@ -2,154 +2,208 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.deleteDoc = exports.updateDoc = exports.patchDoc = exports.setDoc = exports.FieldValue = void 0;
4
4
  const helpers_1 = require("../utils/helpers");
5
- const NexaError_1 = require("../errors/NexaError");
6
5
  var FieldValue_1 = require("./FieldValue");
7
6
  Object.defineProperty(exports, "FieldValue", { enumerable: true, get: function () { return FieldValue_1.FieldValue; } });
8
- const setDoc = async (docRef, data, options) => {
7
+ const normalizeError = (error) => {
8
+ const status = error?.response?.status;
9
+ let code = error?.code || 'internal';
10
+ let retryable = false;
11
+ if (code === 'ERR_NETWORK')
12
+ code = 'network-error';
13
+ if (code === 'ECONNABORTED' || code === 'ETIMEDOUT')
14
+ code = 'timeout';
15
+ if (code === 'ERR_CANCELED')
16
+ code = 'aborted';
17
+ if (status) {
18
+ if ([408, 425, 429, 500, 502, 503, 504].includes(status))
19
+ retryable = true;
20
+ }
21
+ else if (['network-error', 'timeout', 'unavailable', 'aborted', 'resource-exhausted'].includes(code)) {
22
+ retryable = true;
23
+ }
24
+ if (['invalid-argument', 'permission-denied', 'unauthenticated', 'not-found', 'failed-precondition', 'already-exists'].includes(code)) {
25
+ retryable = false;
26
+ }
27
+ return {
28
+ code,
29
+ status,
30
+ message: error?.message || 'Unknown error',
31
+ retryable,
32
+ source: error?.isAxiosError ? 'http' : (error?.source || 'internal')
33
+ };
34
+ };
35
+ const safelyNotifyMutation = (db, path, action, data, metadata) => {
36
+ try {
37
+ db._broadcastLocalMutation(path, action, data, metadata);
38
+ }
39
+ catch (e) {
40
+ console.error('[NexaBase] Listener error:', e);
41
+ }
42
+ try {
43
+ db._notifySnapshotCallbacks(path, action === 'delete' ? 'document_deleted' : 'document_written', data, metadata);
44
+ }
45
+ catch (e) {
46
+ console.error('[NexaBase] Listener error:', e);
47
+ }
48
+ };
49
+ const executeWriteMutation = async (docRef, action, data, options) => {
50
+ if (!docRef || !docRef.path || !docRef.db || typeof docRef.path !== 'string' || docRef.path.split('/').length % 2 !== 0) {
51
+ throw Object.assign(new Error('Invalid DocumentReference'), { code: 'invalid-argument' });
52
+ }
53
+ if (action !== 'delete' && (!data || typeof data !== 'object' || Array.isArray(data))) {
54
+ throw Object.assign(new Error('Data must be a valid plain object'), { code: 'invalid-argument' });
55
+ }
9
56
  const db = docRef.db;
10
57
  if (db._initPromise)
11
58
  await db._initPromise;
12
- const existingData = options?.merge ? db._firestoreCache[docRef.path] || {} : {};
13
- const finalData = (0, helpers_1.applyDataTransforms)(existingData, data);
14
59
  const key = options?.idempotencyKey || (0, helpers_1.generateIdempotencyKey)();
15
- const jobId = Math.random().toString(36).substring(2, 9);
60
+ const mutationId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
61
+ ? crypto.randomUUID()
62
+ : (0, helpers_1.generateIdempotencyKey)();
63
+ const job = {
64
+ id: mutationId,
65
+ type: action,
66
+ path: docRef.path,
67
+ data: data,
68
+ idempotencyKey: key,
69
+ merge: options?.merge,
70
+ state: 'pending',
71
+ timestamp: Date.now(),
72
+ sequenceNumber: await db.localStore.nextMutationSequence(),
73
+ leaseOwner: 'foreground',
74
+ leaseExpiresAt: Date.now() + 30000
75
+ };
76
+ if (options?.preconditionExists) {
77
+ job.precondition = { exists: true };
78
+ }
79
+ db.markInflight(docRef.path, mutationId);
80
+ // Phase 1: Local Commit
81
+ let localView;
82
+ try {
83
+ localView = await db._commitAtomicMutation(docRef.path, action, job);
84
+ }
85
+ catch (error) {
86
+ db.unmarkInflight(docRef.path, mutationId);
87
+ throw Object.assign(new Error('Local persistence failed'), { code: 'internal', cause: error });
88
+ }
89
+ safelyNotifyMutation(db, docRef.path, action, localView.data, {
90
+ mutationId,
91
+ source: 'local',
92
+ state: 'pending',
93
+ hasPendingWrites: localView.hasPendingWrites
94
+ });
16
95
  const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
17
- db.markInflight(docRef.path);
18
- await db._setCache(docRef.path, finalData);
19
- db._broadcastLocalMutation(docRef.path, 'set', finalData);
20
- db._notifySnapshotCallbacks(docRef.path, 'document_written', finalData);
21
96
  if (!isOnline) {
22
- db.unmarkInflight(docRef.path);
23
- await db._addOfflineJob({
24
- id: jobId, type: 'set', path: docRef.path, data, idempotencyKey: key, merge: options?.merge, state: 'pending'
25
- });
26
- return { success: true, message: 'Offline: Tersimpan di cache lokal' };
97
+ db.unmarkInflight(docRef.path, mutationId);
98
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
27
99
  }
28
- else {
29
- try {
30
- const res = (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key, merge: options?.merge }, { headers: { 'X-Idempotency-Key': key } })).data;
31
- return res;
32
- }
33
- catch (error) {
34
- if (error && error.response && error.response.status >= 400 && error.response.status < 500) {
35
- throw (0, NexaError_1.toNexaError)(error);
36
- }
37
- await db._addOfflineJob({
38
- id: jobId,
39
- type: 'set',
40
- path: docRef.path,
100
+ // Phase 2: Network Send
101
+ let res;
102
+ let networkError = null;
103
+ try {
104
+ if (db.wsClient && db.wsClient.isConnected) {
105
+ res = await db.wsClient.send('firestore_write', {
106
+ action: action === 'patch' ? 'update' : action,
107
+ docPath: docRef.path,
108
+ projectId: db.projectId,
41
109
  data,
42
- idempotencyKey: key,
43
110
  merge: options?.merge,
44
- state: 'pending'
111
+ idempotencyKey: key,
112
+ preconditionExists: options?.preconditionExists
45
113
  });
46
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
47
114
  }
48
- finally {
49
- db.unmarkInflight(docRef.path);
115
+ else {
116
+ const headers = { 'X-Idempotency-Key': key };
117
+ if (action === 'set') {
118
+ res = (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key, merge: options?.merge }, { headers })).data;
119
+ }
120
+ else if (action === 'patch') {
121
+ res = (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key, preconditionExists: options?.preconditionExists }, { headers })).data;
122
+ }
123
+ else {
124
+ res = (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`, { headers })).data;
125
+ }
50
126
  }
51
127
  }
52
- };
53
- exports.setDoc = setDoc;
54
- const patchDoc = async (docRef, data, options) => {
55
- const db = docRef.db;
56
- if (db._initPromise)
57
- await db._initPromise;
58
- const existingData = db._firestoreCache[docRef.path] || {};
59
- const finalData = (0, helpers_1.applyDataTransforms)(existingData, data);
60
- const key = options?.idempotencyKey || (0, helpers_1.generateIdempotencyKey)();
61
- const jobId = Math.random().toString(36).substring(2, 9);
62
- const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
63
- db.markInflight(docRef.path);
64
- await db._setCache(docRef.path, finalData);
65
- db._broadcastLocalMutation(docRef.path, 'patch', finalData);
66
- db._notifySnapshotCallbacks(docRef.path, 'document_written', finalData);
67
- if (!isOnline) {
68
- db.unmarkInflight(docRef.path);
69
- await db._addOfflineJob({
70
- id: jobId,
71
- type: 'patch',
72
- path: docRef.path,
73
- data,
74
- idempotencyKey: key,
75
- state: 'pending'
76
- });
77
- return { success: true, message: 'Offline: Tersimpan di cache lokal' };
128
+ catch (err) {
129
+ networkError = err;
78
130
  }
79
- else {
80
- db.markInflight(docRef.path);
81
- try {
82
- const res = (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key }, { headers: { 'X-Idempotency-Key': key } })).data;
83
- return res;
131
+ // Phase 3: Server Error Handling
132
+ if (networkError) {
133
+ const normErr = normalizeError(networkError);
134
+ if (normErr.retryable) {
135
+ db.unmarkInflight(docRef.path, mutationId);
136
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
84
137
  }
85
- catch (error) {
86
- if (error && error.response && error.response.status >= 400 && error.response.status < 500) {
87
- throw (0, NexaError_1.toNexaError)(error);
138
+ else {
139
+ let rebasedView;
140
+ try {
141
+ rebasedView = await db._rejectMutationAtomically({
142
+ mutationId,
143
+ path: docRef.path,
144
+ error: normErr
145
+ });
88
146
  }
89
- await db._addOfflineJob({
90
- id: jobId,
91
- type: 'patch',
92
- path: docRef.path,
93
- data,
94
- idempotencyKey: key,
95
- state: 'pending'
147
+ catch (rejectionError) {
148
+ db.unmarkInflight(docRef.path, mutationId);
149
+ throw Object.assign(new Error('Server rejected mutation, but local rollback failed'), { code: normErr.code, cause: rejectionError, originalError: networkError });
150
+ }
151
+ db.unmarkInflight(docRef.path, mutationId);
152
+ safelyNotifyMutation(db, docRef.path, rebasedView.data ? (action === 'delete' ? 'set' : action) : 'delete', rebasedView.data, {
153
+ mutationId,
154
+ source: 'local',
155
+ state: 'rejected',
156
+ hasPendingWrites: rebasedView.hasPendingWrites
96
157
  });
97
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
98
- }
99
- finally {
100
- db.unmarkInflight(docRef.path);
158
+ throw Object.assign(new Error(normErr.message), { code: normErr.code, cause: networkError });
101
159
  }
102
160
  }
161
+ // Phase 4: ACK Validation
162
+ const isValidAck = res && res.success;
163
+ if (!isValidAck) {
164
+ console.error('[NexaBase] Protocol error: Invalid server ACK:', res);
165
+ db.unmarkInflight(docRef.path, mutationId);
166
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
167
+ }
168
+ // Phase 5: Atomic Local ACK
169
+ let ackView;
170
+ try {
171
+ ackView = await db._commitMutationAck({
172
+ mutationId,
173
+ path: docRef.path,
174
+ action,
175
+ serverDocument: res.document,
176
+ updateTime: res.document?.updateTime
177
+ });
178
+ }
179
+ catch (ackError) {
180
+ console.error('[NexaBase] Failed to persist ACK locally, mutation remains pending:', ackError);
181
+ db.unmarkInflight(docRef.path, mutationId);
182
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
183
+ }
184
+ // Phase 6: Notification
185
+ db.unmarkInflight(docRef.path, mutationId);
186
+ safelyNotifyMutation(db, docRef.path, action, ackView.data, {
187
+ mutationId,
188
+ source: 'server',
189
+ state: 'acknowledged',
190
+ hasPendingWrites: ackView.hasPendingWrites
191
+ });
192
+ return { status: 'committed', committed: true, writtenLocally: true, mutationId, data: ackView.data };
193
+ };
194
+ const setDoc = async (docRef, data, options) => {
195
+ return executeWriteMutation(docRef, 'set', data, options);
196
+ };
197
+ exports.setDoc = setDoc;
198
+ const patchDoc = async (docRef, data, options) => {
199
+ return executeWriteMutation(docRef, 'patch', data, options);
103
200
  };
104
201
  exports.patchDoc = patchDoc;
105
202
  const updateDoc = async (docRef, data, options) => {
106
- return (0, exports.patchDoc)(docRef, data, options);
203
+ return (0, exports.patchDoc)(docRef, data, { ...options, preconditionExists: true });
107
204
  };
108
205
  exports.updateDoc = updateDoc;
109
206
  const deleteDoc = async (docRef, options) => {
110
- const db = docRef.db;
111
- if (db._initPromise)
112
- await db._initPromise;
113
- const key = options?.idempotencyKey || (0, helpers_1.generateIdempotencyKey)();
114
- const jobId = Math.random().toString(36).substring(2, 9);
115
- const isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
116
- db.markInflight(docRef.path);
117
- await db._deleteCache(docRef.path);
118
- db._broadcastLocalMutation(docRef.path, 'delete');
119
- db._notifySnapshotCallbacks(docRef.path, 'document_deleted', null);
120
- if (!isOnline) {
121
- db.unmarkInflight(docRef.path);
122
- await db._addOfflineJob({
123
- id: jobId,
124
- type: 'delete',
125
- path: docRef.path,
126
- idempotencyKey: key,
127
- state: 'pending'
128
- });
129
- return { success: true };
130
- }
131
- else {
132
- db.markInflight(docRef.path);
133
- try {
134
- const res = (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`, { headers: { 'X-Idempotency-Key': key } })).data;
135
- return res;
136
- }
137
- catch (error) {
138
- if (error && error.response && error.response.status >= 400 && error.response.status < 500) {
139
- throw (0, NexaError_1.toNexaError)(error);
140
- }
141
- await db._addOfflineJob({
142
- id: jobId,
143
- type: 'delete',
144
- path: docRef.path,
145
- idempotencyKey: key,
146
- state: 'pending'
147
- });
148
- return { success: true };
149
- }
150
- finally {
151
- db.unmarkInflight(docRef.path);
152
- }
153
- }
207
+ return executeWriteMutation(docRef, 'delete', null, options);
154
208
  };
155
209
  exports.deleteDoc = deleteDoc;
@@ -2,7 +2,11 @@ import { IndexedDB } from './IndexedDB';
2
2
  export declare class LocalStore {
3
3
  private indexedDB;
4
4
  private inMemoryCache;
5
+ private inMemoryServerBase;
6
+ private currentSequence;
5
7
  constructor(indexedDB: IndexedDB);
8
+ nextMutationSequence(): Promise<number>;
9
+ getServerBaseCache(): Record<string, any>;
6
10
  set(path: string, data: any): Promise<void>;
7
11
  getInMemory(path: string): any;
8
12
  getMemoryCache(): Record<string, any>;
@@ -4,8 +4,17 @@ exports.LocalStore = void 0;
4
4
  class LocalStore {
5
5
  constructor(indexedDB) {
6
6
  this.inMemoryCache = {};
7
+ this.inMemoryServerBase = {};
8
+ this.currentSequence = 0;
7
9
  this.indexedDB = indexedDB;
8
10
  }
11
+ async nextMutationSequence() {
12
+ this.currentSequence++;
13
+ return this.currentSequence;
14
+ }
15
+ getServerBaseCache() {
16
+ return this.inMemoryServerBase;
17
+ }
9
18
  async set(path, data) {
10
19
  this.inMemoryCache[path] = data;
11
20
  const db = this.indexedDB.getRawDB();
@@ -14,7 +23,7 @@ class LocalStore {
14
23
  return new Promise((resolve, reject) => {
15
24
  const tx = db.transaction('offline_cache', 'readwrite');
16
25
  const store = tx.objectStore('offline_cache');
17
- const req = store.put({ path, data, timestamp: Date.now() });
26
+ const req = store.put({ path, data, timestamp: Date.now(), serverBase: this.inMemoryServerBase[path] !== undefined ? this.inMemoryServerBase[path] : null });
18
27
  req.onsuccess = () => resolve();
19
28
  req.onerror = () => reject(req.error);
20
29
  });
@@ -39,6 +48,8 @@ class LocalStore {
39
48
  const data = req.result ? req.result.data : null;
40
49
  if (data)
41
50
  this.inMemoryCache[path] = data;
51
+ if (req.result && req.result.serverBase !== undefined)
52
+ this.inMemoryServerBase[path] = req.result.serverBase;
42
53
  resolve(data);
43
54
  };
44
55
  req.onerror = () => reject(req.error);
@@ -46,6 +57,7 @@ class LocalStore {
46
57
  }
47
58
  async delete(path) {
48
59
  delete this.inMemoryCache[path];
60
+ delete this.inMemoryServerBase[path];
49
61
  const db = this.indexedDB.getRawDB();
50
62
  if (!db)
51
63
  return;
@@ -68,13 +80,14 @@ class LocalStore {
68
80
  req.onsuccess = () => {
69
81
  req.result.forEach((item) => {
70
82
  this.inMemoryCache[item.path] = item.data;
83
+ if (item.serverBase !== undefined)
84
+ this.inMemoryServerBase[item.path] = item.serverBase;
71
85
  });
72
86
  resolve(this.inMemoryCache);
73
87
  };
74
88
  req.onerror = () => reject(req.error);
75
89
  });
76
90
  }
77
- // LRU Cache Garbage Collection (Max size 5000 items)
78
91
  async garbageCollect(maxItems = 5000) {
79
92
  const db = this.indexedDB.getRawDB();
80
93
  if (!db)
@@ -86,7 +99,6 @@ class LocalStore {
86
99
  req.onsuccess = () => {
87
100
  const items = req.result;
88
101
  if (items.length > maxItems) {
89
- // Sort by timestamp (oldest first)
90
102
  items.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
91
103
  const itemsToRemove = items.slice(0, items.length - maxItems);
92
104
  if (itemsToRemove.length === 0) {
@@ -97,6 +109,7 @@ class LocalStore {
97
109
  const delReq = store.delete(item.path);
98
110
  delReq.onsuccess = () => {
99
111
  delete this.inMemoryCache[item.path];
112
+ delete this.inMemoryServerBase[item.path];
100
113
  removedCount++;
101
114
  if (removedCount === itemsToRemove.length) {
102
115
  resolve();
@@ -13,9 +13,10 @@ export declare class MutationQueue {
13
13
  loadQueue(): Promise<OfflineJob[]>;
14
14
  addJob(job: OfflineJob): Promise<void>;
15
15
  saveQueue(queue: OfflineJob[]): Promise<void>;
16
+ claimJob(mutationId: string, ownerId: string, leaseDurationMs: number): Promise<OfflineJob | null>;
16
17
  removeJob(jobId: string): Promise<void>;
17
- markInflight(path: string): void;
18
- unmarkInflight(path: string): void;
18
+ markInflight(path: string, mutationId?: string): void;
19
+ unmarkInflight(path: string, mutationId?: string): void;
19
20
  hasPendingWrites(path: string): boolean;
20
21
  getQueue(): OfflineJob[];
21
22
  syncQueue(): Promise<void>;
@@ -4,7 +4,7 @@ exports.MutationQueue = void 0;
4
4
  class MutationQueue {
5
5
  constructor(projectId, indexedDB, client) {
6
6
  this.inMemoryQueue = [];
7
- this.inflightWrites = new Set();
7
+ this.inflightWrites = new Map();
8
8
  this.isSyncing = false;
9
9
  this.projectId = projectId;
10
10
  this.indexedDB = indexedDB;
@@ -71,20 +71,45 @@ class MutationQueue {
71
71
  clearReq.onerror = () => reject(clearReq.error);
72
72
  });
73
73
  }
74
+ async claimJob(mutationId, ownerId, leaseDurationMs) {
75
+ const job = this.inMemoryQueue.find(j => j.id === mutationId);
76
+ if (!job)
77
+ return null;
78
+ const now = Date.now();
79
+ if (job.leaseOwner && job.leaseOwner !== ownerId && job.leaseExpiresAt && job.leaseExpiresAt > now) {
80
+ return null;
81
+ }
82
+ job.leaseOwner = ownerId;
83
+ job.leaseExpiresAt = now + leaseDurationMs;
84
+ await this.saveQueue(this.inMemoryQueue);
85
+ return job;
86
+ }
74
87
  async removeJob(jobId) {
75
88
  const updated = this.inMemoryQueue.filter((j) => j.id !== jobId);
76
89
  await this.saveQueue(updated);
77
90
  }
78
- markInflight(path) {
79
- this.inflightWrites.add(path);
91
+ markInflight(path, mutationId) {
92
+ if (!mutationId)
93
+ return;
94
+ if (!this.inflightWrites.has(path))
95
+ this.inflightWrites.set(path, new Set());
96
+ this.inflightWrites.get(path).add(mutationId);
80
97
  }
81
- unmarkInflight(path) {
82
- this.inflightWrites.delete(path);
98
+ unmarkInflight(path, mutationId) {
99
+ if (!mutationId)
100
+ return;
101
+ const set = this.inflightWrites.get(path);
102
+ if (set) {
103
+ set.delete(mutationId);
104
+ if (set.size === 0) {
105
+ this.inflightWrites.delete(path);
106
+ }
107
+ }
83
108
  }
84
109
  hasPendingWrites(path) {
85
110
  if (this.inflightWrites.has(path))
86
111
  return true;
87
- for (const p of this.inflightWrites) {
112
+ for (const p of Array.from(this.inflightWrites.keys())) {
88
113
  if (p.startsWith(path + '/'))
89
114
  return true;
90
115
  }
@@ -112,7 +137,7 @@ class MutationQueue {
112
137
  merge: job.merge
113
138
  }, { headers });
114
139
  }
115
- else if (job.type === 'patch' || job.type === 'update') {
140
+ else if (job.type === 'patch') {
116
141
  await this.client.patch(`/api/firestore/${this.projectId}/document`, {
117
142
  docPath: job.path,
118
143
  data: job.data,
@@ -140,14 +140,21 @@ export interface WriteBatch {
140
140
  }
141
141
  export interface OfflineJob {
142
142
  id: string;
143
- type: 'set' | 'update' | 'patch' | 'delete';
143
+ type: 'set' | 'patch' | 'delete';
144
144
  path: string;
145
145
  data?: any;
146
146
  idempotencyKey?: string;
147
+ precondition?: {
148
+ exists?: boolean;
149
+ updateTime?: string;
150
+ };
147
151
  timestamp?: number;
148
152
  merge?: boolean;
149
153
  state?: 'pending' | 'acknowledged' | 'rejected';
150
154
  retryCount?: number;
155
+ leaseOwner?: string;
156
+ leaseExpiresAt?: number;
157
+ sequenceNumber?: number;
151
158
  lastError?: string;
152
159
  }
153
160
  export interface Transaction {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexabase-console",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
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",