@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.
Files changed (128) hide show
  1. package/README.md +933 -0
  2. package/dist/CacheSettingsManager-uz-kbnRH.d.ts +461 -0
  3. package/dist/attachments/index.d.ts +745 -332
  4. package/dist/attachments/index.js +152 -6
  5. package/dist/{types-Cd7RhNqf.d.ts → background-sync-ChCXW-EV.d.ts} +53 -2
  6. package/dist/chunk-24RDMMCL.js +44 -0
  7. package/dist/chunk-24RDMMCL.js.map +1 -0
  8. package/dist/chunk-4TXTAEF2.js +2060 -0
  9. package/dist/chunk-4TXTAEF2.js.map +1 -0
  10. package/dist/chunk-63PXSPIN.js +358 -0
  11. package/dist/chunk-63PXSPIN.js.map +1 -0
  12. package/dist/chunk-654ERHA7.js +1 -0
  13. package/dist/chunk-A4IBBWGO.js +377 -0
  14. package/dist/chunk-A4IBBWGO.js.map +1 -0
  15. package/dist/chunk-BRXQNASY.js +1720 -0
  16. package/dist/chunk-BRXQNASY.js.map +1 -0
  17. package/dist/chunk-CAB26E6F.js +142 -0
  18. package/dist/chunk-CAB26E6F.js.map +1 -0
  19. package/dist/{chunk-EJ23MXPQ.js → chunk-CGL33PL4.js} +3 -1
  20. package/dist/chunk-CGL33PL4.js.map +1 -0
  21. package/dist/{chunk-R4YFWQ3Q.js → chunk-CUCAYK7Z.js} +309 -92
  22. package/dist/chunk-CUCAYK7Z.js.map +1 -0
  23. package/dist/chunk-FV2HXEIY.js +124 -0
  24. package/dist/chunk-FV2HXEIY.js.map +1 -0
  25. package/dist/chunk-HWSNV45P.js +279 -0
  26. package/dist/chunk-HWSNV45P.js.map +1 -0
  27. package/dist/{chunk-62J2DPKX.js → chunk-KN2IZERF.js} +530 -413
  28. package/dist/chunk-KN2IZERF.js.map +1 -0
  29. package/dist/{chunk-7EMDVIZX.js → chunk-N75DEF5J.js} +19 -1
  30. package/dist/chunk-N75DEF5J.js.map +1 -0
  31. package/dist/chunk-P4HZA6ZT.js +83 -0
  32. package/dist/chunk-P4HZA6ZT.js.map +1 -0
  33. package/dist/chunk-P6WOZO7H.js +49 -0
  34. package/dist/chunk-P6WOZO7H.js.map +1 -0
  35. package/dist/chunk-T4AO7JIG.js +1 -0
  36. package/dist/chunk-TGBT5XBE.js +1 -0
  37. package/dist/{chunk-FPTDATY5.js → chunk-VACPAAQZ.js} +54 -12
  38. package/dist/chunk-VACPAAQZ.js.map +1 -0
  39. package/dist/chunk-WGHNIAF7.js +329 -0
  40. package/dist/chunk-WGHNIAF7.js.map +1 -0
  41. package/dist/{chunk-3AYXHQ4W.js → chunk-WN5ZJ3E2.js} +108 -47
  42. package/dist/chunk-WN5ZJ3E2.js.map +1 -0
  43. package/dist/chunk-XAEII4ZX.js +456 -0
  44. package/dist/chunk-XAEII4ZX.js.map +1 -0
  45. package/dist/chunk-XOY2CJ67.js +289 -0
  46. package/dist/chunk-XOY2CJ67.js.map +1 -0
  47. package/dist/chunk-YHTZ7VMV.js +1 -0
  48. package/dist/chunk-YSTEESEG.js +676 -0
  49. package/dist/chunk-YSTEESEG.js.map +1 -0
  50. package/dist/chunk-Z6VOBGTU.js +32 -0
  51. package/dist/chunk-Z6VOBGTU.js.map +1 -0
  52. package/dist/chunk-ZM4ENYMF.js +230 -0
  53. package/dist/chunk-ZM4ENYMF.js.map +1 -0
  54. package/dist/connector/index.d.ts +236 -4
  55. package/dist/connector/index.js +15 -4
  56. package/dist/core/index.d.ts +16 -3
  57. package/dist/core/index.js +6 -2
  58. package/dist/error/index.d.ts +54 -0
  59. package/dist/error/index.js +7 -0
  60. package/dist/error/index.js.map +1 -0
  61. package/dist/index.d.ts +102 -12
  62. package/dist/index.js +309 -37
  63. package/dist/index.native.d.ts +22 -10
  64. package/dist/index.native.js +309 -38
  65. package/dist/index.web.d.ts +22 -10
  66. package/dist/index.web.js +310 -38
  67. package/dist/maintenance/index.d.ts +118 -0
  68. package/dist/maintenance/index.js +16 -0
  69. package/dist/maintenance/index.js.map +1 -0
  70. package/dist/platform/index.d.ts +16 -1
  71. package/dist/platform/index.js.map +1 -1
  72. package/dist/platform/index.native.d.ts +2 -2
  73. package/dist/platform/index.native.js +1 -1
  74. package/dist/platform/index.web.d.ts +1 -1
  75. package/dist/platform/index.web.js +1 -1
  76. package/dist/pol-attachment-queue-BVAIueoP.d.ts +817 -0
  77. package/dist/provider/index.d.ts +451 -21
  78. package/dist/provider/index.js +32 -13
  79. package/dist/react/index.d.ts +372 -0
  80. package/dist/react/index.js +25 -0
  81. package/dist/react/index.js.map +1 -0
  82. package/dist/storage/index.d.ts +6 -0
  83. package/dist/storage/index.js +42 -0
  84. package/dist/storage/index.js.map +1 -0
  85. package/dist/storage/index.native.d.ts +6 -0
  86. package/dist/storage/index.native.js +40 -0
  87. package/dist/storage/index.native.js.map +1 -0
  88. package/dist/storage/index.web.d.ts +6 -0
  89. package/dist/storage/index.web.js +40 -0
  90. package/dist/storage/index.web.js.map +1 -0
  91. package/dist/storage/upload/index.d.ts +54 -0
  92. package/dist/storage/upload/index.js +15 -0
  93. package/dist/storage/upload/index.js.map +1 -0
  94. package/dist/storage/upload/index.native.d.ts +56 -0
  95. package/dist/storage/upload/index.native.js +15 -0
  96. package/dist/storage/upload/index.native.js.map +1 -0
  97. package/dist/storage/upload/index.web.d.ts +2 -0
  98. package/dist/storage/upload/index.web.js +14 -0
  99. package/dist/storage/upload/index.web.js.map +1 -0
  100. package/dist/supabase-connector-T9vHq_3i.d.ts +202 -0
  101. package/dist/sync/index.d.ts +288 -23
  102. package/dist/sync/index.js +22 -10
  103. package/dist/{index-l3iL9Jte.d.ts → types-B212hgfA.d.ts} +101 -158
  104. package/dist/{types-afHtE1U_.d.ts → types-CDqWh56B.d.ts} +2 -0
  105. package/dist/types-CyvBaAl8.d.ts +60 -0
  106. package/dist/types-D0WcHrq6.d.ts +234 -0
  107. package/package.json +89 -5
  108. package/dist/chunk-32OLICZO.js +0 -1
  109. package/dist/chunk-3AYXHQ4W.js.map +0 -1
  110. package/dist/chunk-5FIMA26D.js +0 -1
  111. package/dist/chunk-62J2DPKX.js.map +0 -1
  112. package/dist/chunk-7EMDVIZX.js.map +0 -1
  113. package/dist/chunk-EJ23MXPQ.js.map +0 -1
  114. package/dist/chunk-FPTDATY5.js.map +0 -1
  115. package/dist/chunk-KCDG2MNP.js +0 -1431
  116. package/dist/chunk-KCDG2MNP.js.map +0 -1
  117. package/dist/chunk-OLHGI472.js +0 -1
  118. package/dist/chunk-PAFBKNL3.js +0 -99
  119. package/dist/chunk-PAFBKNL3.js.map +0 -1
  120. package/dist/chunk-R4YFWQ3Q.js.map +0 -1
  121. package/dist/chunk-V6LJ6MR2.js +0 -740
  122. package/dist/chunk-V6LJ6MR2.js.map +0 -1
  123. package/dist/chunk-VJCL2SWD.js +0 -1
  124. package/dist/failed-upload-store-C0cLxxPz.d.ts +0 -33
  125. /package/dist/{chunk-32OLICZO.js.map → chunk-654ERHA7.js.map} +0 -0
  126. /package/dist/{chunk-5FIMA26D.js.map → chunk-T4AO7JIG.js.map} +0 -0
  127. /package/dist/{chunk-OLHGI472.js.map → chunk-TGBT5XBE.js.map} +0 -0
  128. /package/dist/{chunk-VJCL2SWD.js.map → chunk-YHTZ7VMV.js.map} +0 -0
