@pol-studios/powersync 1.0.6 → 1.0.10
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 +933 -0
- package/dist/CacheSettingsManager-uz-kbnRH.d.ts +461 -0
- package/dist/attachments/index.d.ts +745 -332
- package/dist/attachments/index.js +152 -6
- package/dist/{types-Cd7RhNqf.d.ts → background-sync-ChCXW-EV.d.ts} +53 -2
- package/dist/chunk-24RDMMCL.js +44 -0
- package/dist/chunk-24RDMMCL.js.map +1 -0
- package/dist/chunk-4TXTAEF2.js +2060 -0
- package/dist/chunk-4TXTAEF2.js.map +1 -0
- package/dist/chunk-63PXSPIN.js +358 -0
- package/dist/chunk-63PXSPIN.js.map +1 -0
- package/dist/chunk-654ERHA7.js +1 -0
- package/dist/chunk-A4IBBWGO.js +377 -0
- package/dist/chunk-A4IBBWGO.js.map +1 -0
- package/dist/chunk-BRXQNASY.js +1720 -0
- package/dist/chunk-BRXQNASY.js.map +1 -0
- package/dist/chunk-CAB26E6F.js +142 -0
- package/dist/chunk-CAB26E6F.js.map +1 -0
- package/dist/{chunk-EJ23MXPQ.js → chunk-CGL33PL4.js} +3 -1
- package/dist/chunk-CGL33PL4.js.map +1 -0
- package/dist/{chunk-R4YFWQ3Q.js → chunk-CUCAYK7Z.js} +309 -92
- package/dist/chunk-CUCAYK7Z.js.map +1 -0
- package/dist/chunk-FV2HXEIY.js +124 -0
- package/dist/chunk-FV2HXEIY.js.map +1 -0
- package/dist/chunk-HWSNV45P.js +279 -0
- package/dist/chunk-HWSNV45P.js.map +1 -0
- package/dist/{chunk-62J2DPKX.js → chunk-KN2IZERF.js} +530 -413
- package/dist/chunk-KN2IZERF.js.map +1 -0
- package/dist/{chunk-7EMDVIZX.js → chunk-N75DEF5J.js} +19 -1
- package/dist/chunk-N75DEF5J.js.map +1 -0
- package/dist/chunk-P4HZA6ZT.js +83 -0
- package/dist/chunk-P4HZA6ZT.js.map +1 -0
- package/dist/chunk-P6WOZO7H.js +49 -0
- package/dist/chunk-P6WOZO7H.js.map +1 -0
- package/dist/chunk-T4AO7JIG.js +1 -0
- package/dist/chunk-TGBT5XBE.js +1 -0
- package/dist/{chunk-FPTDATY5.js → chunk-VACPAAQZ.js} +54 -12
- package/dist/chunk-VACPAAQZ.js.map +1 -0
- package/dist/chunk-WGHNIAF7.js +329 -0
- package/dist/chunk-WGHNIAF7.js.map +1 -0
- package/dist/{chunk-3AYXHQ4W.js → chunk-WN5ZJ3E2.js} +108 -47
- package/dist/chunk-WN5ZJ3E2.js.map +1 -0
- package/dist/chunk-XAEII4ZX.js +456 -0
- package/dist/chunk-XAEII4ZX.js.map +1 -0
- package/dist/chunk-XOY2CJ67.js +289 -0
- package/dist/chunk-XOY2CJ67.js.map +1 -0
- package/dist/chunk-YHTZ7VMV.js +1 -0
- package/dist/chunk-YSTEESEG.js +676 -0
- package/dist/chunk-YSTEESEG.js.map +1 -0
- package/dist/chunk-Z6VOBGTU.js +32 -0
- package/dist/chunk-Z6VOBGTU.js.map +1 -0
- package/dist/chunk-ZM4ENYMF.js +230 -0
- package/dist/chunk-ZM4ENYMF.js.map +1 -0
- package/dist/connector/index.d.ts +236 -4
- package/dist/connector/index.js +15 -4
- package/dist/core/index.d.ts +16 -3
- package/dist/core/index.js +6 -2
- package/dist/error/index.d.ts +54 -0
- package/dist/error/index.js +7 -0
- package/dist/error/index.js.map +1 -0
- package/dist/index.d.ts +102 -12
- package/dist/index.js +309 -37
- package/dist/index.native.d.ts +22 -10
- package/dist/index.native.js +309 -38
- package/dist/index.web.d.ts +22 -10
- package/dist/index.web.js +310 -38
- package/dist/maintenance/index.d.ts +118 -0
- package/dist/maintenance/index.js +16 -0
- package/dist/maintenance/index.js.map +1 -0
- package/dist/platform/index.d.ts +16 -1
- package/dist/platform/index.js.map +1 -1
- package/dist/platform/index.native.d.ts +2 -2
- package/dist/platform/index.native.js +1 -1
- package/dist/platform/index.web.d.ts +1 -1
- package/dist/platform/index.web.js +1 -1
- package/dist/pol-attachment-queue-BVAIueoP.d.ts +817 -0
- package/dist/provider/index.d.ts +451 -21
- package/dist/provider/index.js +32 -13
- package/dist/react/index.d.ts +372 -0
- package/dist/react/index.js +25 -0
- package/dist/react/index.js.map +1 -0
- package/dist/storage/index.d.ts +6 -0
- package/dist/storage/index.js +42 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/index.native.d.ts +6 -0
- package/dist/storage/index.native.js +40 -0
- package/dist/storage/index.native.js.map +1 -0
- package/dist/storage/index.web.d.ts +6 -0
- package/dist/storage/index.web.js +40 -0
- package/dist/storage/index.web.js.map +1 -0
- package/dist/storage/upload/index.d.ts +54 -0
- package/dist/storage/upload/index.js +15 -0
- package/dist/storage/upload/index.js.map +1 -0
- package/dist/storage/upload/index.native.d.ts +56 -0
- package/dist/storage/upload/index.native.js +15 -0
- package/dist/storage/upload/index.native.js.map +1 -0
- package/dist/storage/upload/index.web.d.ts +2 -0
- package/dist/storage/upload/index.web.js +14 -0
- package/dist/storage/upload/index.web.js.map +1 -0
- package/dist/supabase-connector-T9vHq_3i.d.ts +202 -0
- package/dist/sync/index.d.ts +288 -23
- package/dist/sync/index.js +22 -10
- package/dist/{index-l3iL9Jte.d.ts → types-B212hgfA.d.ts} +101 -158
- package/dist/{types-afHtE1U_.d.ts → types-CDqWh56B.d.ts} +2 -0
- package/dist/types-CyvBaAl8.d.ts +60 -0
- package/dist/types-D0WcHrq6.d.ts +234 -0
- package/package.json +89 -5
- package/dist/chunk-32OLICZO.js +0 -1
- package/dist/chunk-3AYXHQ4W.js.map +0 -1
- package/dist/chunk-5FIMA26D.js +0 -1
- package/dist/chunk-62J2DPKX.js.map +0 -1
- package/dist/chunk-7EMDVIZX.js.map +0 -1
- package/dist/chunk-EJ23MXPQ.js.map +0 -1
- package/dist/chunk-FPTDATY5.js.map +0 -1
- package/dist/chunk-KCDG2MNP.js +0 -1431
- package/dist/chunk-KCDG2MNP.js.map +0 -1
- package/dist/chunk-OLHGI472.js +0 -1
- package/dist/chunk-PAFBKNL3.js +0 -99
- package/dist/chunk-PAFBKNL3.js.map +0 -1
- package/dist/chunk-R4YFWQ3Q.js.map +0 -1
- package/dist/chunk-V6LJ6MR2.js +0 -740
- package/dist/chunk-V6LJ6MR2.js.map +0 -1
- package/dist/chunk-VJCL2SWD.js +0 -1
- package/dist/failed-upload-store-C0cLxxPz.d.ts +0 -33
- /package/dist/{chunk-32OLICZO.js.map → chunk-654ERHA7.js.map} +0 -0
- /package/dist/{chunk-5FIMA26D.js.map → chunk-T4AO7JIG.js.map} +0 -0
- /package/dist/{chunk-OLHGI472.js.map → chunk-TGBT5XBE.js.map} +0 -0
- /package/dist/{chunk-VJCL2SWD.js.map → chunk-YHTZ7VMV.js.map} +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
} from "./chunk-
|
|
2
|
+
withExponentialBackoff
|
|
3
|
+
} from "./chunk-FV2HXEIY.js";
|
|
4
4
|
import {
|
|
5
|
-
classifySupabaseError
|
|
6
|
-
|
|
5
|
+
classifySupabaseError,
|
|
6
|
+
isRlsError
|
|
7
|
+
} from "./chunk-VACPAAQZ.js";
|
|
7
8
|
|
|
8
9
|
// src/connector/types.ts
|
|
9
10
|
var defaultSchemaRouter = () => "public";
|
|
@@ -11,18 +12,70 @@ var DEFAULT_RETRY_CONFIG = {
|
|
|
11
12
|
transient: {
|
|
12
13
|
maxRetries: 3,
|
|
13
14
|
baseDelayMs: 1e3,
|
|
14
|
-
|
|
15
|
+
// 1 second initial delay
|
|
16
|
+
maxDelayMs: 4e3,
|
|
17
|
+
// 4 second cap
|
|
15
18
|
backoffMultiplier: 2
|
|
19
|
+
// 1s → 2s → 4s
|
|
16
20
|
},
|
|
17
21
|
permanent: {
|
|
18
|
-
maxRetries:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
maxRetries: 0,
|
|
23
|
+
// No retries - permanent errors won't succeed
|
|
24
|
+
baseDelayMs: 1e3,
|
|
25
|
+
// Irrelevant with 0 retries, but needed for type
|
|
26
|
+
maxDelayMs: 1e3,
|
|
27
|
+
// Irrelevant with 0 retries
|
|
28
|
+
backoffMultiplier: 1
|
|
29
|
+
// Irrelevant with 0 retries
|
|
30
|
+
},
|
|
31
|
+
rls: {
|
|
32
|
+
maxRetries: 5,
|
|
33
|
+
// Extended retries for RLS errors
|
|
34
|
+
baseDelayMs: 3e4,
|
|
35
|
+
// 30 seconds initial delay
|
|
36
|
+
maxDelayMs: 12e4,
|
|
37
|
+
// 2 minutes max delay
|
|
38
|
+
backoffMultiplier: 2
|
|
39
|
+
// 30s → 60s → 120s → 120s → 120s
|
|
22
40
|
}
|
|
23
41
|
};
|
|
24
42
|
|
|
25
43
|
// src/conflicts/detect.ts
|
|
44
|
+
var ConflictDetectionError = class extends Error {
|
|
45
|
+
constructor(message, options) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "ConflictDetectionError";
|
|
48
|
+
this.cause = options?.cause;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
function deepEqual(a, b, maxDepth = 10) {
|
|
52
|
+
if (a === b) return true;
|
|
53
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
54
|
+
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
55
|
+
}
|
|
56
|
+
if (a === null || a === void 0 || b === null || b === void 0) return false;
|
|
57
|
+
if (typeof a !== typeof b) return false;
|
|
58
|
+
if (typeof a !== "object") return false;
|
|
59
|
+
if (maxDepth <= 0) {
|
|
60
|
+
console.warn("[deepEqual] Max depth reached, falling back to reference equality");
|
|
61
|
+
return a === b;
|
|
62
|
+
}
|
|
63
|
+
if (a instanceof Date && b instanceof Date) {
|
|
64
|
+
return a.getTime() === b.getTime();
|
|
65
|
+
}
|
|
66
|
+
if (a instanceof Date || b instanceof Date) return false;
|
|
67
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
68
|
+
const keysA = Object.keys(a);
|
|
69
|
+
const keysB = Object.keys(b);
|
|
70
|
+
if (keysA.length !== keysB.length) return false;
|
|
71
|
+
for (const key of keysA) {
|
|
72
|
+
if (!keysB.includes(key)) return false;
|
|
73
|
+
if (!deepEqual(a[key], b[key], maxDepth - 1)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
26
79
|
var TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
27
80
|
function validateTableName(table) {
|
|
28
81
|
if (!TABLE_NAME_REGEX.test(table)) {
|
|
@@ -54,14 +107,9 @@ async function detectConflicts(table, recordId, localVersion, serverVersion, pen
|
|
|
54
107
|
ascending: false
|
|
55
108
|
}).limit(20);
|
|
56
109
|
if (error) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
conflicts: [],
|
|
61
|
-
nonConflictingChanges: Object.keys(filteredPendingChanges),
|
|
62
|
-
table,
|
|
63
|
-
recordId
|
|
64
|
-
};
|
|
110
|
+
throw new ConflictDetectionError(`AuditLog query failed for ${table}/${recordId}`, {
|
|
111
|
+
cause: error
|
|
112
|
+
});
|
|
65
113
|
}
|
|
66
114
|
const serverChanges = /* @__PURE__ */ new Map();
|
|
67
115
|
for (const log of auditLogs ?? []) {
|
|
@@ -70,7 +118,7 @@ async function detectConflicts(table, recordId, localVersion, serverVersion, pen
|
|
|
70
118
|
if (!oldRec || !newRec) continue;
|
|
71
119
|
for (const [field, newValue] of Object.entries(newRec)) {
|
|
72
120
|
if (ignoredFields.has(field)) continue;
|
|
73
|
-
if (oldRec[field]
|
|
121
|
+
if (!deepEqual(oldRec[field], newValue) && !serverChanges.has(field)) {
|
|
74
122
|
serverChanges.set(field, {
|
|
75
123
|
newValue,
|
|
76
124
|
changedBy: log.changeBy,
|
|
@@ -129,122 +177,64 @@ async function getLocalVersion(table, recordId, db) {
|
|
|
129
177
|
return result?._version ?? null;
|
|
130
178
|
}
|
|
131
179
|
|
|
132
|
-
// src/
|
|
133
|
-
var
|
|
134
|
-
constructor(message
|
|
180
|
+
// src/connector/supabase-connector.ts
|
|
181
|
+
var ValidationError = class extends Error {
|
|
182
|
+
constructor(message) {
|
|
135
183
|
super(message);
|
|
136
|
-
this.name = "
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
var RetryExhaustedError = class extends Error {
|
|
140
|
-
/** The last error that caused the final retry to fail */
|
|
141
|
-
cause;
|
|
142
|
-
/** Total number of attempts made */
|
|
143
|
-
attempts;
|
|
144
|
-
constructor(cause, attempts) {
|
|
145
|
-
super(`Retry exhausted after ${attempts} attempt(s): ${cause.message}`);
|
|
146
|
-
this.name = "RetryExhaustedError";
|
|
147
|
-
this.cause = cause;
|
|
148
|
-
this.attempts = attempts;
|
|
184
|
+
this.name = "ValidationError";
|
|
149
185
|
}
|
|
150
186
|
};
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const safeAttempt = Math.max(0, attempt);
|
|
158
|
-
const exponentialDelay = baseDelayMs * Math.pow(backoffMultiplier, safeAttempt);
|
|
159
|
-
return Math.min(exponentialDelay, maxDelayMs);
|
|
160
|
-
}
|
|
161
|
-
function addJitter(delay) {
|
|
162
|
-
const jitterFactor = 0.9 + Math.random() * 0.2;
|
|
163
|
-
return Math.round(delay * jitterFactor);
|
|
164
|
-
}
|
|
165
|
-
function sleep(ms, signal) {
|
|
166
|
-
return new Promise((resolve, reject) => {
|
|
167
|
-
if (signal?.aborted) {
|
|
168
|
-
reject(new AbortError());
|
|
169
|
-
return;
|
|
187
|
+
function isAuthError(error) {
|
|
188
|
+
if (!error) return false;
|
|
189
|
+
if (typeof error === "object" && error !== null) {
|
|
190
|
+
const statusCode = error.status ?? error.statusCode;
|
|
191
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
192
|
+
return true;
|
|
170
193
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return;
|
|
194
|
+
const code = error.code;
|
|
195
|
+
if (code === "401" || code === "403" || code === "42501") {
|
|
196
|
+
return true;
|
|
174
197
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
198
|
+
}
|
|
199
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
200
|
+
const authPatterns = [
|
|
201
|
+
/\bjwt\s+(expired|invalid|malformed)\b/,
|
|
202
|
+
/\btoken\s+(expired|invalid|revoked)\b/,
|
|
203
|
+
/\bsession\s+(expired|invalid)\b/,
|
|
204
|
+
/\bunauthorized\s*(access|request|error)?\b/,
|
|
205
|
+
/\baccess\s+(denied|forbidden)\b/,
|
|
206
|
+
/\bnot\s+authorized\b/,
|
|
207
|
+
/\bauth(entication)?\s+(failed|error|required)\b/,
|
|
208
|
+
/\bpermission\s+denied\b/,
|
|
209
|
+
/\binvalid\s+(credentials|api[_\s]?key)\b/,
|
|
210
|
+
/\brls\b.*\bpolicy\b/,
|
|
211
|
+
/\brow[_\s]?level[_\s]?security\b/,
|
|
212
|
+
/\b42501\b/
|
|
213
|
+
// PostgreSQL insufficient privilege error code
|
|
214
|
+
];
|
|
215
|
+
return authPatterns.some((pattern) => pattern.test(message));
|
|
216
|
+
}
|
|
217
|
+
function withTimeout(promise, ms, message) {
|
|
218
|
+
let timeoutId;
|
|
219
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
220
|
+
timeoutId = setTimeout(() => reject(new Error(message)), ms);
|
|
193
221
|
});
|
|
222
|
+
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId));
|
|
194
223
|
}
|
|
195
|
-
var
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
baseDelayMs,
|
|
205
|
-
maxDelayMs,
|
|
206
|
-
backoffMultiplier
|
|
207
|
-
} = config;
|
|
208
|
-
const {
|
|
209
|
-
signal,
|
|
210
|
-
onRetry
|
|
211
|
-
} = options ?? {};
|
|
212
|
-
if (signal?.aborted) {
|
|
213
|
-
throw new AbortError();
|
|
214
|
-
}
|
|
215
|
-
const safeMaxRetries = Math.max(0, Math.floor(maxRetries));
|
|
216
|
-
const totalAttempts = safeMaxRetries + 1;
|
|
217
|
-
let lastError;
|
|
218
|
-
for (let attempt = 0; attempt < totalAttempts; attempt++) {
|
|
219
|
-
if (signal?.aborted) {
|
|
220
|
-
throw new AbortError();
|
|
221
|
-
}
|
|
222
|
-
try {
|
|
223
|
-
return await fn();
|
|
224
|
-
} catch (error) {
|
|
225
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
226
|
-
const isLastAttempt = attempt === totalAttempts - 1;
|
|
227
|
-
if (isLastAttempt) {
|
|
228
|
-
break;
|
|
229
|
-
}
|
|
230
|
-
if (signal?.aborted) {
|
|
231
|
-
throw new AbortError();
|
|
232
|
-
}
|
|
233
|
-
const baseDelay = calculateBackoffDelay(attempt, {
|
|
234
|
-
baseDelayMs,
|
|
235
|
-
maxDelayMs,
|
|
236
|
-
backoffMultiplier
|
|
237
|
-
});
|
|
238
|
-
const delayWithJitter = addJitter(baseDelay);
|
|
239
|
-
onRetry?.(attempt + 1, delayWithJitter, lastError);
|
|
240
|
-
await sleep(delayWithJitter, signal);
|
|
224
|
+
var MAX_PAYLOAD_SIZE = 900 * 1024;
|
|
225
|
+
function groupEntriesByTable(entries) {
|
|
226
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const existing = grouped.get(entry.table);
|
|
229
|
+
if (existing) {
|
|
230
|
+
existing.push(entry);
|
|
231
|
+
} else {
|
|
232
|
+
grouped.set(entry.table, [entry]);
|
|
241
233
|
}
|
|
242
234
|
}
|
|
243
|
-
|
|
235
|
+
return grouped;
|
|
244
236
|
}
|
|
245
|
-
|
|
246
|
-
// src/connector/supabase-connector.ts
|
|
247
|
-
var SupabaseConnector = class {
|
|
237
|
+
var SupabaseConnector = class _SupabaseConnector {
|
|
248
238
|
supabase;
|
|
249
239
|
powerSyncUrl;
|
|
250
240
|
schemaRouter;
|
|
@@ -267,9 +257,24 @@ var SupabaseConnector = class {
|
|
|
267
257
|
resolvedConflicts = /* @__PURE__ */ new Map();
|
|
268
258
|
// Cleanup function for resolution listener subscription
|
|
269
259
|
unsubscribeResolution;
|
|
260
|
+
// Promise-based locking for version column checks to prevent duplicate queries
|
|
261
|
+
versionColumnPromises = /* @__PURE__ */ new Map();
|
|
262
|
+
// Flag to track if connector has been destroyed
|
|
263
|
+
isDestroyed = false;
|
|
270
264
|
// Retry configuration
|
|
271
265
|
retryConfig;
|
|
266
|
+
// Track completion failures for circuit breaker logic
|
|
267
|
+
// Maps transaction fingerprint (hash of entry IDs) to failure tracking info
|
|
268
|
+
completionFailures = /* @__PURE__ */ new Map();
|
|
269
|
+
static COMPLETION_MAX_FAILURES = 3;
|
|
270
|
+
static COMPLETION_EXTENDED_TIMEOUT_MS = 6e4;
|
|
271
|
+
// 60s timeout for retry
|
|
272
272
|
autoRetryPaused = false;
|
|
273
|
+
// Per-entry cooldown tracking for failed operations
|
|
274
|
+
// Maps entry key (table:id) to the timestamp when it can be retried
|
|
275
|
+
entryCooldowns = /* @__PURE__ */ new Map();
|
|
276
|
+
static COOLDOWN_DURATION_MS = 6e4;
|
|
277
|
+
// 1 minute cooldown after exhausting retries
|
|
273
278
|
constructor(options) {
|
|
274
279
|
this.supabase = options.supabaseClient;
|
|
275
280
|
this.powerSyncUrl = options.powerSyncUrl;
|
|
@@ -291,6 +296,10 @@ var SupabaseConnector = class {
|
|
|
291
296
|
permanent: {
|
|
292
297
|
...DEFAULT_RETRY_CONFIG.permanent,
|
|
293
298
|
...options.retryConfig?.permanent
|
|
299
|
+
},
|
|
300
|
+
rls: {
|
|
301
|
+
...DEFAULT_RETRY_CONFIG.rls,
|
|
302
|
+
...options.retryConfig?.rls
|
|
294
303
|
}
|
|
295
304
|
};
|
|
296
305
|
if (this.conflictBus) {
|
|
@@ -313,11 +322,22 @@ var SupabaseConnector = class {
|
|
|
313
322
|
* Call this when the connector is no longer needed.
|
|
314
323
|
*/
|
|
315
324
|
destroy() {
|
|
325
|
+
this.isDestroyed = true;
|
|
316
326
|
if (this.unsubscribeResolution) {
|
|
317
327
|
this.unsubscribeResolution();
|
|
318
328
|
this.unsubscribeResolution = void 0;
|
|
319
329
|
}
|
|
320
330
|
this.resolvedConflicts.clear();
|
|
331
|
+
this.versionColumnPromises.clear();
|
|
332
|
+
this.completionFailures.clear();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Generate a fingerprint for a set of entries to track completion failures.
|
|
336
|
+
* Uses sorted entry IDs to create a consistent fingerprint regardless of order.
|
|
337
|
+
*/
|
|
338
|
+
generateTransactionFingerprint(entries) {
|
|
339
|
+
const sortedIds = entries.map((e) => `${e.table}:${e.id}`).sort();
|
|
340
|
+
return sortedIds.join("|");
|
|
321
341
|
}
|
|
322
342
|
// ─── Retry Control Methods ─────────────────────────────────────────────────
|
|
323
343
|
/**
|
|
@@ -339,113 +359,107 @@ var SupabaseConnector = class {
|
|
|
339
359
|
console.log("[Connector] Auto-retry resumed");
|
|
340
360
|
}
|
|
341
361
|
}
|
|
342
|
-
/**
|
|
343
|
-
* Manually retry all failed uploads that are ready for retry.
|
|
344
|
-
* This processes entries from the failed upload store.
|
|
345
|
-
*/
|
|
346
|
-
async retryFailedUploads() {
|
|
347
|
-
const retryableUploads = failedUploadStore.getRetryable();
|
|
348
|
-
if (retryableUploads.length === 0) {
|
|
349
|
-
if (__DEV__) {
|
|
350
|
-
console.log("[Connector] No failed uploads ready for retry");
|
|
351
|
-
}
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
if (__DEV__) {
|
|
355
|
-
console.log("[Connector] Manually retrying failed uploads:", {
|
|
356
|
-
count: retryableUploads.length,
|
|
357
|
-
entries: retryableUploads.map((u) => ({
|
|
358
|
-
table: u.table,
|
|
359
|
-
id: u.id,
|
|
360
|
-
operation: u.operation
|
|
361
|
-
}))
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
for (const upload of retryableUploads) {
|
|
365
|
-
try {
|
|
366
|
-
const entry = {
|
|
367
|
-
table: upload.table,
|
|
368
|
-
op: upload.operation,
|
|
369
|
-
id: upload.id,
|
|
370
|
-
clientId: Date.now(),
|
|
371
|
-
// Synthetic clientId for retry
|
|
372
|
-
opData: upload.data
|
|
373
|
-
};
|
|
374
|
-
await this.processWithRetry(entry);
|
|
375
|
-
failedUploadStore.remove(upload.id);
|
|
376
|
-
if (__DEV__) {
|
|
377
|
-
console.log("[Connector] Retry succeeded for:", {
|
|
378
|
-
table: upload.table,
|
|
379
|
-
id: upload.id
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
} catch (error) {
|
|
383
|
-
if (__DEV__) {
|
|
384
|
-
console.log("[Connector] Retry failed again for:", {
|
|
385
|
-
table: upload.table,
|
|
386
|
-
id: upload.id,
|
|
387
|
-
error: error instanceof Error ? error.message : String(error)
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
/**
|
|
394
|
-
* Clear all failed uploads from the store.
|
|
395
|
-
* Use with caution - this discards all pending retries.
|
|
396
|
-
*/
|
|
397
|
-
clearFailedUploads() {
|
|
398
|
-
failedUploadStore.clear();
|
|
399
|
-
if (__DEV__) {
|
|
400
|
-
console.log("[Connector] Failed uploads cleared");
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
/**
|
|
404
|
-
* Get all failed uploads from the store.
|
|
405
|
-
*/
|
|
406
|
-
getFailedUploads() {
|
|
407
|
-
return failedUploadStore.getAll();
|
|
408
|
-
}
|
|
409
362
|
// ─── Private Retry Logic ───────────────────────────────────────────────────
|
|
410
363
|
/**
|
|
411
364
|
* Process a single CRUD entry with exponential backoff retry.
|
|
412
365
|
*
|
|
366
|
+
* This method uses a two-phase approach:
|
|
367
|
+
* 1. First attempt - try the operation to classify the error type
|
|
368
|
+
* 2. Retry phase - use the appropriate config (transient vs permanent) based on classification
|
|
369
|
+
*
|
|
370
|
+
* This fixes the issue where reassigning selectedConfig inside withExponentialBackoff's
|
|
371
|
+
* callback had no effect because the config was already destructured at call time.
|
|
372
|
+
*
|
|
413
373
|
* @param entry - The CRUD entry to process
|
|
414
374
|
* @throws Error if all retries exhausted (for critical failures)
|
|
415
375
|
*/
|
|
416
376
|
async processWithRetry(entry) {
|
|
377
|
+
if (this.isDestroyed) {
|
|
378
|
+
throw new Error("Connector destroyed");
|
|
379
|
+
}
|
|
380
|
+
const entryKey = `${entry.table}:${entry.id}`;
|
|
381
|
+
const cooldownUntil = this.entryCooldowns.get(entryKey);
|
|
382
|
+
if (cooldownUntil && Date.now() < cooldownUntil) {
|
|
383
|
+
const remainingMs = cooldownUntil - Date.now();
|
|
384
|
+
if (__DEV__) {
|
|
385
|
+
console.log("[Connector] Entry in cooldown, skipping:", {
|
|
386
|
+
table: entry.table,
|
|
387
|
+
id: entry.id,
|
|
388
|
+
remainingSec: Math.round(remainingMs / 1e3)
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`Entry in cooldown for ${Math.round(remainingMs / 1e3)}s`);
|
|
392
|
+
}
|
|
417
393
|
const classified = {
|
|
418
394
|
isPermanent: false,
|
|
419
395
|
pgCode: void 0,
|
|
420
396
|
userMessage: ""
|
|
421
397
|
};
|
|
422
|
-
let selectedConfig = this.retryConfig.transient;
|
|
423
398
|
let lastError;
|
|
424
399
|
try {
|
|
425
|
-
await
|
|
400
|
+
await this.processCrudEntry(entry);
|
|
401
|
+
this.entryCooldowns.delete(entryKey);
|
|
402
|
+
return;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
405
|
+
const classifiedError = classifySupabaseError(error);
|
|
406
|
+
classified.isPermanent = classifiedError.isPermanent;
|
|
407
|
+
classified.pgCode = classifiedError.pgCode;
|
|
408
|
+
classified.userMessage = classifiedError.userMessage;
|
|
409
|
+
if (isAuthError(error)) {
|
|
410
|
+
if (__DEV__) {
|
|
411
|
+
console.log("[Connector] Auth error detected, refreshing session before retry:", {
|
|
412
|
+
table: entry.table,
|
|
413
|
+
op: entry.op,
|
|
414
|
+
id: entry.id
|
|
415
|
+
});
|
|
416
|
+
}
|
|
426
417
|
try {
|
|
418
|
+
await this.supabase.auth.refreshSession();
|
|
427
419
|
await this.processCrudEntry(entry);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
437
|
-
if (__DEV__) {
|
|
438
|
-
console.log("[Connector] CRUD operation failed, will retry:", {
|
|
439
|
-
table: entry.table,
|
|
440
|
-
op: entry.op,
|
|
441
|
-
id: entry.id,
|
|
442
|
-
isPermanent: classifiedError.isPermanent,
|
|
443
|
-
pgCode: classifiedError.pgCode,
|
|
444
|
-
userMessage: classifiedError.userMessage
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
throw error;
|
|
420
|
+
this.entryCooldowns.delete(entryKey);
|
|
421
|
+
return;
|
|
422
|
+
} catch (retryError) {
|
|
423
|
+
lastError = retryError instanceof Error ? retryError : new Error(String(retryError));
|
|
424
|
+
const retriedClassification = classifySupabaseError(retryError);
|
|
425
|
+
classified.isPermanent = retriedClassification.isPermanent;
|
|
426
|
+
classified.pgCode = retriedClassification.pgCode;
|
|
427
|
+
classified.userMessage = retriedClassification.userMessage;
|
|
448
428
|
}
|
|
429
|
+
}
|
|
430
|
+
if (__DEV__) {
|
|
431
|
+
console.log("[Connector] Initial attempt failed, will retry with appropriate config:", {
|
|
432
|
+
table: entry.table,
|
|
433
|
+
op: entry.op,
|
|
434
|
+
id: entry.id,
|
|
435
|
+
isPermanent: classified.isPermanent,
|
|
436
|
+
pgCode: classified.pgCode,
|
|
437
|
+
userMessage: classified.userMessage
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
this.logger?.warn("[Connector] Initial attempt failed:", {
|
|
441
|
+
table: entry.table,
|
|
442
|
+
op: entry.op,
|
|
443
|
+
id: entry.id,
|
|
444
|
+
error: lastError.message,
|
|
445
|
+
isPermanent: classified.isPermanent
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const isRlsPermissionError = lastError && isRlsError(lastError);
|
|
449
|
+
const selectedConfig = isRlsPermissionError ? this.retryConfig.rls : classified.isPermanent ? this.retryConfig.permanent : this.retryConfig.transient;
|
|
450
|
+
if (__DEV__ && isRlsPermissionError) {
|
|
451
|
+
console.log("[Connector] RLS/permission error detected, using extended retry config:", {
|
|
452
|
+
table: entry.table,
|
|
453
|
+
op: entry.op,
|
|
454
|
+
id: entry.id,
|
|
455
|
+
maxRetries: selectedConfig.maxRetries,
|
|
456
|
+
baseDelayMs: selectedConfig.baseDelayMs,
|
|
457
|
+
maxDelayMs: selectedConfig.maxDelayMs
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
await withExponentialBackoff(async () => {
|
|
462
|
+
await this.processCrudEntry(entry);
|
|
449
463
|
}, selectedConfig, {
|
|
450
464
|
onRetry: (attempt, delay, error) => {
|
|
451
465
|
this.logger?.debug("[Connector] Retry attempt:", {
|
|
@@ -462,38 +476,24 @@ var SupabaseConnector = class {
|
|
|
462
476
|
op: entry.op,
|
|
463
477
|
id: entry.id,
|
|
464
478
|
attempt,
|
|
479
|
+
maxRetries: selectedConfig.maxRetries,
|
|
465
480
|
delayMs: delay,
|
|
466
481
|
errorMessage: error.message
|
|
467
482
|
});
|
|
468
483
|
}
|
|
469
484
|
}
|
|
470
485
|
});
|
|
486
|
+
this.entryCooldowns.delete(entryKey);
|
|
471
487
|
} catch (error) {
|
|
472
|
-
const finalError =
|
|
488
|
+
const finalError = error instanceof Error ? error : new Error(String(error));
|
|
473
489
|
const category = classified.isPermanent ? "permanent" : classified.pgCode ? "transient" : "unknown";
|
|
474
|
-
const retryConfig = classified.isPermanent ? this.retryConfig.permanent : this.retryConfig.transient;
|
|
475
|
-
const nextRetryDelay = calculateBackoffDelay(retryConfig.maxRetries, retryConfig);
|
|
476
|
-
const nextRetryAt = Date.now() + nextRetryDelay;
|
|
477
|
-
failedUploadStore.add({
|
|
478
|
-
table: entry.table,
|
|
479
|
-
operation: entry.op,
|
|
480
|
-
data: entry.opData ?? {},
|
|
481
|
-
error: {
|
|
482
|
-
message: finalError.message,
|
|
483
|
-
code: classified.pgCode,
|
|
484
|
-
category
|
|
485
|
-
},
|
|
486
|
-
retryCount: retryConfig.maxRetries,
|
|
487
|
-
lastAttempt: Date.now(),
|
|
488
|
-
nextRetryAt
|
|
489
|
-
});
|
|
490
490
|
if (__DEV__) {
|
|
491
|
-
console.log("[Connector]
|
|
491
|
+
console.log("[Connector] CRUD entry failed after retries, leaving in ps_crud:", {
|
|
492
492
|
table: entry.table,
|
|
493
493
|
op: entry.op,
|
|
494
494
|
id: entry.id,
|
|
495
495
|
category,
|
|
496
|
-
|
|
496
|
+
error: finalError.message
|
|
497
497
|
});
|
|
498
498
|
}
|
|
499
499
|
this.logger?.error("[Connector] CRUD entry failed after retries:", {
|
|
@@ -501,12 +501,18 @@ var SupabaseConnector = class {
|
|
|
501
501
|
op: entry.op,
|
|
502
502
|
id: entry.id,
|
|
503
503
|
error: finalError.message,
|
|
504
|
-
category
|
|
505
|
-
nextRetryAt
|
|
504
|
+
category
|
|
506
505
|
});
|
|
507
|
-
|
|
508
|
-
|
|
506
|
+
const cooldownMs = _SupabaseConnector.COOLDOWN_DURATION_MS;
|
|
507
|
+
this.entryCooldowns.set(entryKey, Date.now() + cooldownMs);
|
|
508
|
+
if (__DEV__) {
|
|
509
|
+
console.log("[Connector] Entry placed in cooldown:", {
|
|
510
|
+
table: entry.table,
|
|
511
|
+
id: entry.id,
|
|
512
|
+
cooldownSec: cooldownMs / 1e3
|
|
513
|
+
});
|
|
509
514
|
}
|
|
515
|
+
throw finalError;
|
|
510
516
|
}
|
|
511
517
|
}
|
|
512
518
|
/**
|
|
@@ -564,58 +570,33 @@ var SupabaseConnector = class {
|
|
|
564
570
|
* 5. Applies resolution or skips entry based on handler response
|
|
565
571
|
*/
|
|
566
572
|
async uploadData(database) {
|
|
573
|
+
if (this.isDestroyed) {
|
|
574
|
+
throw new Error("Connector destroyed - aborting upload");
|
|
575
|
+
}
|
|
567
576
|
if (this.shouldUploadFn && !this.shouldUploadFn()) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
this.logger?.debug("[Connector] Upload skipped - sync mode does not allow uploads");
|
|
572
|
-
return;
|
|
577
|
+
this.logger?.debug("[Connector] Upload blocked - not currently permitted, will retry");
|
|
578
|
+
throw new Error("Upload not permitted - will retry on next sync cycle");
|
|
573
579
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const entry = {
|
|
585
|
-
table: upload.table,
|
|
586
|
-
op: upload.operation,
|
|
587
|
-
id: upload.id,
|
|
588
|
-
clientId: Date.now(),
|
|
589
|
-
// Synthetic clientId for retry
|
|
590
|
-
opData: upload.data
|
|
591
|
-
};
|
|
592
|
-
await this.processWithRetry(entry);
|
|
593
|
-
failedUploadStore.remove(upload.id);
|
|
594
|
-
if (__DEV__) {
|
|
595
|
-
console.log("[Connector] Retried upload succeeded:", {
|
|
596
|
-
table: upload.table,
|
|
597
|
-
id: upload.id
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
} catch (error) {
|
|
601
|
-
if (__DEV__) {
|
|
602
|
-
console.log("[Connector] Retried upload failed again:", {
|
|
603
|
-
table: upload.table,
|
|
604
|
-
id: upload.id,
|
|
605
|
-
error: error instanceof Error ? error.message : String(error)
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
580
|
+
const {
|
|
581
|
+
data: {
|
|
582
|
+
session
|
|
583
|
+
},
|
|
584
|
+
error: sessionError
|
|
585
|
+
} = await this.supabase.auth.getSession();
|
|
586
|
+
if (sessionError || !session) {
|
|
587
|
+
const noSessionError = new Error("No active session - cannot upload data");
|
|
588
|
+
if (__DEV__) {
|
|
589
|
+
console.error("[Connector] uploadData failed: no session");
|
|
610
590
|
}
|
|
591
|
+
throw noSessionError;
|
|
611
592
|
}
|
|
612
593
|
if (__DEV__) {
|
|
613
594
|
console.log("[Connector] uploadData called, fetching next CRUD transaction...");
|
|
614
595
|
}
|
|
615
596
|
const transaction = await database.getNextCrudTransaction();
|
|
616
|
-
if (!transaction) {
|
|
597
|
+
if (!transaction || transaction.crud.length === 0) {
|
|
617
598
|
if (__DEV__) {
|
|
618
|
-
console.log("[Connector] No pending CRUD transaction found");
|
|
599
|
+
console.log("[Connector] No pending CRUD transaction found or transaction is empty");
|
|
619
600
|
}
|
|
620
601
|
return;
|
|
621
602
|
}
|
|
@@ -672,6 +653,9 @@ var SupabaseConnector = class {
|
|
|
672
653
|
entriesDiscarded.push(entry);
|
|
673
654
|
break;
|
|
674
655
|
case "partial":
|
|
656
|
+
if (!existingResolution.fields || existingResolution.fields.length === 0) {
|
|
657
|
+
throw new Error("Partial resolution requires at least one field");
|
|
658
|
+
}
|
|
675
659
|
const partialEntry = {
|
|
676
660
|
...entry,
|
|
677
661
|
opData: this.filterFields(entry.opData ?? {}, existingResolution.fields)
|
|
@@ -726,6 +710,9 @@ var SupabaseConnector = class {
|
|
|
726
710
|
}
|
|
727
711
|
break;
|
|
728
712
|
case "partial":
|
|
713
|
+
if (!resolution.fields || resolution.fields.length === 0) {
|
|
714
|
+
throw new Error("Partial resolution requires at least one field");
|
|
715
|
+
}
|
|
729
716
|
const partialEntry = {
|
|
730
717
|
...entry,
|
|
731
718
|
opData: this.filterFields(entry.opData ?? {}, resolution.fields)
|
|
@@ -738,7 +725,7 @@ var SupabaseConnector = class {
|
|
|
738
725
|
break;
|
|
739
726
|
}
|
|
740
727
|
} else {
|
|
741
|
-
|
|
728
|
+
this.logger?.warn("[Connector] Conflict detected but no handler:", {
|
|
742
729
|
table: entry.table,
|
|
743
730
|
id: entry.id,
|
|
744
731
|
conflicts: conflictResult.conflicts
|
|
@@ -755,7 +742,7 @@ var SupabaseConnector = class {
|
|
|
755
742
|
});
|
|
756
743
|
}
|
|
757
744
|
this.onTransactionComplete?.(entriesQueuedForUI);
|
|
758
|
-
|
|
745
|
+
throw new Error("Entries queued for UI resolution - retry will occur on next sync cycle");
|
|
759
746
|
}
|
|
760
747
|
if (entriesToProcess.length === 0 && entriesDiscarded.length > 0) {
|
|
761
748
|
if (__DEV__) {
|
|
@@ -772,7 +759,6 @@ var SupabaseConnector = class {
|
|
|
772
759
|
}
|
|
773
760
|
return;
|
|
774
761
|
}
|
|
775
|
-
const criticalFailures = [];
|
|
776
762
|
const successfulEntries = [];
|
|
777
763
|
for (const entry of entriesToProcess) {
|
|
778
764
|
if (__DEV__) {
|
|
@@ -783,54 +769,77 @@ var SupabaseConnector = class {
|
|
|
783
769
|
opData: entry.opData
|
|
784
770
|
});
|
|
785
771
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
successfulEntries.push(entry);
|
|
789
|
-
} catch (error) {
|
|
790
|
-
criticalFailures.push({
|
|
791
|
-
entry,
|
|
792
|
-
error: error instanceof Error ? error : new Error(String(error))
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
if (criticalFailures.length > 0) {
|
|
797
|
-
const firstFailure = criticalFailures[0];
|
|
798
|
-
const classified = classifySupabaseError(firstFailure.error);
|
|
799
|
-
console.error("[PowerSync Connector] Critical upload failure:", {
|
|
800
|
-
errorMessage: firstFailure.error.message,
|
|
801
|
-
classified,
|
|
802
|
-
isPermanent: classified.isPermanent,
|
|
803
|
-
criticalCount: criticalFailures.length,
|
|
804
|
-
successCount: successfulEntries.length,
|
|
805
|
-
entries: criticalFailures.map((f) => ({
|
|
806
|
-
table: f.entry.table,
|
|
807
|
-
op: f.entry.op,
|
|
808
|
-
id: f.entry.id
|
|
809
|
-
}))
|
|
810
|
-
});
|
|
811
|
-
this.logger?.error("[Connector] Critical upload failure:", {
|
|
812
|
-
error: firstFailure.error,
|
|
813
|
-
classified,
|
|
814
|
-
entries: criticalFailures.map((f) => ({
|
|
815
|
-
table: f.entry.table,
|
|
816
|
-
op: f.entry.op,
|
|
817
|
-
id: f.entry.id
|
|
818
|
-
}))
|
|
819
|
-
});
|
|
820
|
-
this.onTransactionFailure?.(criticalFailures.map((f) => f.entry), firstFailure.error, classified);
|
|
821
|
-
throw firstFailure.error;
|
|
772
|
+
await this.processWithRetry(entry);
|
|
773
|
+
successfulEntries.push(entry);
|
|
822
774
|
}
|
|
775
|
+
await this.finalizeTransaction({
|
|
776
|
+
transaction,
|
|
777
|
+
successfulEntries,
|
|
778
|
+
discardedEntries: entriesDiscarded,
|
|
779
|
+
partialResolutions
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Finalize a transaction by completing it after all entries processed successfully.
|
|
784
|
+
* Extracted to eliminate duplication between uploadData and processTransaction.
|
|
785
|
+
*
|
|
786
|
+
* Implements circuit breaker logic for completion failures:
|
|
787
|
+
* - On first failure: retry once with extended timeout (60s)
|
|
788
|
+
* - After 3 failures for same entries: log and return without throwing
|
|
789
|
+
* (data is safely in Supabase via idempotent upserts, preventing infinite retry loop)
|
|
790
|
+
*
|
|
791
|
+
* @param context - The finalization context containing results and transaction
|
|
792
|
+
*/
|
|
793
|
+
async finalizeTransaction(context) {
|
|
794
|
+
const {
|
|
795
|
+
transaction,
|
|
796
|
+
successfulEntries,
|
|
797
|
+
discardedEntries = [],
|
|
798
|
+
partialResolutions = []
|
|
799
|
+
} = context;
|
|
823
800
|
if (__DEV__) {
|
|
824
801
|
console.log("[Connector] All CRUD entries processed, completing transaction...");
|
|
825
802
|
}
|
|
803
|
+
const allEntries = [...successfulEntries, ...discardedEntries];
|
|
804
|
+
const fingerprint = this.generateTransactionFingerprint(allEntries);
|
|
805
|
+
const failureInfo = this.completionFailures.get(fingerprint);
|
|
806
|
+
const currentFailureCount = failureInfo?.count ?? 0;
|
|
807
|
+
if (currentFailureCount >= _SupabaseConnector.COMPLETION_MAX_FAILURES) {
|
|
808
|
+
this.logger?.warn("[Connector] Circuit breaker triggered - completion failed too many times, bypassing throw:", {
|
|
809
|
+
fingerprint: fingerprint.substring(0, 50) + "...",
|
|
810
|
+
failureCount: currentFailureCount,
|
|
811
|
+
entriesCount: allEntries.length,
|
|
812
|
+
message: "Data is safely in Supabase. Returning without throw to prevent infinite retry loop."
|
|
813
|
+
});
|
|
814
|
+
if (__DEV__) {
|
|
815
|
+
console.warn("[Connector] CIRCUIT BREAKER: Completion failed", currentFailureCount, "times. Data is in Supabase - returning without throw to break retry loop.");
|
|
816
|
+
}
|
|
817
|
+
this.completionFailures.delete(fingerprint);
|
|
818
|
+
this.onTransactionSuccess?.(successfulEntries);
|
|
819
|
+
this.onTransactionComplete?.(successfulEntries);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
826
822
|
try {
|
|
827
|
-
await transaction.complete();
|
|
823
|
+
await withTimeout(transaction.complete(), 3e4, "Transaction complete timeout");
|
|
828
824
|
if (__DEV__) {
|
|
829
825
|
console.log("[Connector] Transaction completed successfully:", {
|
|
830
826
|
entriesCount: successfulEntries.length,
|
|
831
|
-
discardedCount:
|
|
827
|
+
discardedCount: discardedEntries.length
|
|
832
828
|
});
|
|
833
829
|
}
|
|
830
|
+
this.completionFailures.delete(fingerprint);
|
|
831
|
+
for (const entry of successfulEntries) {
|
|
832
|
+
this.resolvedConflicts.delete(`${entry.table}:${entry.id}`);
|
|
833
|
+
}
|
|
834
|
+
for (const entry of discardedEntries) {
|
|
835
|
+
this.resolvedConflicts.delete(`${entry.table}:${entry.id}`);
|
|
836
|
+
}
|
|
837
|
+
if (this.resolvedConflicts.size > 100) {
|
|
838
|
+
const oldest = [...this.resolvedConflicts.keys()][0];
|
|
839
|
+
if (oldest) {
|
|
840
|
+
this.resolvedConflicts.delete(oldest);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
834
843
|
if (this.conflictBus && partialResolutions.length > 0) {
|
|
835
844
|
for (const {
|
|
836
845
|
originalConflict,
|
|
@@ -861,21 +870,62 @@ var SupabaseConnector = class {
|
|
|
861
870
|
this.onTransactionComplete?.(successfulEntries);
|
|
862
871
|
} catch (error) {
|
|
863
872
|
const classified = classifySupabaseError(error);
|
|
864
|
-
|
|
873
|
+
const newFailureCount = currentFailureCount + 1;
|
|
874
|
+
this.logger?.error("[Connector] Transaction completion FAILED:", {
|
|
865
875
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
866
|
-
errorKeys: error && typeof error === "object" ? Object.keys(error) : [],
|
|
867
|
-
errorObject: JSON.stringify(error, null, 2),
|
|
868
876
|
classified,
|
|
869
877
|
isPermanent: classified.isPermanent,
|
|
878
|
+
failureCount: newFailureCount,
|
|
879
|
+
maxFailures: _SupabaseConnector.COMPLETION_MAX_FAILURES,
|
|
870
880
|
entries: successfulEntries.map((e) => ({
|
|
871
881
|
table: e.table,
|
|
872
882
|
op: e.op,
|
|
873
883
|
id: e.id
|
|
874
884
|
}))
|
|
875
885
|
});
|
|
886
|
+
if (__DEV__) {
|
|
887
|
+
console.error("[Connector] Transaction completion error details:", {
|
|
888
|
+
errorKeys: error && typeof error === "object" ? Object.keys(error) : [],
|
|
889
|
+
errorObject: JSON.stringify(error, null, 2),
|
|
890
|
+
failureCount: newFailureCount
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
if (currentFailureCount === 0) {
|
|
894
|
+
if (__DEV__) {
|
|
895
|
+
console.log("[Connector] First completion failure - retrying with extended timeout (60s)...");
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
await withTimeout(transaction.complete(), _SupabaseConnector.COMPLETION_EXTENDED_TIMEOUT_MS, "Transaction complete extended timeout");
|
|
899
|
+
if (__DEV__) {
|
|
900
|
+
console.log("[Connector] Transaction completed on extended retry");
|
|
901
|
+
}
|
|
902
|
+
this.completionFailures.delete(fingerprint);
|
|
903
|
+
this.onTransactionSuccess?.(successfulEntries);
|
|
904
|
+
this.onTransactionComplete?.(successfulEntries);
|
|
905
|
+
return;
|
|
906
|
+
} catch (retryError) {
|
|
907
|
+
if (__DEV__) {
|
|
908
|
+
console.warn("[Connector] Extended retry also failed:", {
|
|
909
|
+
error: retryError instanceof Error ? retryError.message : String(retryError)
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
this.completionFailures.set(fingerprint, {
|
|
915
|
+
count: newFailureCount,
|
|
916
|
+
lastAttempt: /* @__PURE__ */ new Date()
|
|
917
|
+
});
|
|
918
|
+
if (this.completionFailures.size > 50) {
|
|
919
|
+
const oldestKey = [...this.completionFailures.keys()][0];
|
|
920
|
+
if (oldestKey) {
|
|
921
|
+
this.completionFailures.delete(oldestKey);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
876
924
|
this.logger?.error("[Connector] Transaction completion error:", {
|
|
877
925
|
error,
|
|
878
926
|
classified,
|
|
927
|
+
failureCount: newFailureCount,
|
|
928
|
+
willRetry: newFailureCount < _SupabaseConnector.COMPLETION_MAX_FAILURES,
|
|
879
929
|
entries: successfulEntries.map((e) => ({
|
|
880
930
|
table: e.table,
|
|
881
931
|
op: e.op,
|
|
@@ -888,106 +938,175 @@ var SupabaseConnector = class {
|
|
|
888
938
|
}
|
|
889
939
|
/**
|
|
890
940
|
* Process a transaction without conflict detection.
|
|
891
|
-
*
|
|
941
|
+
* Uses batched operations for PUT and DELETE, individual processing for PATCH.
|
|
892
942
|
*/
|
|
893
943
|
async processTransaction(transaction, _database) {
|
|
894
|
-
const criticalFailures = [];
|
|
895
944
|
const successfulEntries = [];
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
945
|
+
const entriesByTable = groupEntriesByTable(transaction.crud);
|
|
946
|
+
if (__DEV__) {
|
|
947
|
+
console.log("[Connector] Processing transaction with batching:", {
|
|
948
|
+
totalEntries: transaction.crud.length,
|
|
949
|
+
tables: Array.from(entriesByTable.keys()),
|
|
950
|
+
entriesPerTable: Object.fromEntries(Array.from(entriesByTable.entries()).map(([table, entries]) => [table, {
|
|
951
|
+
total: entries.length,
|
|
952
|
+
put: entries.filter((e) => e.op === "PUT" /* PUT */).length,
|
|
953
|
+
patch: entries.filter((e) => e.op === "PATCH" /* PATCH */).length,
|
|
954
|
+
delete: entries.filter((e) => e.op === "DELETE" /* DELETE */).length
|
|
955
|
+
}]))
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
for (const [table, entries] of entriesByTable) {
|
|
959
|
+
const schema = this.schemaRouter(table);
|
|
960
|
+
const putEntries = entries.filter((e) => e.op === "PUT" /* PUT */);
|
|
961
|
+
const patchEntries = entries.filter((e) => e.op === "PATCH" /* PATCH */);
|
|
962
|
+
const deleteEntries = entries.filter((e) => e.op === "DELETE" /* DELETE */);
|
|
963
|
+
if (this.crudHandler) {
|
|
964
|
+
for (const entry of entries) {
|
|
965
|
+
await this.processWithRetry(entry);
|
|
966
|
+
successfulEntries.push(entry);
|
|
967
|
+
}
|
|
968
|
+
continue;
|
|
904
969
|
}
|
|
905
|
-
|
|
970
|
+
if (putEntries.length > 0) {
|
|
971
|
+
const successful = await this.processBatchedPuts(table, schema, putEntries);
|
|
972
|
+
successfulEntries.push(...successful);
|
|
973
|
+
}
|
|
974
|
+
if (deleteEntries.length > 0) {
|
|
975
|
+
const successful = await this.processBatchedDeletes(table, schema, deleteEntries);
|
|
976
|
+
successfulEntries.push(...successful);
|
|
977
|
+
}
|
|
978
|
+
for (const entry of patchEntries) {
|
|
979
|
+
if (__DEV__) {
|
|
980
|
+
console.log("[Connector] Processing PATCH entry individually:", {
|
|
981
|
+
table: entry.table,
|
|
982
|
+
op: entry.op,
|
|
983
|
+
id: entry.id,
|
|
984
|
+
opData: entry.opData
|
|
985
|
+
});
|
|
986
|
+
}
|
|
906
987
|
await this.processWithRetry(entry);
|
|
907
988
|
successfulEntries.push(entry);
|
|
908
|
-
} catch (error) {
|
|
909
|
-
criticalFailures.push({
|
|
910
|
-
entry,
|
|
911
|
-
error: error instanceof Error ? error : new Error(String(error))
|
|
912
|
-
});
|
|
913
989
|
}
|
|
914
990
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
table: f.entry.table,
|
|
935
|
-
op: f.entry.op,
|
|
936
|
-
id: f.entry.id
|
|
937
|
-
}))
|
|
991
|
+
await this.finalizeTransaction({
|
|
992
|
+
transaction,
|
|
993
|
+
successfulEntries
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Process batched PUT (upsert) operations for a single table.
|
|
998
|
+
* Falls back to individual processing if batch fails.
|
|
999
|
+
* CRITICAL: Throws on first failure to maintain transaction atomicity.
|
|
1000
|
+
* @returns Array of successfully processed entries
|
|
1001
|
+
* @throws Error on first failure - keeps entire transaction in ps_crud
|
|
1002
|
+
*/
|
|
1003
|
+
async processBatchedPuts(table, schema, entries) {
|
|
1004
|
+
const successful = [];
|
|
1005
|
+
if (__DEV__) {
|
|
1006
|
+
console.log("[Connector] Processing batched PUTs:", {
|
|
1007
|
+
schema,
|
|
1008
|
+
table,
|
|
1009
|
+
count: entries.length
|
|
938
1010
|
});
|
|
939
|
-
this.onTransactionFailure?.(criticalFailures.map((f) => f.entry), firstFailure.error, classified);
|
|
940
|
-
throw firstFailure.error;
|
|
941
1011
|
}
|
|
1012
|
+
const rows = entries.map((entry) => ({
|
|
1013
|
+
id: entry.id,
|
|
1014
|
+
...entry.opData
|
|
1015
|
+
}));
|
|
1016
|
+
const query = schema === "public" ? this.supabase.from(table) : this.supabase.schema(schema).from(table);
|
|
1017
|
+
const {
|
|
1018
|
+
error
|
|
1019
|
+
} = await query.upsert(rows, {
|
|
1020
|
+
onConflict: "id"
|
|
1021
|
+
});
|
|
1022
|
+
if (error) {
|
|
1023
|
+
if (__DEV__) {
|
|
1024
|
+
console.warn("[Connector] Batched PUT failed, falling back to individual processing:", {
|
|
1025
|
+
schema,
|
|
1026
|
+
table,
|
|
1027
|
+
error: error.message
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
for (const entry of entries) {
|
|
1031
|
+
await this.processWithRetry(entry);
|
|
1032
|
+
successful.push(entry);
|
|
1033
|
+
}
|
|
1034
|
+
} else {
|
|
1035
|
+
if (__DEV__) {
|
|
1036
|
+
console.log("[Connector] Batched PUT successful:", {
|
|
1037
|
+
schema,
|
|
1038
|
+
table,
|
|
1039
|
+
count: entries.length
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
successful.push(...entries);
|
|
1043
|
+
}
|
|
1044
|
+
return successful;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Process batched DELETE operations for a single table.
|
|
1048
|
+
* Falls back to individual processing if batch fails.
|
|
1049
|
+
* CRITICAL: Throws on first failure to maintain transaction atomicity.
|
|
1050
|
+
* @returns Array of successfully processed entries
|
|
1051
|
+
* @throws Error on first failure - keeps entire transaction in ps_crud
|
|
1052
|
+
*/
|
|
1053
|
+
async processBatchedDeletes(table, schema, entries) {
|
|
1054
|
+
const successful = [];
|
|
942
1055
|
if (__DEV__) {
|
|
943
|
-
console.log("[Connector]
|
|
1056
|
+
console.log("[Connector] Processing batched DELETEs:", {
|
|
1057
|
+
schema,
|
|
1058
|
+
table,
|
|
1059
|
+
count: entries.length
|
|
1060
|
+
});
|
|
944
1061
|
}
|
|
945
|
-
|
|
946
|
-
|
|
1062
|
+
const ids = entries.map((entry) => entry.id);
|
|
1063
|
+
const query = schema === "public" ? this.supabase.from(table) : this.supabase.schema(schema).from(table);
|
|
1064
|
+
const {
|
|
1065
|
+
error
|
|
1066
|
+
} = await query.delete().in("id", ids);
|
|
1067
|
+
if (error) {
|
|
947
1068
|
if (__DEV__) {
|
|
948
|
-
console.
|
|
949
|
-
|
|
1069
|
+
console.warn("[Connector] Batched DELETE failed, falling back to individual processing:", {
|
|
1070
|
+
schema,
|
|
1071
|
+
table,
|
|
1072
|
+
error: error.message
|
|
950
1073
|
});
|
|
951
1074
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
id: e.id
|
|
966
|
-
}))
|
|
967
|
-
});
|
|
968
|
-
this.logger?.error("[Connector] Transaction completion error:", {
|
|
969
|
-
error,
|
|
970
|
-
classified,
|
|
971
|
-
entries: successfulEntries.map((e) => ({
|
|
972
|
-
table: e.table,
|
|
973
|
-
op: e.op,
|
|
974
|
-
id: e.id
|
|
975
|
-
}))
|
|
976
|
-
});
|
|
977
|
-
this.onTransactionFailure?.(successfulEntries, error instanceof Error ? error : new Error(String(error)), classified);
|
|
978
|
-
throw error;
|
|
1075
|
+
for (const entry of entries) {
|
|
1076
|
+
await this.processWithRetry(entry);
|
|
1077
|
+
successful.push(entry);
|
|
1078
|
+
}
|
|
1079
|
+
} else {
|
|
1080
|
+
if (__DEV__) {
|
|
1081
|
+
console.log("[Connector] Batched DELETE successful:", {
|
|
1082
|
+
schema,
|
|
1083
|
+
table,
|
|
1084
|
+
count: entries.length
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
successful.push(...entries);
|
|
979
1088
|
}
|
|
1089
|
+
return successful;
|
|
980
1090
|
}
|
|
981
1091
|
/**
|
|
982
1092
|
* Check if a table has a _version column (cached).
|
|
1093
|
+
* P4.1: Uses Promise-based locking to prevent duplicate concurrent queries.
|
|
983
1094
|
*/
|
|
984
1095
|
async checkVersionColumn(table, db) {
|
|
985
1096
|
if (this.versionColumnCache.has(table)) {
|
|
986
1097
|
return this.versionColumnCache.get(table);
|
|
987
1098
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1099
|
+
if (!this.versionColumnPromises.has(table)) {
|
|
1100
|
+
this.versionColumnPromises.set(table, hasVersionColumn(table, db).then((result) => {
|
|
1101
|
+
this.versionColumnCache.set(table, result);
|
|
1102
|
+
this.versionColumnPromises.delete(table);
|
|
1103
|
+
return result;
|
|
1104
|
+
}).catch((error) => {
|
|
1105
|
+
this.versionColumnPromises.delete(table);
|
|
1106
|
+
throw error;
|
|
1107
|
+
}));
|
|
1108
|
+
}
|
|
1109
|
+
return this.versionColumnPromises.get(table);
|
|
991
1110
|
}
|
|
992
1111
|
/**
|
|
993
1112
|
* Filter opData to only include specified fields.
|
|
@@ -1003,12 +1122,6 @@ var SupabaseConnector = class {
|
|
|
1003
1122
|
}
|
|
1004
1123
|
return filtered;
|
|
1005
1124
|
}
|
|
1006
|
-
/**
|
|
1007
|
-
* Process a single CRUD operation.
|
|
1008
|
-
*
|
|
1009
|
-
* UUID-native tables (public schema, post-migration) use `id` as the UUID column.
|
|
1010
|
-
* Core schema tables (Profile, Comment, CommentSection) still use a separate `uuid` column.
|
|
1011
|
-
*/
|
|
1012
1125
|
/**
|
|
1013
1126
|
* Process a single CRUD operation.
|
|
1014
1127
|
*
|
|
@@ -1018,6 +1131,16 @@ var SupabaseConnector = class {
|
|
|
1018
1131
|
const table = entry.table;
|
|
1019
1132
|
const id = entry.id;
|
|
1020
1133
|
const schema = this.schemaRouter(table);
|
|
1134
|
+
if (entry.op === "PATCH" /* PATCH */ && Object.keys(entry.opData ?? {}).length === 0) {
|
|
1135
|
+
this.logger?.debug(`[Connector] Skipping empty PATCH for ${entry.table}:${entry.id}`);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (entry.opData) {
|
|
1139
|
+
const payloadSize = JSON.stringify(entry.opData).length;
|
|
1140
|
+
if (payloadSize > MAX_PAYLOAD_SIZE) {
|
|
1141
|
+
throw new ValidationError(`Payload too large (${(payloadSize / 1024).toFixed(0)}KB > 900KB) for ${schema}.${table}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1021
1144
|
if (this.crudHandler) {
|
|
1022
1145
|
let handled = false;
|
|
1023
1146
|
switch (entry.op) {
|
|
@@ -1164,17 +1287,11 @@ var SupabaseConnector = class {
|
|
|
1164
1287
|
export {
|
|
1165
1288
|
defaultSchemaRouter,
|
|
1166
1289
|
DEFAULT_RETRY_CONFIG,
|
|
1290
|
+
ConflictDetectionError,
|
|
1167
1291
|
detectConflicts,
|
|
1168
1292
|
hasVersionColumn,
|
|
1169
1293
|
fetchServerVersion,
|
|
1170
1294
|
getLocalVersion,
|
|
1171
|
-
AbortError,
|
|
1172
|
-
RetryExhaustedError,
|
|
1173
|
-
calculateBackoffDelay,
|
|
1174
|
-
addJitter,
|
|
1175
|
-
sleep,
|
|
1176
|
-
DEFAULT_BACKOFF_CONFIG,
|
|
1177
|
-
withExponentialBackoff,
|
|
1178
1295
|
SupabaseConnector
|
|
1179
1296
|
};
|
|
1180
|
-
//# sourceMappingURL=chunk-
|
|
1297
|
+
//# sourceMappingURL=chunk-KN2IZERF.js.map
|