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