nexabase-console 2.0.7 → 2.0.8

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,209 @@
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,
41
108
  data,
42
- idempotencyKey: key,
43
109
  merge: options?.merge,
44
- state: 'pending'
110
+ idempotencyKey: key,
111
+ preconditionExists: options?.preconditionExists
45
112
  });
46
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
47
113
  }
48
- finally {
49
- db.unmarkInflight(docRef.path);
114
+ else {
115
+ const headers = { 'X-Idempotency-Key': key };
116
+ if (action === 'set') {
117
+ res = (await db.client.post(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key, merge: options?.merge }, { headers })).data;
118
+ }
119
+ else if (action === 'patch') {
120
+ res = (await db.client.patch(`/api/firestore/${db.projectId}/document`, { docPath: docRef.path, data, idempotencyKey: key, preconditionExists: options?.preconditionExists }, { headers })).data;
121
+ }
122
+ else {
123
+ res = (await db.client.delete(`/api/firestore/${db.projectId}/document?docPath=${encodeURIComponent(docRef.path)}`, { headers })).data;
124
+ }
50
125
  }
51
126
  }
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' };
127
+ catch (err) {
128
+ networkError = err;
78
129
  }
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;
130
+ // Phase 3: Server Error Handling
131
+ if (networkError) {
132
+ const normErr = normalizeError(networkError);
133
+ if (normErr.retryable) {
134
+ db.unmarkInflight(docRef.path, mutationId);
135
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
84
136
  }
85
- catch (error) {
86
- if (error && error.response && error.response.status >= 400 && error.response.status < 500) {
87
- throw (0, NexaError_1.toNexaError)(error);
137
+ else {
138
+ let rebasedView;
139
+ try {
140
+ rebasedView = await db._rejectMutationAtomically({
141
+ mutationId,
142
+ path: docRef.path,
143
+ error: normErr
144
+ });
88
145
  }
89
- await db._addOfflineJob({
90
- id: jobId,
91
- type: 'patch',
92
- path: docRef.path,
93
- data,
94
- idempotencyKey: key,
95
- state: 'pending'
146
+ catch (rejectionError) {
147
+ db.unmarkInflight(docRef.path, mutationId);
148
+ throw Object.assign(new Error('Server rejected mutation, but local rollback failed'), { code: normErr.code, cause: rejectionError, originalError: networkError });
149
+ }
150
+ db.unmarkInflight(docRef.path, mutationId);
151
+ safelyNotifyMutation(db, docRef.path, rebasedView.data ? (action === 'delete' ? 'set' : action) : 'delete', rebasedView.data, {
152
+ mutationId,
153
+ source: 'local',
154
+ state: 'rejected',
155
+ hasPendingWrites: rebasedView.hasPendingWrites
96
156
  });
97
- return { success: true, message: 'Koneksi terganggu: Tersimpan di antrean offline' };
98
- }
99
- finally {
100
- db.unmarkInflight(docRef.path);
157
+ throw Object.assign(new Error(normErr.message), { code: normErr.code, cause: networkError });
101
158
  }
102
159
  }
160
+ // Phase 4: ACK Validation
161
+ const isValidAck = res && res.success &&
162
+ (action === 'delete' || (res.document && res.document.fields)) &&
163
+ (action !== 'delete' ? res.document.path === docRef.path : true);
164
+ if (!isValidAck) {
165
+ console.error('[NexaBase] Protocol error: Invalid server ACK:', res);
166
+ db.unmarkInflight(docRef.path, mutationId);
167
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
168
+ }
169
+ // Phase 5: Atomic Local ACK
170
+ let ackView;
171
+ try {
172
+ ackView = await db._commitMutationAck({
173
+ mutationId,
174
+ path: docRef.path,
175
+ action,
176
+ serverDocument: res.document,
177
+ updateTime: res.document?.updateTime
178
+ });
179
+ }
180
+ catch (ackError) {
181
+ console.error('[NexaBase] Failed to persist ACK locally, mutation remains pending:', ackError);
182
+ db.unmarkInflight(docRef.path, mutationId);
183
+ return { status: 'pending', committed: false, writtenLocally: true, mutationId };
184
+ }
185
+ // Phase 6: Notification
186
+ db.unmarkInflight(docRef.path, mutationId);
187
+ safelyNotifyMutation(db, docRef.path, action, ackView.data, {
188
+ mutationId,
189
+ source: 'server',
190
+ state: 'acknowledged',
191
+ hasPendingWrites: ackView.hasPendingWrites
192
+ });
193
+ return { status: 'committed', committed: true, writtenLocally: true, mutationId, data: ackView.data };
194
+ };
195
+ const setDoc = async (docRef, data, options) => {
196
+ return executeWriteMutation(docRef, 'set', data, options);
197
+ };
198
+ exports.setDoc = setDoc;
199
+ const patchDoc = async (docRef, data, options) => {
200
+ return executeWriteMutation(docRef, 'patch', data, options);
103
201
  };
104
202
  exports.patchDoc = patchDoc;
105
203
  const updateDoc = async (docRef, data, options) => {
106
- return (0, exports.patchDoc)(docRef, data, options);
204
+ return (0, exports.patchDoc)(docRef, data, { ...options, preconditionExists: true });
107
205
  };
108
206
  exports.updateDoc = updateDoc;
109
207
  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
- }
208
+ return executeWriteMutation(docRef, 'delete', null, options);
154
209
  };
155
210
  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.8",
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",