@@ -1,9 +1,10 @@
1
1
  import {
2
- failedUploadStore
3
- } from "./chunk-PAFBKNL3.js";
2
+ withExponentialBackoff
3
+ } from "./chunk-FV2HXEIY.js";
4
4
  import {
5
- classifySupabaseError
6
- } from "./chunk-FPTDATY5.js";
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
- maxDelayMs: 3e4,
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: 2,
19
- baseDelayMs: 5e3,
20
- maxDelayMs: 3e5,
21
- backoffMultiplier: 3
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
- console.warn("[detectConflicts] Failed to query AuditLog:", error);
58
- return {
59
- hasConflict: false,
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] !== newValue && !serverChanges.has(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/utils/retry.ts
133
- var AbortError = class extends Error {
134
- constructor(message = "Operation aborted") {
180
+ // src/connector/supabase-connector.ts
181
+ var ValidationError = class extends Error {
182
+ constructor(message) {
135
183
  super(message);
136
- this.name = "AbortError";
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 calculateBackoffDelay(attempt, config) {
152
- const {
153
- baseDelayMs,
154
- maxDelayMs,
155
- backoffMultiplier
156
- } = config;
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
- if (ms <= 0) {
172
- resolve();
173
- return;
194
+ const code = error.code;
195
+ if (code === "401" || code === "403" || code === "42501") {
196
+ return true;
174
197
  }
175
- let timeoutId;
176
- const handleAbort = () => {
177
- if (timeoutId !== void 0) {
178
- clearTimeout(timeoutId);
179
- }
180
- reject(new AbortError());
181
- };
182
- if (signal) {
183
- signal.addEventListener("abort", handleAbort, {
184
- once: true
185
- });
186
- }
187
- timeoutId = setTimeout(() => {
188
- if (signal) {
189
- signal.removeEventListener("abort", handleAbort);
190
- }
191
- resolve();
192
- }, ms);
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 DEFAULT_BACKOFF_CONFIG = {
196
- maxRetries: 3,
197
- baseDelayMs: 1e3,
198
- maxDelayMs: 3e4,
199
- backoffMultiplier: 2
200
- };
201
- async function withExponentialBackoff(fn, config, options) {
202
- const {
203
- maxRetries,
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
- throw new RetryExhaustedError(lastError, totalAttempts);
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 withExponentialBackoff(async () => {
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
- } catch (error) {
429
- const classifiedError = classifySupabaseError(error);
430
- classified.isPermanent = classifiedError.isPermanent;
431
- classified.pgCode = classifiedError.pgCode;
432
- classified.userMessage = classifiedError.userMessage;
433
- if (classifiedError.isPermanent) {
434
- selectedConfig = this.retryConfig.permanent;
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 = lastError ?? (error instanceof Error ? error : new Error(String(error)));
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] Entry added to failed upload store:", {
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
- nextRetryAt: new Date(nextRetryAt).toISOString()
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
- if (classified.isPermanent) {
508
- throw finalError;
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
- if (__DEV__) {
569
- console.log("[Connector] Upload skipped - sync mode does not allow uploads");
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
- if (!this.autoRetryPaused) {
575
- const retryableUploads = failedUploadStore.getRetryable();
576
- if (retryableUploads.length > 0) {
577
- if (__DEV__) {
578
- console.log("[Connector] Processing retryable failed uploads:", {
579
- count: retryableUploads.length
580
- });
581
- }
582
- for (const upload of retryableUploads) {
583
- try {
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
- console.warn("[Connector] Conflict detected but no handler:", {
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
- return;
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
- try {
787
- await this.processWithRetry(entry);
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: entriesDiscarded.length
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
- console.error("[PowerSync Connector] Transaction completion FAILED:", {
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
- * Used when conflict detection is disabled.
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
- for (const entry of transaction.crud) {
897
- if (__DEV__) {
898
- console.log("[Connector] Processing CRUD entry with retry:", {
899
- table: entry.table,
900
- op: entry.op,
901
- id: entry.id,
902
- opData: entry.opData
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
- try {
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
- if (criticalFailures.length > 0) {
916
- const firstFailure = criticalFailures[0];
917
- const classified = classifySupabaseError(firstFailure.error);
918
- console.error("[PowerSync Connector] Critical upload failure:", {
919
- errorMessage: firstFailure.error.message,
920
- classified,
921
- isPermanent: classified.isPermanent,
922
- criticalCount: criticalFailures.length,
923
- successCount: successfulEntries.length,
924
- entries: criticalFailures.map((f) => ({
925
- table: f.entry.table,
926
- op: f.entry.op,
927
- id: f.entry.id
928
- }))
929
- });
930
- this.logger?.error("[Connector] Critical upload failure:", {
931
- error: firstFailure.error,
932
- classified,
933
- entries: criticalFailures.map((f) => ({
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] All CRUD entries processed, completing transaction...");
1056
+ console.log("[Connector] Processing batched DELETEs:", {
1057
+ schema,
1058
+ table,
1059
+ count: entries.length
1060
+ });
944
1061
  }
945
- try {
946
- await transaction.complete();
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.log("[Connector] Transaction completed successfully:", {
949
- entriesCount: successfulEntries.length
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
- this.onTransactionSuccess?.(successfulEntries);
953
- this.onTransactionComplete?.(successfulEntries);
954
- } catch (error) {
955
- const classified = classifySupabaseError(error);
956
- console.error("[PowerSync Connector] Transaction completion FAILED:", {
957
- errorMessage: error instanceof Error ? error.message : String(error),
958
- errorKeys: error && typeof error === "object" ? Object.keys(error) : [],
959
- errorObject: JSON.stringify(error, null, 2),
960
- classified,
961
- isPermanent: classified.isPermanent,
962
- entries: successfulEntries.map((e) => ({
963
- table: e.table,
964
- op: e.op,
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
- const hasVersion = await hasVersionColumn(table, db);
989
- this.versionColumnCache.set(table, hasVersion);
990
- return hasVersion;
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-62J2DPKX.js.map
1297
+ //# sourceMappingURL=chunk-KN2IZERF.js.map