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