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