nexabase-console 1.0.6 → 1.1.0
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 +111 -9
- package/dist/index.d.ts +15 -2
- package/dist/index.js +95 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,8 +7,9 @@ SDK Client Resmi untuk **NexaBase**: Platform sinkronisasi database modern yang
|
|
|
7
7
|
## 🚀 Fitur Utama
|
|
8
8
|
|
|
9
9
|
- **⚡ Real-Time Synchronization**: Sinkronisasi data real-time dengan latency rendah menggunakan Server-Sent Events (SSE).
|
|
10
|
+
- **🔒 Concurrency Control & Transactions (`runTransaction`)**: Transaksi dua arah (Read-Before-Write) atomik dengan kontrol konkurensi (Optimistic Locking & Automatic Retry) untuk mencegah race conditions pada pemotongan stok, kuota, atau transaksi finansial.
|
|
10
11
|
- **📦 Offline-First & Auto-Sync**: Sinkronisasi state lokal secara otomatis menggunakan **IndexedDB**. Data tetap tersimpan di browser jika koneksi terputus dan akan disinkronkan ke server secara otomatis saat kembali online.
|
|
11
|
-
- **🔥 Modular Firestore API**: Desain API modular modern mirip Firebase SDK (v9+)
|
|
12
|
+
- **🔥 Modular Firestore API & TypeScript Generics**: Desain API modular modern mirip Firebase SDK (v9+) dengan dukungan penuh parameter tipe generik (TypeScript Generics) untuk menjamin tipe data yang aman (type-safe) secara end-to-end.
|
|
12
13
|
- **🔑 Secure Auth & Passwordless OTP**: Dukungan penuh registrasi, login email/sandi, serta OTP instan via SMTP yang aman.
|
|
13
14
|
- **📂 File Storage Integration**: Unggah aset media dan file blob ke cloud storage dengan satu perintah.
|
|
14
15
|
|
|
@@ -109,24 +110,80 @@ const listenLive = () => {
|
|
|
109
110
|
};
|
|
110
111
|
```
|
|
111
112
|
|
|
112
|
-
#### Menggunakan Atomic Write Batch
|
|
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).
|
|
115
|
+
|
|
113
116
|
```javascript
|
|
114
117
|
import { doc, writeBatch } from 'nexabase-console';
|
|
115
118
|
|
|
116
|
-
const
|
|
119
|
+
const executeBulkUpload = async (listResi) => {
|
|
117
120
|
const batch = writeBatch(db);
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// ListResi adalah array berisi objek resi. Maksimal 500 per batch.
|
|
123
|
+
listResi.forEach((resi) => {
|
|
124
|
+
// Referensi dokumen
|
|
125
|
+
const docRef = doc(db, 'resi', resi.nomor_resi);
|
|
126
|
+
batch.set(docRef, resi);
|
|
127
|
+
});
|
|
124
128
|
|
|
129
|
+
// Eksekusi semua secara atomik (bersamaan dalam 1 request)
|
|
125
130
|
const result = await batch.commit();
|
|
126
|
-
console.log(
|
|
131
|
+
console.log(`Berhasil mengunggah ${listResi.length} resi secara massal.`);
|
|
127
132
|
};
|
|
128
133
|
```
|
|
129
134
|
|
|
135
|
+
#### Menggunakan Transactions (`runTransaction`) — Concurrency Control
|
|
136
|
+
```javascript
|
|
137
|
+
import { doc, runTransaction } from 'nexabase-console';
|
|
138
|
+
|
|
139
|
+
async function potongStokAman(productId, variantId, qtyBeli) {
|
|
140
|
+
const productRef = doc(db, 'products', productId);
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const hasil = await runTransaction(db, async (transaction) => {
|
|
144
|
+
// 1. Baca dokumen di dalam transaksi (Read)
|
|
145
|
+
const productSnap = await transaction.get(productRef);
|
|
146
|
+
if (!productSnap.exists()) {
|
|
147
|
+
throw new Error("Produk tidak ditemukan!");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const productData = productSnap.data();
|
|
151
|
+
const variantIndex = productData.variants?.findIndex((v) => v.id === variantId);
|
|
152
|
+
if (variantIndex === -1 || variantIndex === undefined) {
|
|
153
|
+
throw new Error("Varian produk tidak ditemukan!");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const variant = productData.variants[variantIndex];
|
|
157
|
+
const stokSaatIni = variant.stock;
|
|
158
|
+
|
|
159
|
+
// 2. Validasi stok sebelum dipotong
|
|
160
|
+
if (stokSaatIni < qtyBeli) {
|
|
161
|
+
throw new Error(`Stok tidak mencukupi! Tersedia: ${stokSaatIni}, Diminta: ${qtyBeli}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 3. Kalkulasi data baru
|
|
165
|
+
const updatedVariants = [...productData.variants];
|
|
166
|
+
updatedVariants[variantIndex] = {
|
|
167
|
+
...variant,
|
|
168
|
+
stock: stokSaatIni - qtyBeli
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// 4. Tulis perubahan di dalam transaksi (Write)
|
|
172
|
+
transaction.update(productRef, {
|
|
173
|
+
variants: updatedVariants,
|
|
174
|
+
updatedAt: new Date().toISOString()
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return { success: true, sisaStok: stokSaatIni - qtyBeli };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
console.log("Transaksi Berhasil!", hasil);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error("Transaksi Gagal / Conflict:", error.message);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
130
187
|
---
|
|
131
188
|
|
|
132
189
|
### 3. Autentikasi Pengguna & OTP Tanpa Sandi
|
|
@@ -185,6 +242,51 @@ const uploadImage = async (fileBlob) => {
|
|
|
185
242
|
|
|
186
243
|
---
|
|
187
244
|
|
|
245
|
+
### 5. Keamanan Tipe TypeScript (Generics) & Kueri Lanjutan
|
|
246
|
+
|
|
247
|
+
#### Dukungan TypeScript Generics
|
|
248
|
+
Kini Anda bisa mendefinisikan tipe data struktur dokumen Anda agar penulisan dan pembacaan data sepenuhnya aman (Type-safe).
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { collection, doc, getDoc, getDocs } from 'nexabase-console';
|
|
252
|
+
|
|
253
|
+
interface Product {
|
|
254
|
+
name: string;
|
|
255
|
+
price: number;
|
|
256
|
+
stock: number;
|
|
257
|
+
tags: string[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 1. Definisikan tipe koleksi
|
|
261
|
+
const productsCol = collection<Product>(db, 'products');
|
|
262
|
+
|
|
263
|
+
// 2. Definisikan tipe referensi dokumen
|
|
264
|
+
const productRef = doc<Product>(db, 'products', 'prod_a');
|
|
265
|
+
|
|
266
|
+
// 3. Baca data dengan Autocomplete & Type Checking penuh
|
|
267
|
+
const snapshot = await getDoc(productRef);
|
|
268
|
+
if (snapshot.exists()) {
|
|
269
|
+
const data = snapshot.data(); // Tipe data otomatis terdeteksi sebagai 'Product'
|
|
270
|
+
console.log(data.name); // Aman! Autocomplete tersedia
|
|
271
|
+
console.log(data.price); // Aman!
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
#### Kompatibilitas QuerySnapshot (`docChanges()`)
|
|
276
|
+
Bagi Anda yang bermigrasi dari Firebase, objek `QuerySnapshot` yang dikembalikan oleh `getDocs` atau `onSnapshot` kini mendukung metode `.docChanges()` secara native:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const snapshot = await getDocs(productsCol);
|
|
280
|
+
const changes = snapshot.docChanges(); // Mengembalikan array perubahan dokumen
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
#### Filter Offline Lengkap (Advanced Querying Operators)
|
|
284
|
+
Mekanisme kueri offline pada SDK NexaBase kini mendukung operator Firestore tingkat lanjut secara penuh demi keandalan luar biasa bahkan saat koneksi terputus:
|
|
285
|
+
- Operator dasar: `==`, `!=`, `>`, `<`, `>=`, `<=`
|
|
286
|
+
- Operator array & keanggotaan: `in`, `not-in`, `array-contains`, `array-contains-any`
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
188
290
|
## 🗄️ Dukungan Sinkronisasi Offline (IndexedDB)
|
|
189
291
|
|
|
190
292
|
NexaBase JS SDK dikembangkan dengan arsitektur **Offline-First**.
|
package/dist/index.d.ts
CHANGED
|
@@ -58,16 +58,29 @@ export interface QuerySnapshot<T = any> {
|
|
|
58
58
|
docChanges: () => any[];
|
|
59
59
|
}
|
|
60
60
|
export interface DatabaseReference {
|
|
61
|
+
path: string;
|
|
61
62
|
get: () => Promise<any>;
|
|
62
63
|
set: (data: any) => Promise<any>;
|
|
63
|
-
|
|
64
|
+
update: (data: any) => Promise<any>;
|
|
65
|
+
delete: () => Promise<any>;
|
|
66
|
+
remove: () => Promise<any>;
|
|
67
|
+
onDataChanged: (callback: (data: any) => void) => () => void;
|
|
68
|
+
}
|
|
69
|
+
export interface UploadOptions {
|
|
70
|
+
contextType?: 'chat' | 'public_post';
|
|
71
|
+
contextId?: string;
|
|
72
|
+
fileId?: string;
|
|
64
73
|
}
|
|
65
74
|
export interface StorageReference {
|
|
66
75
|
name: string;
|
|
67
76
|
fullPath: string;
|
|
68
|
-
put: (file: File | Blob, onProgress?: (percent: number) => void) => Promise<{
|
|
77
|
+
put: (file: File | Blob, options?: UploadOptions | ((percent: number) => void), onProgress?: (percent: number) => void) => Promise<{
|
|
69
78
|
url: string;
|
|
70
79
|
success: boolean;
|
|
80
|
+
fileId?: string;
|
|
81
|
+
proxyViewUrl?: string;
|
|
82
|
+
proxyDownloadUrl?: string;
|
|
83
|
+
metadata?: any;
|
|
71
84
|
}>;
|
|
72
85
|
delete: () => Promise<{
|
|
73
86
|
success: boolean;
|
package/dist/index.js
CHANGED
|
@@ -85,25 +85,50 @@ class NexaApp {
|
|
|
85
85
|
// -----------------------------------------------------------------
|
|
86
86
|
database() {
|
|
87
87
|
return {
|
|
88
|
-
ref: (path = '') =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
88
|
+
ref: (path = '') => {
|
|
89
|
+
const cleanPath = (path || '').replace(/^\/+|\/+$/g, '');
|
|
90
|
+
return {
|
|
91
|
+
path: cleanPath,
|
|
92
|
+
get: async () => {
|
|
93
|
+
const response = await this.client.get(`/api/db/${this.projectId}/${cleanPath}`);
|
|
94
|
+
return response.data;
|
|
95
|
+
},
|
|
96
|
+
set: async (data) => {
|
|
97
|
+
const payload = data instanceof Map ? Object.fromEntries(data) : data;
|
|
98
|
+
const response = await this.client.put(`/api/db/${this.projectId}/${cleanPath}`, payload);
|
|
99
|
+
return response.data;
|
|
100
|
+
},
|
|
101
|
+
update: async (data) => {
|
|
102
|
+
const payload = data instanceof Map ? Object.fromEntries(data) : data;
|
|
103
|
+
const response = await this.client.patch(`/api/db/${this.projectId}/${cleanPath}`, payload);
|
|
104
|
+
return response.data;
|
|
105
|
+
},
|
|
106
|
+
delete: async () => {
|
|
107
|
+
const response = await this.client.put(`/api/db/${this.projectId}/${cleanPath}`, null);
|
|
108
|
+
return response.data;
|
|
109
|
+
},
|
|
110
|
+
remove: async () => {
|
|
111
|
+
const response = await this.client.put(`/api/db/${this.projectId}/${cleanPath}`, null);
|
|
112
|
+
return response.data;
|
|
113
|
+
},
|
|
114
|
+
onDataChanged: (callback) => {
|
|
115
|
+
this.connectRealtime();
|
|
116
|
+
const handler = (payload) => {
|
|
117
|
+
const payloadPath = (payload.path || '').replace(/^\/+|\/+$/g, '');
|
|
118
|
+
if (payloadPath === cleanPath ||
|
|
119
|
+
payloadPath.startsWith(cleanPath + '/') ||
|
|
120
|
+
cleanPath === '' ||
|
|
121
|
+
cleanPath.startsWith(payloadPath + '/')) {
|
|
122
|
+
callback(payload.data);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
this._sseCallbacks.data_changed.push(handler);
|
|
126
|
+
return () => {
|
|
127
|
+
this._sseCallbacks.data_changed = this._sseCallbacks.data_changed.filter((cb) => cb !== handler);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
107
132
|
};
|
|
108
133
|
}
|
|
109
134
|
// -----------------------------------------------------------------
|
|
@@ -167,19 +192,43 @@ class NexaApp {
|
|
|
167
192
|
return {
|
|
168
193
|
name,
|
|
169
194
|
fullPath: path,
|
|
170
|
-
put: async (file, onProgress) => {
|
|
195
|
+
put: async (file, options, onProgress) => {
|
|
196
|
+
let actualOptions = {};
|
|
197
|
+
let actualOnProgress = onProgress;
|
|
198
|
+
if (typeof options === 'function') {
|
|
199
|
+
actualOnProgress = options;
|
|
200
|
+
}
|
|
201
|
+
else if (options && typeof options === 'object') {
|
|
202
|
+
actualOptions = options;
|
|
203
|
+
}
|
|
171
204
|
const formData = new FormData();
|
|
172
205
|
formData.append('file', file);
|
|
206
|
+
if (actualOptions.contextType) {
|
|
207
|
+
formData.append('contextType', actualOptions.contextType);
|
|
208
|
+
}
|
|
209
|
+
if (actualOptions.contextId) {
|
|
210
|
+
formData.append('contextId', actualOptions.contextId);
|
|
211
|
+
}
|
|
212
|
+
if (actualOptions.fileId) {
|
|
213
|
+
formData.append('fileId', actualOptions.fileId);
|
|
214
|
+
}
|
|
173
215
|
const response = await this.client.post(`/api/project/${this.projectId}/storage/upload`, formData, {
|
|
174
216
|
// We omit Content-Type headers here (interceptor strips it anyway) so the browser naturally generates the boundary
|
|
175
217
|
onUploadProgress: (progressEvent) => {
|
|
176
|
-
if (
|
|
218
|
+
if (actualOnProgress && progressEvent.total) {
|
|
177
219
|
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
|
178
|
-
|
|
220
|
+
actualOnProgress(percent);
|
|
179
221
|
}
|
|
180
222
|
}
|
|
181
223
|
});
|
|
182
|
-
return
|
|
224
|
+
return {
|
|
225
|
+
url: response.data.url,
|
|
226
|
+
success: true,
|
|
227
|
+
fileId: response.data.fileId,
|
|
228
|
+
proxyViewUrl: response.data.proxyViewUrl,
|
|
229
|
+
proxyDownloadUrl: response.data.proxyDownloadUrl,
|
|
230
|
+
metadata: response.data.metadata
|
|
231
|
+
};
|
|
183
232
|
},
|
|
184
233
|
delete: async () => {
|
|
185
234
|
const response = await this.client.delete(`/api/project/${this.projectId}/storage/${encodeURIComponent(name)}`);
|
|
@@ -647,15 +696,29 @@ const onSnapshot = (ref, callback) => {
|
|
|
647
696
|
callback(cached);
|
|
648
697
|
}
|
|
649
698
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
699
|
+
let debounceTimer = null;
|
|
700
|
+
const triggerCallback = () => {
|
|
701
|
+
if (debounceTimer)
|
|
702
|
+
clearTimeout(debounceTimer);
|
|
703
|
+
debounceTimer = setTimeout(async () => {
|
|
704
|
+
if (ref.type === 'collection' || ref.type === 'query') {
|
|
705
|
+
// Immediately serve from cache for UI responsiveness
|
|
706
|
+
callback((0, exports.getCachedDocs)(ref));
|
|
707
|
+
try {
|
|
708
|
+
const docs = await (0, exports.getDocs)(ref);
|
|
709
|
+
callback(docs);
|
|
710
|
+
}
|
|
711
|
+
catch (e) { }
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
callback((0, exports.getCachedDoc)(ref));
|
|
715
|
+
try {
|
|
716
|
+
const docData = await (0, exports.getDoc)(ref);
|
|
717
|
+
callback(docData);
|
|
718
|
+
}
|
|
719
|
+
catch (e) { }
|
|
720
|
+
}
|
|
721
|
+
}, 800); // Debounce to batch rapid changes (e.g. 500 inserts)
|
|
659
722
|
};
|
|
660
723
|
// 2. Fetch the latest live server data and update cache + trigger the callback again
|
|
661
724
|
triggerCallback();
|
package/package.json
CHANGED