stellar-drive 1.2.14 → 1.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/install-pwa.d.ts.map +1 -1
- package/dist/bin/install-pwa.js +16 -1
- package/dist/bin/install-pwa.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +2 -0
- package/dist/data.js.map +1 -1
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +482 -311
- package/dist/engine.js.map +1 -1
- package/dist/entries/types.d.ts +1 -1
- package/dist/entries/types.d.ts.map +1 -1
- package/dist/realtime.d.ts +1 -0
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +2 -1
- package/dist/realtime.js.map +1 -1
- package/dist/stores/sync.d.ts +44 -0
- package/dist/stores/sync.d.ts.map +1 -1
- package/dist/stores/sync.js +38 -2
- package/dist/stores/sync.js.map +1 -1
- package/package.json +1 -1
- package/src/components/SyncStatus.svelte +210 -10
package/dist/engine.js
CHANGED
|
@@ -138,6 +138,26 @@ function getColumns(name) {
|
|
|
138
138
|
const table = getEngineConfig().tables.find((t) => t.supabaseName === name || t.schemaKey === name);
|
|
139
139
|
return table?.columns || '*';
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Guarantee mandatory system-field defaults on an outbound sync payload.
|
|
143
|
+
*
|
|
144
|
+
* Every synced table in Supabase has `deleted boolean not null default false`.
|
|
145
|
+
* When an entity was written to IndexedDB by older code paths (before the
|
|
146
|
+
* `deleted` column existed) OR by a caller that passed a create payload
|
|
147
|
+
* without explicitly setting `deleted`, the field serializes as `undefined`
|
|
148
|
+
* (dropped by JSON) or `null` — both of which violate the NOT NULL
|
|
149
|
+
* constraint server-side.
|
|
150
|
+
*
|
|
151
|
+
* Rather than scattering this fix across every payload-assembly site, call
|
|
152
|
+
* this helper right before `filterPayloadToSchema()` so no code path can
|
|
153
|
+
* forget. Mutates and returns the same object for convenience.
|
|
154
|
+
*/
|
|
155
|
+
function ensureSystemFieldDefaults(payload) {
|
|
156
|
+
if (payload.deleted === undefined || payload.deleted === null) {
|
|
157
|
+
payload.deleted = false;
|
|
158
|
+
}
|
|
159
|
+
return payload;
|
|
160
|
+
}
|
|
141
161
|
/**
|
|
142
162
|
* Filter a payload to only include columns defined in the schema.
|
|
143
163
|
*
|
|
@@ -197,7 +217,10 @@ function getTombstoneMaxAgeDays() {
|
|
|
197
217
|
}
|
|
198
218
|
/** Minimum time tab must be hidden before triggering a sync on return. Default: 5min */
|
|
199
219
|
function getVisibilitySyncMinAwayMs() {
|
|
200
|
-
|
|
220
|
+
// 15 min default. No data loss: local writes always push immediately, and realtime
|
|
221
|
+
// (restarted on tab visible by Phase 1) delivers remote changes in real-time.
|
|
222
|
+
// This threshold only controls when a *poll* runs as backup on return.
|
|
223
|
+
return getEngineConfig().visibilitySyncMinAwayMs ?? 900000;
|
|
201
224
|
}
|
|
202
225
|
/** Cooldown after a successful sync before allowing reconnect-triggered sync. Default: 2min */
|
|
203
226
|
function getOnlineReconnectCooldownMs() {
|
|
@@ -412,6 +435,20 @@ let isTabVisible = true;
|
|
|
412
435
|
let visibilityDebounceTimeout = null;
|
|
413
436
|
/** When the tab became hidden (null if currently visible) — used to calculate away duration */
|
|
414
437
|
let tabHiddenAt = null;
|
|
438
|
+
/** Timer for tearing down realtime after tab is hidden for 15s */
|
|
439
|
+
let realtimeTeardownTimer = null;
|
|
440
|
+
/**
|
|
441
|
+
* Delay before tearing down realtime when tab is hidden.
|
|
442
|
+
* 15s prevents thrashing on quick tab switches (checking a notification and
|
|
443
|
+
* returning often takes 8-12s). After this delay, the WebSocket channel is
|
|
444
|
+
* fully closed — eliminating heartbeat egress (~21 pings/min) while hidden.
|
|
445
|
+
* Remote changes are caught by the visibility sync poll on return.
|
|
446
|
+
*/
|
|
447
|
+
const REALTIME_TEARDOWN_DELAY_MS = 15000;
|
|
448
|
+
/** Whether realtime was torn down while tab was hidden (needs restart on return) */
|
|
449
|
+
let realtimeWasTornDown = false;
|
|
450
|
+
/** When realtime was last (re)started — used for reconnect grace period in periodic sync */
|
|
451
|
+
let lastRealtimeStartAt = 0;
|
|
415
452
|
/** Debounce delay for visibility-change syncs (prevents rapid tab-switching spam) */
|
|
416
453
|
const VISIBILITY_SYNC_DEBOUNCE_MS = 1000;
|
|
417
454
|
/**
|
|
@@ -523,8 +560,13 @@ let lockPromise = null;
|
|
|
523
560
|
let lockResolve = null;
|
|
524
561
|
/** Timestamp when the lock was acquired (for stale-lock detection) */
|
|
525
562
|
let lockAcquiredAt = null;
|
|
526
|
-
/**
|
|
527
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Maximum time a sync lock can be held before force-release.
|
|
565
|
+
* 6 minutes — exceeds the max operation timeout (5 min push/pull) by 1 min
|
|
566
|
+
* headroom, preventing the watchdog from killing legitimate large batch
|
|
567
|
+
* operations (e.g. 10,000+ row push = 20 batch upserts of 500).
|
|
568
|
+
*/
|
|
569
|
+
const SYNC_LOCK_TIMEOUT_MS = 360000;
|
|
528
570
|
// --- Event listener references (stored for cleanup in stopSyncEngine) ---
|
|
529
571
|
let handleOnlineRef = null;
|
|
530
572
|
let handleOfflineRef = null;
|
|
@@ -941,13 +983,14 @@ async function pullRemoteChanges(minCursor) {
|
|
|
941
983
|
let offset = 0;
|
|
942
984
|
let hasMore = true;
|
|
943
985
|
while (hasMore) {
|
|
944
|
-
const { data, error } = await supabase
|
|
986
|
+
const { data, error } = (await withTimeout(Promise.resolve(supabase
|
|
945
987
|
.from(table.supabaseName)
|
|
946
988
|
.select(table.columns)
|
|
947
989
|
.gt('updated_at', lastSync)
|
|
948
990
|
.order('updated_at', { ascending: true })
|
|
949
991
|
.order('id', { ascending: true })
|
|
950
|
-
.range(offset, offset + PULL_PAGE_SIZE - 1);
|
|
992
|
+
.range(offset, offset + PULL_PAGE_SIZE - 1)), 30000, `Pull ${table.supabaseName} page at offset ${offset}`));
|
|
993
|
+
debugLog(`[SYNC] Pulled ${table.supabaseName} offset=${offset} rows=${data?.length ?? 0}`);
|
|
951
994
|
if (error)
|
|
952
995
|
return { data: allData, error };
|
|
953
996
|
if (!data)
|
|
@@ -958,8 +1001,10 @@ async function pullRemoteChanges(minCursor) {
|
|
|
958
1001
|
}
|
|
959
1002
|
return { data: allData, error: null };
|
|
960
1003
|
}
|
|
961
|
-
//
|
|
962
|
-
|
|
1004
|
+
// No global timeout here — outer timeout in runFullSync wraps the entire pull.
|
|
1005
|
+
// Per-page timeouts (below) catch individual hung requests without killing
|
|
1006
|
+
// large pulls (10,000+ rows = many sequential pages).
|
|
1007
|
+
const results = await Promise.all(config.tables.map(pullTablePaginated));
|
|
963
1008
|
// Check for errors
|
|
964
1009
|
for (let i = 0; i < results.length; i++) {
|
|
965
1010
|
if (results[i].error)
|
|
@@ -1145,148 +1190,322 @@ async function pushPendingOps() {
|
|
|
1145
1190
|
// "pending" state between sync cycles instead of silently consuming them.
|
|
1146
1191
|
const snapshotItems = await getPendingSync();
|
|
1147
1192
|
const snapshotIds = new Set(snapshotItems.map((item) => item.id));
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1193
|
+
// Start progress tracking for high-volume pushes so the UI can show
|
|
1194
|
+
// "1,200 of 2,500 changes synced…" instead of an opaque spinner that the
|
|
1195
|
+
// user interprets as "stuck". Threshold matches the realtime-suspend
|
|
1196
|
+
// threshold so the two cinematic "heavy sync" signals are in lockstep.
|
|
1197
|
+
//
|
|
1198
|
+
// A periodic monitor reads the live IndexedDB queue size every 400ms and
|
|
1199
|
+
// publishes it through the syncStatusStore. Polling IndexedDB is cheap
|
|
1200
|
+
// (indexed count query on a small table) and keeps the UI accurate no
|
|
1201
|
+
// matter which internal push path (batch, per-item fallback, singleton,
|
|
1202
|
+
// coalesced, duplicate-retry) the items take — we don't have to sprinkle
|
|
1203
|
+
// progress calls across every branch.
|
|
1204
|
+
const PROGRESS_THRESHOLD = 50;
|
|
1205
|
+
const trackProgress = snapshotItems.length >= PROGRESS_THRESHOLD;
|
|
1206
|
+
let progressMonitor = null;
|
|
1207
|
+
let lastReportedCompleted = 0;
|
|
1208
|
+
if (trackProgress) {
|
|
1209
|
+
syncStatusStore.startProgress(snapshotItems.length);
|
|
1210
|
+
syncStatusStore.setPendingCount(snapshotItems.length);
|
|
1211
|
+
progressMonitor = setInterval(async () => {
|
|
1212
|
+
try {
|
|
1213
|
+
const liveCount = await db.table('syncQueue').count();
|
|
1214
|
+
const completed = Math.max(0, snapshotItems.length - liveCount);
|
|
1215
|
+
syncStatusStore.setPendingCount(liveCount);
|
|
1216
|
+
const delta = completed - lastReportedCompleted;
|
|
1217
|
+
if (delta !== 0) {
|
|
1218
|
+
syncStatusStore.advanceProgress(delta);
|
|
1219
|
+
lastReportedCompleted = completed;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
catch (err) {
|
|
1223
|
+
debugWarn('[SYNC] Progress monitor tick failed:', err);
|
|
1224
|
+
}
|
|
1225
|
+
}, 400);
|
|
1226
|
+
}
|
|
1227
|
+
try {
|
|
1228
|
+
while (iterations < maxIterations) {
|
|
1229
|
+
const pendingItems = (await getPendingSync()).filter((item) => snapshotIds.has(item.id));
|
|
1230
|
+
if (pendingItems.length === 0)
|
|
1231
|
+
break;
|
|
1232
|
+
iterations++;
|
|
1233
|
+
let processedAny = false;
|
|
1234
|
+
// ── Batch creates: group by table and INSERT in bulk ──
|
|
1235
|
+
// This is critical for performance: CSV imports with hundreds of transactions
|
|
1236
|
+
// push in a few batch calls instead of hundreds of individual HTTP requests.
|
|
1237
|
+
const createItems = pendingItems.filter((item) => item.operationType === 'create');
|
|
1238
|
+
const nonCreateItems = pendingItems.filter((item) => item.operationType !== 'create');
|
|
1239
|
+
if (createItems.length > 0) {
|
|
1240
|
+
// Group creates by table, preserving queue order within each group.
|
|
1241
|
+
// Bulk-read all sync queue IDs in one IndexedDB call instead of
|
|
1242
|
+
// per-item reads (N sequential reads → 1 bulk read).
|
|
1243
|
+
const createQueueIds = createItems.filter((item) => item.id).map((item) => item.id);
|
|
1244
|
+
const queuedRows = await db.table('syncQueue').bulkGet(createQueueIds);
|
|
1245
|
+
const stillQueuedIds = new Set(queuedRows
|
|
1246
|
+
.map((row, i) => (row ? createQueueIds[i] : null))
|
|
1247
|
+
.filter((id) => id !== null));
|
|
1248
|
+
const createsByTable = new Map();
|
|
1249
|
+
for (const item of createItems) {
|
|
1250
|
+
if (item.id && !stillQueuedIds.has(item.id))
|
|
1251
|
+
continue;
|
|
1252
|
+
const existing = createsByTable.get(item.table) || [];
|
|
1253
|
+
existing.push(item);
|
|
1254
|
+
createsByTable.set(item.table, existing);
|
|
1255
|
+
}
|
|
1256
|
+
// Sort table order: parent tables before child tables to satisfy RLS FK checks.
|
|
1257
|
+
const schema = getEngineConfig().schema;
|
|
1258
|
+
const sortedTableEntries = [...createsByTable.entries()].sort(([tableA], [tableB]) => {
|
|
1259
|
+
if (!schema)
|
|
1260
|
+
return 0;
|
|
1261
|
+
// Resolve schema keys from supabase names (strip prefix)
|
|
1262
|
+
const configA = getEngineConfig().tables.find((t) => t.supabaseName === tableA);
|
|
1263
|
+
const configB = getEngineConfig().tables.find((t) => t.supabaseName === tableB);
|
|
1264
|
+
const keyA = configA?.schemaKey || tableA;
|
|
1265
|
+
const keyB = configB?.schemaKey || tableB;
|
|
1266
|
+
const aIsChild = isChildTable(schema, keyA);
|
|
1267
|
+
const bIsChild = isChildTable(schema, keyB);
|
|
1268
|
+
if (aIsChild && !bIsChild)
|
|
1269
|
+
return 1;
|
|
1270
|
+
if (!aIsChild && bIsChild)
|
|
1271
|
+
return -1;
|
|
1180
1272
|
return 0;
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
try {
|
|
1225
|
-
// Query which IDs from this batch already exist in Supabase
|
|
1226
|
-
const batchEntityIds = batchItems.map((item) => item.entityId);
|
|
1227
|
-
const { data: existingRows } = await supabase
|
|
1228
|
-
.from(tableName)
|
|
1229
|
-
.select('id')
|
|
1230
|
-
.in('id', batchEntityIds);
|
|
1231
|
-
const existingIds = new Set((existingRows || []).map((r) => r.id));
|
|
1232
|
-
// Remove already-synced items from queue
|
|
1233
|
-
const duplicateQueueIds = batchItems
|
|
1234
|
-
.filter((item) => existingIds.has(item.entityId) && item.id)
|
|
1235
|
-
.map((item) => item.id);
|
|
1236
|
-
if (duplicateQueueIds.length > 0) {
|
|
1237
|
-
await bulkRemoveSyncItems(duplicateQueueIds);
|
|
1238
|
-
processedAny = true;
|
|
1239
|
-
actualPushed += duplicateQueueIds.length;
|
|
1240
|
-
debugLog(`[SYNC] Removed ${duplicateQueueIds.length} already-synced items from queue`);
|
|
1241
|
-
}
|
|
1242
|
-
// Retry with only the truly new items
|
|
1243
|
-
const newBatch = batch.filter((row) => !existingIds.has(row.id));
|
|
1244
|
-
const newBatchItems = batchItems.filter((item) => !existingIds.has(item.entityId));
|
|
1245
|
-
if (newBatch.length > 0) {
|
|
1246
|
-
const { error: retryError } = await supabase
|
|
1273
|
+
});
|
|
1274
|
+
for (const [tableName, items] of sortedTableEntries) {
|
|
1275
|
+
const supabase = getSupabase();
|
|
1276
|
+
const deviceId = getDeviceId();
|
|
1277
|
+
// Build batch payload — filter to schema-defined columns only.
|
|
1278
|
+
// Ensure system field `deleted` is always present: create payloads
|
|
1279
|
+
// queued before the column defaulted locally (or by callers that
|
|
1280
|
+
// never set it) serialize with `undefined`/`null`, which violates
|
|
1281
|
+
// the Supabase NOT NULL constraint on `deleted`.
|
|
1282
|
+
const payloads = items.map((item) => {
|
|
1283
|
+
const rawPayload = {
|
|
1284
|
+
id: item.entityId,
|
|
1285
|
+
...item.value,
|
|
1286
|
+
device_id: deviceId
|
|
1287
|
+
};
|
|
1288
|
+
ensureSystemFieldDefaults(rawPayload);
|
|
1289
|
+
return filterPayloadToSchema(tableName, rawPayload);
|
|
1290
|
+
});
|
|
1291
|
+
// Batch insert (up to 500 at a time to stay within Supabase limits)
|
|
1292
|
+
const BATCH_SIZE = 500;
|
|
1293
|
+
for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
|
|
1294
|
+
const batch = payloads.slice(i, i + BATCH_SIZE);
|
|
1295
|
+
const batchItems = items.slice(i, i + BATCH_SIZE);
|
|
1296
|
+
try {
|
|
1297
|
+
debugLog(`[SYNC] Batch upsert ${batch.length} rows into ${tableName}`);
|
|
1298
|
+
const { error } = await supabase
|
|
1299
|
+
.from(tableName)
|
|
1300
|
+
.upsert(batch, { onConflict: 'id', ignoreDuplicates: false });
|
|
1301
|
+
if (error) {
|
|
1302
|
+
if (error.code === '23505') {
|
|
1303
|
+
// Duplicate key on a SECONDARY unique constraint (e.g., csv_import_hash,
|
|
1304
|
+
// teller_transaction_id). The primary `id` column is unique by UUID
|
|
1305
|
+
// generation, so this means another row with a different id already has
|
|
1306
|
+
// the same value for a secondary unique field.
|
|
1307
|
+
//
|
|
1308
|
+
// Strategy: query Supabase for which entity IDs already exist, remove those
|
|
1309
|
+
// from the queue (they're true duplicates), then retry with only the new ones.
|
|
1310
|
+
// This avoids the catastrophic individual fallback (500 sequential HTTP requests).
|
|
1311
|
+
debugLog(`[SYNC] Batch create hit secondary unique constraint for ${tableName} — filtering duplicates`);
|
|
1312
|
+
try {
|
|
1313
|
+
// Query which IDs from this batch already exist in Supabase
|
|
1314
|
+
const batchEntityIds = batchItems.map((item) => item.entityId);
|
|
1315
|
+
const { data: existingRows } = await supabase
|
|
1247
1316
|
.from(tableName)
|
|
1248
|
-
.
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1317
|
+
.select('id')
|
|
1318
|
+
.in('id', batchEntityIds);
|
|
1319
|
+
const existingIds = new Set((existingRows || []).map((r) => r.id));
|
|
1320
|
+
// Remove already-synced items from queue
|
|
1321
|
+
const duplicateQueueIds = batchItems
|
|
1322
|
+
.filter((item) => existingIds.has(item.entityId) && item.id)
|
|
1323
|
+
.map((item) => item.id);
|
|
1324
|
+
if (duplicateQueueIds.length > 0) {
|
|
1325
|
+
await bulkRemoveSyncItems(duplicateQueueIds);
|
|
1326
|
+
processedAny = true;
|
|
1327
|
+
actualPushed += duplicateQueueIds.length;
|
|
1328
|
+
debugLog(`[SYNC] Removed ${duplicateQueueIds.length} already-synced items from queue`);
|
|
1329
|
+
}
|
|
1330
|
+
// Retry with only the truly new items
|
|
1331
|
+
const newBatch = batch.filter((row) => !existingIds.has(row.id));
|
|
1332
|
+
const newBatchItems = batchItems.filter((item) => !existingIds.has(item.entityId));
|
|
1333
|
+
if (newBatch.length > 0) {
|
|
1334
|
+
const { error: retryError } = await supabase
|
|
1335
|
+
.from(tableName)
|
|
1336
|
+
.upsert(newBatch, { onConflict: 'id', ignoreDuplicates: false });
|
|
1337
|
+
if (!retryError) {
|
|
1338
|
+
const idsToRemove = newBatchItems
|
|
1339
|
+
.filter((item) => item.id)
|
|
1340
|
+
.map((item) => item.id);
|
|
1341
|
+
if (idsToRemove.length > 0) {
|
|
1342
|
+
await bulkRemoveSyncItems(idsToRemove);
|
|
1343
|
+
processedAny = true;
|
|
1344
|
+
actualPushed += idsToRemove.length;
|
|
1345
|
+
}
|
|
1346
|
+
debugLog(`[SYNC] Batch create retry success: ${newBatch.length} new rows into ${tableName}`);
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
// Retry still failed — likely another secondary constraint issue.
|
|
1350
|
+
// Remove all items from queue to prevent infinite retry loops.
|
|
1351
|
+
debugError(`[SYNC] Batch create retry failed for ${tableName} — removing from queue to prevent retry storm:`, retryError);
|
|
1352
|
+
const allQueueIds = newBatchItems
|
|
1353
|
+
.filter((item) => item.id)
|
|
1354
|
+
.map((item) => item.id);
|
|
1355
|
+
if (allQueueIds.length > 0) {
|
|
1356
|
+
await bulkRemoveSyncItems(allQueueIds);
|
|
1357
|
+
processedAny = true;
|
|
1358
|
+
}
|
|
1257
1359
|
}
|
|
1258
|
-
debugLog(`[SYNC] Batch create retry success: ${newBatch.length} new rows into ${tableName}`);
|
|
1259
1360
|
}
|
|
1260
1361
|
else {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1362
|
+
debugLog(`[SYNC] All ${batch.length} items were duplicates — batch fully resolved`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
catch (filterError) {
|
|
1366
|
+
// If the filter query itself fails, remove items to prevent retry storm
|
|
1367
|
+
debugError(`[SYNC] Duplicate filter query failed for ${tableName} — removing from queue:`, filterError);
|
|
1368
|
+
const allQueueIds = batchItems
|
|
1369
|
+
.filter((item) => item.id)
|
|
1370
|
+
.map((item) => item.id);
|
|
1371
|
+
if (allQueueIds.length > 0) {
|
|
1372
|
+
await bulkRemoveSyncItems(allQueueIds);
|
|
1373
|
+
processedAny = true;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
// Non-duplicate error — fall back to individual to identify the problem row(s).
|
|
1379
|
+
// Common cause: RLS on child tables when parent hasn't synced yet.
|
|
1380
|
+
debugError(`[SYNC] Batch upsert failed for ${tableName}:`, error);
|
|
1381
|
+
for (const item of batchItems) {
|
|
1382
|
+
try {
|
|
1383
|
+
await processSyncItem(item);
|
|
1384
|
+
if (item.id) {
|
|
1385
|
+
await removeSyncItem(item.id);
|
|
1269
1386
|
processedAny = true;
|
|
1387
|
+
actualPushed++;
|
|
1270
1388
|
}
|
|
1271
1389
|
}
|
|
1390
|
+
catch (itemError) {
|
|
1391
|
+
handleSyncItemError(item, itemError);
|
|
1392
|
+
}
|
|
1272
1393
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
// Batch succeeded — bulk-remove all items from queue in one transaction
|
|
1398
|
+
const idsToRemove = batchItems.filter((item) => item.id).map((item) => item.id);
|
|
1399
|
+
if (idsToRemove.length > 0) {
|
|
1400
|
+
await bulkRemoveSyncItems(idsToRemove);
|
|
1401
|
+
processedAny = true;
|
|
1402
|
+
actualPushed += idsToRemove.length;
|
|
1403
|
+
}
|
|
1404
|
+
debugLog(`[SYNC] Batch upsert success: ${batch.length} rows into ${tableName}`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
catch (batchError) {
|
|
1408
|
+
// Network-level failure — fall back to individual
|
|
1409
|
+
debugError(`[SYNC] Batch insert threw for ${tableName}:`, batchError);
|
|
1410
|
+
for (const item of batchItems) {
|
|
1411
|
+
try {
|
|
1412
|
+
await processSyncItem(item);
|
|
1413
|
+
if (item.id) {
|
|
1414
|
+
await removeSyncItem(item.id);
|
|
1415
|
+
processedAny = true;
|
|
1416
|
+
actualPushed++;
|
|
1275
1417
|
}
|
|
1276
1418
|
}
|
|
1277
|
-
catch (
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1419
|
+
catch (itemError) {
|
|
1420
|
+
handleSyncItemError(item, itemError);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// ── Batch non-create operations: group by table and UPSERT in bulk ──
|
|
1428
|
+
// For set/delete/increment on non-singleton tables, read the full local entity
|
|
1429
|
+
// from IndexedDB and upsert in batches. This turns N sequential HTTP requests
|
|
1430
|
+
// into ceil(N/500) batch calls. Singleton tables need special ID reconciliation
|
|
1431
|
+
// and must be processed individually.
|
|
1432
|
+
const batchableItems = nonCreateItems.filter((item) => !isSingletonTable(item.table));
|
|
1433
|
+
const individualItems = nonCreateItems.filter((item) => isSingletonTable(item.table));
|
|
1434
|
+
if (batchableItems.length > 0) {
|
|
1435
|
+
// Bulk-read sync queue IDs for the still-queued check (same optimization as creates)
|
|
1436
|
+
const batchQueueIds = batchableItems.filter((item) => item.id).map((item) => item.id);
|
|
1437
|
+
const batchQueuedRows = await db.table('syncQueue').bulkGet(batchQueueIds);
|
|
1438
|
+
const batchStillQueuedIds = new Set(batchQueuedRows
|
|
1439
|
+
.map((row, i) => (row ? batchQueueIds[i] : null))
|
|
1440
|
+
.filter((id) => id !== null));
|
|
1441
|
+
// Group by table
|
|
1442
|
+
const itemsByTable = new Map();
|
|
1443
|
+
for (const item of batchableItems) {
|
|
1444
|
+
if (item.id && !batchStillQueuedIds.has(item.id))
|
|
1445
|
+
continue;
|
|
1446
|
+
const existing = itemsByTable.get(item.table) || [];
|
|
1447
|
+
existing.push(item);
|
|
1448
|
+
itemsByTable.set(item.table, existing);
|
|
1449
|
+
}
|
|
1450
|
+
for (const [tableName, items] of itemsByTable) {
|
|
1451
|
+
const supabase = getSupabase();
|
|
1452
|
+
const deviceId = getDeviceId();
|
|
1453
|
+
const dexieTable = getDexieTableName(tableName);
|
|
1454
|
+
// Build batch payload from local IndexedDB state (full entity rows).
|
|
1455
|
+
// Bulk-read all entities in one IndexedDB call instead of per-item reads.
|
|
1456
|
+
const entityIds = items.map((item) => item.entityId);
|
|
1457
|
+
const localEntities = await db.table(dexieTable).bulkGet(entityIds);
|
|
1458
|
+
const entityMap = new Map();
|
|
1459
|
+
localEntities.forEach((entity, i) => {
|
|
1460
|
+
if (entity)
|
|
1461
|
+
entityMap.set(entityIds[i], entity);
|
|
1462
|
+
});
|
|
1463
|
+
const payloads = [];
|
|
1464
|
+
const validItems = [];
|
|
1465
|
+
for (const item of items) {
|
|
1466
|
+
const localEntity = entityMap.get(item.entityId);
|
|
1467
|
+
if (!localEntity) {
|
|
1468
|
+
// Entity deleted locally — for delete ops this is expected (already gone),
|
|
1469
|
+
// for others skip it
|
|
1470
|
+
if (item.operationType === 'delete') {
|
|
1471
|
+
// Still need to ensure server-side deletion; fall back to individual
|
|
1472
|
+
try {
|
|
1473
|
+
await processSyncItem(item);
|
|
1474
|
+
if (item.id) {
|
|
1475
|
+
await removeSyncItem(item.id);
|
|
1283
1476
|
processedAny = true;
|
|
1477
|
+
actualPushed++;
|
|
1284
1478
|
}
|
|
1285
1479
|
}
|
|
1480
|
+
catch (itemError) {
|
|
1481
|
+
handleSyncItemError(item, itemError);
|
|
1482
|
+
}
|
|
1286
1483
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
// Strip internal Dexie fields, add device_id, filter to schema columns.
|
|
1487
|
+
// Ensure system field `deleted` is always present — IndexedDB rows created
|
|
1488
|
+
// before this field was set default to `undefined`, which serializes as `null`
|
|
1489
|
+
// and violates NOT NULL constraints on Supabase.
|
|
1490
|
+
const rawPayload = { ...localEntity, device_id: deviceId };
|
|
1491
|
+
ensureSystemFieldDefaults(rawPayload);
|
|
1492
|
+
delete rawPayload._version;
|
|
1493
|
+
payloads.push(filterPayloadToSchema(tableName, rawPayload));
|
|
1494
|
+
validItems.push(item);
|
|
1495
|
+
}
|
|
1496
|
+
if (payloads.length === 0)
|
|
1497
|
+
continue;
|
|
1498
|
+
const BATCH_SIZE = 500;
|
|
1499
|
+
for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
|
|
1500
|
+
const batch = payloads.slice(i, i + BATCH_SIZE);
|
|
1501
|
+
const batchItems = validItems.slice(i, i + BATCH_SIZE);
|
|
1502
|
+
try {
|
|
1503
|
+
debugLog(`[SYNC] Batch upsert ${batch.length} rows into ${tableName}`);
|
|
1504
|
+
const { error } = await supabase
|
|
1505
|
+
.from(tableName)
|
|
1506
|
+
.upsert(batch, { onConflict: 'id', ignoreDuplicates: false });
|
|
1507
|
+
if (error) {
|
|
1508
|
+
// Batch failed — fall back to individual processing
|
|
1290
1509
|
debugError(`[SYNC] Batch upsert failed for ${tableName}:`, error);
|
|
1291
1510
|
for (const item of batchItems) {
|
|
1292
1511
|
try {
|
|
@@ -1302,117 +1521,19 @@ async function pushPendingOps() {
|
|
|
1302
1521
|
}
|
|
1303
1522
|
}
|
|
1304
1523
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
await bulkRemoveSyncItems(idsToRemove);
|
|
1311
|
-
processedAny = true;
|
|
1312
|
-
actualPushed += idsToRemove.length;
|
|
1313
|
-
}
|
|
1314
|
-
debugLog(`[SYNC] Batch upsert success: ${batch.length} rows into ${tableName}`);
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
catch (batchError) {
|
|
1318
|
-
// Network-level failure — fall back to individual
|
|
1319
|
-
debugError(`[SYNC] Batch insert threw for ${tableName}:`, batchError);
|
|
1320
|
-
for (const item of batchItems) {
|
|
1321
|
-
try {
|
|
1322
|
-
await processSyncItem(item);
|
|
1323
|
-
if (item.id) {
|
|
1324
|
-
await removeSyncItem(item.id);
|
|
1325
|
-
processedAny = true;
|
|
1326
|
-
actualPushed++;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
catch (itemError) {
|
|
1330
|
-
handleSyncItemError(item, itemError);
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
// ── Batch non-create operations: group by table and UPSERT in bulk ──
|
|
1338
|
-
// For set/delete/increment on non-singleton tables, read the full local entity
|
|
1339
|
-
// from IndexedDB and upsert in batches. This turns N sequential HTTP requests
|
|
1340
|
-
// into ceil(N/500) batch calls. Singleton tables need special ID reconciliation
|
|
1341
|
-
// and must be processed individually.
|
|
1342
|
-
const batchableItems = nonCreateItems.filter((item) => !isSingletonTable(item.table));
|
|
1343
|
-
const individualItems = nonCreateItems.filter((item) => isSingletonTable(item.table));
|
|
1344
|
-
if (batchableItems.length > 0) {
|
|
1345
|
-
// Bulk-read sync queue IDs for the still-queued check (same optimization as creates)
|
|
1346
|
-
const batchQueueIds = batchableItems.filter((item) => item.id).map((item) => item.id);
|
|
1347
|
-
const batchQueuedRows = await db.table('syncQueue').bulkGet(batchQueueIds);
|
|
1348
|
-
const batchStillQueuedIds = new Set(batchQueuedRows
|
|
1349
|
-
.map((row, i) => (row ? batchQueueIds[i] : null))
|
|
1350
|
-
.filter((id) => id !== null));
|
|
1351
|
-
// Group by table
|
|
1352
|
-
const itemsByTable = new Map();
|
|
1353
|
-
for (const item of batchableItems) {
|
|
1354
|
-
if (item.id && !batchStillQueuedIds.has(item.id))
|
|
1355
|
-
continue;
|
|
1356
|
-
const existing = itemsByTable.get(item.table) || [];
|
|
1357
|
-
existing.push(item);
|
|
1358
|
-
itemsByTable.set(item.table, existing);
|
|
1359
|
-
}
|
|
1360
|
-
for (const [tableName, items] of itemsByTable) {
|
|
1361
|
-
const supabase = getSupabase();
|
|
1362
|
-
const deviceId = getDeviceId();
|
|
1363
|
-
const dexieTable = getDexieTableName(tableName);
|
|
1364
|
-
// Build batch payload from local IndexedDB state (full entity rows).
|
|
1365
|
-
// Bulk-read all entities in one IndexedDB call instead of per-item reads.
|
|
1366
|
-
const entityIds = items.map((item) => item.entityId);
|
|
1367
|
-
const localEntities = await db.table(dexieTable).bulkGet(entityIds);
|
|
1368
|
-
const entityMap = new Map();
|
|
1369
|
-
localEntities.forEach((entity, i) => {
|
|
1370
|
-
if (entity)
|
|
1371
|
-
entityMap.set(entityIds[i], entity);
|
|
1372
|
-
});
|
|
1373
|
-
const payloads = [];
|
|
1374
|
-
const validItems = [];
|
|
1375
|
-
for (const item of items) {
|
|
1376
|
-
const localEntity = entityMap.get(item.entityId);
|
|
1377
|
-
if (!localEntity) {
|
|
1378
|
-
// Entity deleted locally — for delete ops this is expected (already gone),
|
|
1379
|
-
// for others skip it
|
|
1380
|
-
if (item.operationType === 'delete') {
|
|
1381
|
-
// Still need to ensure server-side deletion; fall back to individual
|
|
1382
|
-
try {
|
|
1383
|
-
await processSyncItem(item);
|
|
1384
|
-
if (item.id) {
|
|
1385
|
-
await removeSyncItem(item.id);
|
|
1524
|
+
else {
|
|
1525
|
+
// Batch succeeded — bulk-remove all items from queue in one transaction
|
|
1526
|
+
const idsToRemove = batchItems.filter((item) => item.id).map((item) => item.id);
|
|
1527
|
+
if (idsToRemove.length > 0) {
|
|
1528
|
+
await bulkRemoveSyncItems(idsToRemove);
|
|
1386
1529
|
processedAny = true;
|
|
1387
|
-
actualPushed
|
|
1530
|
+
actualPushed += idsToRemove.length;
|
|
1388
1531
|
}
|
|
1389
|
-
|
|
1390
|
-
catch (itemError) {
|
|
1391
|
-
handleSyncItemError(item, itemError);
|
|
1532
|
+
debugLog(`[SYNC] Batch upsert success: ${batch.length} rows into ${tableName}`);
|
|
1392
1533
|
}
|
|
1393
1534
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
// Strip internal Dexie fields, add device_id, filter to schema columns
|
|
1397
|
-
const rawPayload = { ...localEntity, device_id: deviceId };
|
|
1398
|
-
delete rawPayload._version;
|
|
1399
|
-
payloads.push(filterPayloadToSchema(tableName, rawPayload));
|
|
1400
|
-
validItems.push(item);
|
|
1401
|
-
}
|
|
1402
|
-
if (payloads.length === 0)
|
|
1403
|
-
continue;
|
|
1404
|
-
const BATCH_SIZE = 500;
|
|
1405
|
-
for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
|
|
1406
|
-
const batch = payloads.slice(i, i + BATCH_SIZE);
|
|
1407
|
-
const batchItems = validItems.slice(i, i + BATCH_SIZE);
|
|
1408
|
-
try {
|
|
1409
|
-
debugLog(`[SYNC] Batch upsert ${batch.length} rows into ${tableName}`);
|
|
1410
|
-
const { error } = await supabase
|
|
1411
|
-
.from(tableName)
|
|
1412
|
-
.upsert(batch, { onConflict: 'id', ignoreDuplicates: false });
|
|
1413
|
-
if (error) {
|
|
1414
|
-
// Batch failed — fall back to individual processing
|
|
1415
|
-
debugError(`[SYNC] Batch upsert failed for ${tableName}:`, error);
|
|
1535
|
+
catch (batchError) {
|
|
1536
|
+
debugError(`[SYNC] Batch upsert threw for ${tableName}:`, batchError);
|
|
1416
1537
|
for (const item of batchItems) {
|
|
1417
1538
|
try {
|
|
1418
1539
|
await processSyncItem(item);
|
|
@@ -1427,62 +1548,57 @@ async function pushPendingOps() {
|
|
|
1427
1548
|
}
|
|
1428
1549
|
}
|
|
1429
1550
|
}
|
|
1430
|
-
else {
|
|
1431
|
-
// Batch succeeded — bulk-remove all items from queue in one transaction
|
|
1432
|
-
const idsToRemove = batchItems.filter((item) => item.id).map((item) => item.id);
|
|
1433
|
-
if (idsToRemove.length > 0) {
|
|
1434
|
-
await bulkRemoveSyncItems(idsToRemove);
|
|
1435
|
-
processedAny = true;
|
|
1436
|
-
actualPushed += idsToRemove.length;
|
|
1437
|
-
}
|
|
1438
|
-
debugLog(`[SYNC] Batch upsert success: ${batch.length} rows into ${tableName}`);
|
|
1439
|
-
}
|
|
1440
1551
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
}
|
|
1452
|
-
catch (itemError) {
|
|
1453
|
-
handleSyncItemError(item, itemError);
|
|
1454
|
-
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// ── Process singleton table operations individually (need ID reconciliation) ──
|
|
1555
|
+
for (const item of individualItems) {
|
|
1556
|
+
try {
|
|
1557
|
+
if (item.id) {
|
|
1558
|
+
const stillQueued = await db.table('syncQueue').get(item.id);
|
|
1559
|
+
if (!stillQueued) {
|
|
1560
|
+
debugLog(`[SYNC] Skipping purged item: ${item.operationType} ${item.table}/${item.entityId}`);
|
|
1561
|
+
continue;
|
|
1455
1562
|
}
|
|
1456
1563
|
}
|
|
1564
|
+
debugLog(`[SYNC] Processing: ${item.operationType} ${item.table}/${item.entityId}`);
|
|
1565
|
+
await processSyncItem(item);
|
|
1566
|
+
if (item.id) {
|
|
1567
|
+
await removeSyncItem(item.id);
|
|
1568
|
+
processedAny = true;
|
|
1569
|
+
actualPushed++;
|
|
1570
|
+
debugLog(`[SYNC] Success: ${item.operationType} ${item.table}/${item.entityId}`);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
catch (error) {
|
|
1574
|
+
handleSyncItemError(item, error);
|
|
1457
1575
|
}
|
|
1458
1576
|
}
|
|
1577
|
+
// If we didn't process anything (all items in backoff), stop iterating
|
|
1578
|
+
if (!processedAny)
|
|
1579
|
+
break;
|
|
1459
1580
|
}
|
|
1460
|
-
|
|
1461
|
-
|
|
1581
|
+
}
|
|
1582
|
+
finally {
|
|
1583
|
+
if (progressMonitor) {
|
|
1584
|
+
clearInterval(progressMonitor);
|
|
1585
|
+
// Final count flush before clearing progress — ensures the UI lands on
|
|
1586
|
+
// the exact remaining count rather than whatever the last tick showed.
|
|
1587
|
+
// Wrapped in try/catch because this runs inside finally and must not
|
|
1588
|
+
// mask an upstream error.
|
|
1462
1589
|
try {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1470
|
-
debugLog(`[SYNC] Processing: ${item.operationType} ${item.table}/${item.entityId}`);
|
|
1471
|
-
await processSyncItem(item);
|
|
1472
|
-
if (item.id) {
|
|
1473
|
-
await removeSyncItem(item.id);
|
|
1474
|
-
processedAny = true;
|
|
1475
|
-
actualPushed++;
|
|
1476
|
-
debugLog(`[SYNC] Success: ${item.operationType} ${item.table}/${item.entityId}`);
|
|
1477
|
-
}
|
|
1590
|
+
const finalLiveCount = await db.table('syncQueue').count();
|
|
1591
|
+
syncStatusStore.setPendingCount(finalLiveCount);
|
|
1592
|
+
const finalCompleted = Math.max(0, snapshotItems.length - finalLiveCount);
|
|
1593
|
+
const delta = finalCompleted - lastReportedCompleted;
|
|
1594
|
+
if (delta !== 0)
|
|
1595
|
+
syncStatusStore.advanceProgress(delta);
|
|
1478
1596
|
}
|
|
1479
|
-
catch (
|
|
1480
|
-
|
|
1597
|
+
catch (err) {
|
|
1598
|
+
debugWarn('[SYNC] Final progress flush failed:', err);
|
|
1481
1599
|
}
|
|
1600
|
+
syncStatusStore.clearProgress();
|
|
1482
1601
|
}
|
|
1483
|
-
// If we didn't process anything (all items in backoff), stop iterating
|
|
1484
|
-
if (!processedAny)
|
|
1485
|
-
break;
|
|
1486
1602
|
}
|
|
1487
1603
|
return { originalCount, coalescedCount, actualPushed };
|
|
1488
1604
|
}
|
|
@@ -1615,11 +1731,11 @@ async function processSyncItem(item) {
|
|
|
1615
1731
|
// INSERT the full entity payload with the originating device_id.
|
|
1616
1732
|
// Uses .select('id').maybeSingle() to verify the row was actually created
|
|
1617
1733
|
// (RLS can silently block inserts, returning success with no data).
|
|
1618
|
-
const payload = filterPayloadToSchema(table, {
|
|
1734
|
+
const payload = filterPayloadToSchema(table, ensureSystemFieldDefaults({
|
|
1619
1735
|
id: entityId,
|
|
1620
1736
|
...value,
|
|
1621
1737
|
device_id: deviceId
|
|
1622
|
-
});
|
|
1738
|
+
}));
|
|
1623
1739
|
const { data, error } = await supabase.from(table).insert(payload).select('id').maybeSingle();
|
|
1624
1740
|
// Duplicate key = another device already created this entity.
|
|
1625
1741
|
// For regular tables, this is a no-op (the entity exists, which is what we wanted).
|
|
@@ -1724,6 +1840,7 @@ async function processSyncItem(item) {
|
|
|
1724
1840
|
debugLog(`[SYNC] Increment fallback to insert for missing row: ${table}/${entityId}`);
|
|
1725
1841
|
const rawInsertPayload = { ...localInc, device_id: deviceId };
|
|
1726
1842
|
delete rawInsertPayload._version;
|
|
1843
|
+
ensureSystemFieldDefaults(rawInsertPayload);
|
|
1727
1844
|
const insertPayload = filterPayloadToSchema(table, rawInsertPayload);
|
|
1728
1845
|
const { error: insertError } = await supabase
|
|
1729
1846
|
.from(table)
|
|
@@ -1810,6 +1927,7 @@ async function processSyncItem(item) {
|
|
|
1810
1927
|
const rawSetPayload = { ...localEntity, device_id: deviceId };
|
|
1811
1928
|
// Remove Dexie internal keys
|
|
1812
1929
|
delete rawSetPayload._version;
|
|
1930
|
+
ensureSystemFieldDefaults(rawSetPayload);
|
|
1813
1931
|
const insertPayload = filterPayloadToSchema(table, rawSetPayload);
|
|
1814
1932
|
const { data: inserted, error: insertError } = await supabase
|
|
1815
1933
|
.from(table)
|
|
@@ -2040,7 +2158,9 @@ export async function runFullSync(quiet = false, skipPull = false) {
|
|
|
2040
2158
|
try {
|
|
2041
2159
|
// Don't pass postPushCursor - we want ALL changes since stored cursor
|
|
2042
2160
|
// The conflict resolution handles our own pushed changes via device_id check
|
|
2043
|
-
|
|
2161
|
+
// Fixed 5-min timeout for pulls — pulls can be very large (initial hydration,
|
|
2162
|
+
// force sync, 20,000+ rows) and should not be capped by push-count heuristics.
|
|
2163
|
+
pullEgress = await withTimeout(pullRemoteChanges(), 300000, 'Pull remote changes');
|
|
2044
2164
|
pullSucceeded = true;
|
|
2045
2165
|
}
|
|
2046
2166
|
catch (pullError) {
|
|
@@ -2735,9 +2855,10 @@ export async function startSyncEngine() {
|
|
|
2735
2855
|
// Initialize network status monitoring (idempotent)
|
|
2736
2856
|
isOnline.init();
|
|
2737
2857
|
// Subscribe to auth state changes.
|
|
2738
|
-
//
|
|
2739
|
-
//
|
|
2740
|
-
//
|
|
2858
|
+
// SIGNED_IN: user just logged in — full sync + realtime restart needed.
|
|
2859
|
+
// TOKEN_REFRESHED: routine refresh (~every 55 min) — only restart realtime if
|
|
2860
|
+
// unhealthy. No sync needed: the Supabase JS client handles token rotation
|
|
2861
|
+
// automatically, and the visibility handler catches missed changes on tab return.
|
|
2741
2862
|
authStateUnsubscribe = supabase.auth.onAuthStateChange(async (event, session) => {
|
|
2742
2863
|
debugLog(`[SYNC] Auth state change: ${event}`);
|
|
2743
2864
|
if (event === 'SIGNED_OUT') {
|
|
@@ -2747,20 +2868,30 @@ export async function startSyncEngine() {
|
|
|
2747
2868
|
syncStatusStore.setStatus('error');
|
|
2748
2869
|
syncStatusStore.setError('Signed out', 'Please sign in to sync your data.');
|
|
2749
2870
|
}
|
|
2750
|
-
else if (event === 'SIGNED_IN'
|
|
2751
|
-
// User
|
|
2752
|
-
debugLog('[SYNC]
|
|
2871
|
+
else if (event === 'SIGNED_IN') {
|
|
2872
|
+
// User just logged in — restart realtime + full sync to hydrate
|
|
2873
|
+
debugLog('[SYNC] Signed in — resuming sync');
|
|
2753
2874
|
if (navigator.onLine) {
|
|
2754
|
-
// Clear any auth errors
|
|
2755
2875
|
syncStatusStore.reset();
|
|
2756
|
-
// Restart realtime
|
|
2757
2876
|
if (session?.user?.id) {
|
|
2758
2877
|
startRealtimeSubscriptions(session.user.id);
|
|
2878
|
+
lastRealtimeStartAt = Date.now();
|
|
2759
2879
|
}
|
|
2760
|
-
// Run a sync to push any pending changes
|
|
2761
2880
|
runFullSync(false).catch((e) => debugError('[SYNC] Auth-triggered sync failed:', e));
|
|
2762
2881
|
}
|
|
2763
2882
|
}
|
|
2883
|
+
else if (event === 'TOKEN_REFRESHED') {
|
|
2884
|
+
// Routine token refresh (~every 55 min). The Supabase JS client automatically
|
|
2885
|
+
// uses the new token for subsequent requests — no sync needed.
|
|
2886
|
+
// Only restart realtime if it's currently unhealthy (e.g. WebSocket dropped).
|
|
2887
|
+
const realtimeHealthy = isRealtimeHealthy();
|
|
2888
|
+
debugLog(`[SYNC] Token refreshed — realtime healthy: ${realtimeHealthy}, skipping sync`);
|
|
2889
|
+
if (navigator.onLine && !realtimeHealthy && session?.user?.id) {
|
|
2890
|
+
debugLog('[SYNC] Restarting unhealthy realtime after token refresh');
|
|
2891
|
+
startRealtimeSubscriptions(session.user.id);
|
|
2892
|
+
lastRealtimeStartAt = Date.now();
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2764
2895
|
// Delegate to app-level callback
|
|
2765
2896
|
const config = getEngineConfig();
|
|
2766
2897
|
if (config.onAuthStateChange) {
|
|
@@ -2890,6 +3021,7 @@ export async function startSyncEngine() {
|
|
|
2890
3021
|
const userId = await getCurrentUserId();
|
|
2891
3022
|
if (userId) {
|
|
2892
3023
|
startRealtimeSubscriptions(userId);
|
|
3024
|
+
lastRealtimeStartAt = Date.now();
|
|
2893
3025
|
}
|
|
2894
3026
|
};
|
|
2895
3027
|
window.addEventListener('online', handleOnlineRef);
|
|
@@ -2907,26 +3039,52 @@ export async function startSyncEngine() {
|
|
|
2907
3039
|
const wasHidden = !isTabVisible;
|
|
2908
3040
|
isTabVisible = !document.hidden;
|
|
2909
3041
|
syncStatusStore.setTabVisible(isTabVisible);
|
|
2910
|
-
//
|
|
3042
|
+
// Tab becoming hidden → schedule realtime teardown after delay
|
|
2911
3043
|
if (!isTabVisible) {
|
|
2912
3044
|
tabHiddenAt = Date.now();
|
|
3045
|
+
// Schedule realtime teardown to eliminate background heartbeat egress.
|
|
3046
|
+
// The 15s delay avoids thrashing on quick tab switches (e.g. checking
|
|
3047
|
+
// a notification and returning takes 8-12s).
|
|
3048
|
+
if (!realtimeTeardownTimer) {
|
|
3049
|
+
realtimeTeardownTimer = setTimeout(() => {
|
|
3050
|
+
realtimeTeardownTimer = null;
|
|
3051
|
+
realtimeWasTornDown = true;
|
|
3052
|
+
stopRealtimeSubscriptions();
|
|
3053
|
+
debugLog('[SYNC] Realtime torn down — tab hidden for 15s+');
|
|
3054
|
+
}, REALTIME_TEARDOWN_DELAY_MS);
|
|
3055
|
+
debugLog('[SYNC] Realtime teardown scheduled in 15s');
|
|
3056
|
+
}
|
|
2913
3057
|
return;
|
|
2914
3058
|
}
|
|
2915
|
-
//
|
|
2916
|
-
if (wasHidden && isTabVisible
|
|
2917
|
-
//
|
|
2918
|
-
|
|
3059
|
+
// Tab becoming visible
|
|
3060
|
+
if (wasHidden && isTabVisible) {
|
|
3061
|
+
// Cancel teardown timer if it hasn't fired yet (quick tab switch)
|
|
3062
|
+
if (realtimeTeardownTimer) {
|
|
3063
|
+
clearTimeout(realtimeTeardownTimer);
|
|
3064
|
+
realtimeTeardownTimer = null;
|
|
3065
|
+
debugLog('[SYNC] Realtime teardown cancelled — tab visible before 15s');
|
|
3066
|
+
}
|
|
3067
|
+
// Restart realtime if it was torn down while hidden
|
|
3068
|
+
if (realtimeWasTornDown && navigator.onLine) {
|
|
3069
|
+
realtimeWasTornDown = false;
|
|
3070
|
+
getCurrentUserId().then((userId) => {
|
|
3071
|
+
if (userId) {
|
|
3072
|
+
startRealtimeSubscriptions(userId);
|
|
3073
|
+
lastRealtimeStartAt = Date.now();
|
|
3074
|
+
debugLog('[SYNC] Restarting realtime — tab visible after teardown');
|
|
3075
|
+
}
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
if (!navigator.onLine)
|
|
3079
|
+
return;
|
|
2919
3080
|
const awayDuration = tabHiddenAt ? Date.now() - tabHiddenAt : 0;
|
|
2920
3081
|
tabHiddenAt = null;
|
|
2921
3082
|
if (awayDuration < getVisibilitySyncMinAwayMs()) {
|
|
2922
3083
|
debugLog(`[SYNC] Visibility sync skipped: away only ${Math.round(awayDuration / 1000)}s (min: ${Math.round(getVisibilitySyncMinAwayMs() / 1000)}s)`);
|
|
2923
3084
|
return;
|
|
2924
3085
|
}
|
|
2925
|
-
//
|
|
2926
|
-
|
|
2927
|
-
debugLog('[SYNC] Visibility sync skipped: realtime is healthy');
|
|
2928
|
-
return;
|
|
2929
|
-
}
|
|
3086
|
+
// Don't skip on isRealtimeHealthy() — we may have just torn down realtime,
|
|
3087
|
+
// so it won't be healthy yet. The poll catches changes missed while hidden.
|
|
2930
3088
|
// Clear any pending visibility sync
|
|
2931
3089
|
if (visibilityDebounceTimeout) {
|
|
2932
3090
|
clearTimeout(visibilityDebounceTimeout);
|
|
@@ -2934,7 +3092,7 @@ export async function startSyncEngine() {
|
|
|
2934
3092
|
// Debounce to prevent rapid syncs when user quickly switches tabs
|
|
2935
3093
|
visibilityDebounceTimeout = setTimeout(() => {
|
|
2936
3094
|
visibilityDebounceTimeout = null;
|
|
2937
|
-
runFullSync(true).catch((e) => debugError('[SYNC] Visibility sync failed:', e));
|
|
3095
|
+
runFullSync(true).catch((e) => debugError('[SYNC] Visibility sync failed:', e));
|
|
2938
3096
|
}, VISIBILITY_SYNC_DEBOUNCE_MS);
|
|
2939
3097
|
}
|
|
2940
3098
|
};
|
|
@@ -2960,6 +3118,7 @@ export async function startSyncEngine() {
|
|
|
2960
3118
|
});
|
|
2961
3119
|
// Start realtime subscriptions
|
|
2962
3120
|
startRealtimeSubscriptions(userId);
|
|
3121
|
+
lastRealtimeStartAt = Date.now();
|
|
2963
3122
|
}
|
|
2964
3123
|
// Start the periodic background sync timer.
|
|
2965
3124
|
// This is the polling fallback for when realtime subscriptions are down.
|
|
@@ -2971,7 +3130,15 @@ export async function startSyncEngine() {
|
|
|
2971
3130
|
// This egress optimization is critical — without it, every open tab polls
|
|
2972
3131
|
// the entire database every 15 minutes regardless of realtime status.
|
|
2973
3132
|
if (navigator.onLine && isTabVisible && !isRealtimeHealthy()) {
|
|
2974
|
-
|
|
3133
|
+
// Grace period: if realtime was just (re)started, give it up to 60s to
|
|
3134
|
+
// establish the WebSocket connection before falling back to polling.
|
|
3135
|
+
const sinceRealtimeStart = Date.now() - lastRealtimeStartAt;
|
|
3136
|
+
if (lastRealtimeStartAt > 0 && sinceRealtimeStart < 60000) {
|
|
3137
|
+
debugLog(`[SYNC] Skipping periodic poll — realtime reconnecting (${Math.round(sinceRealtimeStart / 1000)}s ago)`);
|
|
3138
|
+
}
|
|
3139
|
+
else {
|
|
3140
|
+
runFullSync(true).catch((e) => debugError('[SYNC] Periodic sync failed:', e));
|
|
3141
|
+
}
|
|
2975
3142
|
}
|
|
2976
3143
|
// Cleanup old tombstones, conflict history, failed sync items, and recently modified cache
|
|
2977
3144
|
await cleanupOldTombstones();
|
|
@@ -3116,6 +3283,10 @@ export async function stopSyncEngine() {
|
|
|
3116
3283
|
clearTimeout(visibilityDebounceTimeout);
|
|
3117
3284
|
visibilityDebounceTimeout = null;
|
|
3118
3285
|
}
|
|
3286
|
+
if (realtimeTeardownTimer) {
|
|
3287
|
+
clearTimeout(realtimeTeardownTimer);
|
|
3288
|
+
realtimeTeardownTimer = null;
|
|
3289
|
+
}
|
|
3119
3290
|
releaseSyncLock();
|
|
3120
3291
|
_hasHydrated = false;
|
|
3121
3292
|
_hydrationAttempted = false;
|