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/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
- return getEngineConfig().visibilitySyncMinAwayMs ?? 300000;
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
- /** Maximum time a sync lock can be held before force-release (60 seconds) */
527
- const SYNC_LOCK_TIMEOUT_MS = 60000;
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
- // Wrapped in timeout to prevent hanging if Supabase doesn't respond
962
- const results = await withTimeout(Promise.all(config.tables.map(pullTablePaginated)), 30000, 'Pull remote changes');
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
- while (iterations < maxIterations) {
1149
- const pendingItems = (await getPendingSync()).filter((item) => snapshotIds.has(item.id));
1150
- if (pendingItems.length === 0)
1151
- break;
1152
- iterations++;
1153
- let processedAny = false;
1154
- // ── Batch creates: group by table and INSERT in bulk ──
1155
- // This is critical for performance: CSV imports with hundreds of transactions
1156
- // push in a few batch calls instead of hundreds of individual HTTP requests.
1157
- const createItems = pendingItems.filter((item) => item.operationType === 'create');
1158
- const nonCreateItems = pendingItems.filter((item) => item.operationType !== 'create');
1159
- if (createItems.length > 0) {
1160
- // Group creates by table, preserving queue order within each group.
1161
- // Bulk-read all sync queue IDs in one IndexedDB call instead of
1162
- // per-item reads (N sequential reads → 1 bulk read).
1163
- const createQueueIds = createItems.filter((item) => item.id).map((item) => item.id);
1164
- const queuedRows = await db.table('syncQueue').bulkGet(createQueueIds);
1165
- const stillQueuedIds = new Set(queuedRows
1166
- .map((row, i) => (row ? createQueueIds[i] : null))
1167
- .filter((id) => id !== null));
1168
- const createsByTable = new Map();
1169
- for (const item of createItems) {
1170
- if (item.id && !stillQueuedIds.has(item.id))
1171
- continue;
1172
- const existing = createsByTable.get(item.table) || [];
1173
- existing.push(item);
1174
- createsByTable.set(item.table, existing);
1175
- }
1176
- // Sort table order: parent tables before child tables to satisfy RLS FK checks.
1177
- const schema = getEngineConfig().schema;
1178
- const sortedTableEntries = [...createsByTable.entries()].sort(([tableA], [tableB]) => {
1179
- if (!schema)
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
- // Resolve schema keys from supabase names (strip prefix)
1182
- const configA = getEngineConfig().tables.find((t) => t.supabaseName === tableA);
1183
- const configB = getEngineConfig().tables.find((t) => t.supabaseName === tableB);
1184
- const keyA = configA?.schemaKey || tableA;
1185
- const keyB = configB?.schemaKey || tableB;
1186
- const aIsChild = isChildTable(schema, keyA);
1187
- const bIsChild = isChildTable(schema, keyB);
1188
- if (aIsChild && !bIsChild)
1189
- return 1;
1190
- if (!aIsChild && bIsChild)
1191
- return -1;
1192
- return 0;
1193
- });
1194
- for (const [tableName, items] of sortedTableEntries) {
1195
- const supabase = getSupabase();
1196
- const deviceId = getDeviceId();
1197
- // Build batch payload — filter to schema-defined columns only
1198
- const payloads = items.map((item) => filterPayloadToSchema(tableName, {
1199
- id: item.entityId,
1200
- ...item.value,
1201
- device_id: deviceId
1202
- }));
1203
- // Batch insert (up to 500 at a time to stay within Supabase limits)
1204
- const BATCH_SIZE = 500;
1205
- for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
1206
- const batch = payloads.slice(i, i + BATCH_SIZE);
1207
- const batchItems = items.slice(i, i + BATCH_SIZE);
1208
- try {
1209
- debugLog(`[SYNC] Batch upsert ${batch.length} rows into ${tableName}`);
1210
- const { error } = await supabase
1211
- .from(tableName)
1212
- .upsert(batch, { onConflict: 'id', ignoreDuplicates: false });
1213
- if (error) {
1214
- if (error.code === '23505') {
1215
- // Duplicate key on a SECONDARY unique constraint (e.g., csv_import_hash,
1216
- // teller_transaction_id). The primary `id` column is unique by UUID
1217
- // generation, so this means another row with a different id already has
1218
- // the same value for a secondary unique field.
1219
- //
1220
- // Strategy: query Supabase for which entity IDs already exist, remove those
1221
- // from the queue (they're true duplicates), then retry with only the new ones.
1222
- // This avoids the catastrophic individual fallback (500 sequential HTTP requests).
1223
- debugLog(`[SYNC] Batch create hit secondary unique constraint for ${tableName} filtering duplicates`);
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
- .upsert(newBatch, { onConflict: 'id', ignoreDuplicates: false });
1249
- if (!retryError) {
1250
- const idsToRemove = newBatchItems
1251
- .filter((item) => item.id)
1252
- .map((item) => item.id);
1253
- if (idsToRemove.length > 0) {
1254
- await bulkRemoveSyncItems(idsToRemove);
1255
- processedAny = true;
1256
- actualPushed += idsToRemove.length;
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
- // Retry still failed likely another secondary constraint issue.
1262
- // Remove all items from queue to prevent infinite retry loops.
1263
- debugError(`[SYNC] Batch create retry failed for ${tableName} — removing from queue to prevent retry storm:`, retryError);
1264
- const allQueueIds = newBatchItems
1265
- .filter((item) => item.id)
1266
- .map((item) => item.id);
1267
- if (allQueueIds.length > 0) {
1268
- await bulkRemoveSyncItems(allQueueIds);
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
- else {
1274
- debugLog(`[SYNC] All ${batch.length} items were duplicates — batch fully resolved`);
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 (filterError) {
1278
- // If the filter query itself fails, remove items to prevent retry storm
1279
- debugError(`[SYNC] Duplicate filter query failed for ${tableName} — removing from queue:`, filterError);
1280
- const allQueueIds = batchItems.filter((item) => item.id).map((item) => item.id);
1281
- if (allQueueIds.length > 0) {
1282
- await bulkRemoveSyncItems(allQueueIds);
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
- else {
1288
- // Non-duplicate error — fall back to individual to identify the problem row(s).
1289
- // Common cause: RLS on child tables when parent hasn't synced yet.
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
- else {
1307
- // Batch succeeded bulk-remove all items from queue in one transaction
1308
- const idsToRemove = batchItems.filter((item) => item.id).map((item) => item.id);
1309
- if (idsToRemove.length > 0) {
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
- continue;
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
- catch (batchError) {
1442
- debugError(`[SYNC] Batch upsert threw for ${tableName}:`, batchError);
1443
- for (const item of batchItems) {
1444
- try {
1445
- await processSyncItem(item);
1446
- if (item.id) {
1447
- await removeSyncItem(item.id);
1448
- processedAny = true;
1449
- actualPushed++;
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
- // ── Process singleton table operations individually (need ID reconciliation) ──
1461
- for (const item of individualItems) {
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
- if (item.id) {
1464
- const stillQueued = await db.table('syncQueue').get(item.id);
1465
- if (!stillQueued) {
1466
- debugLog(`[SYNC] Skipping purged item: ${item.operationType} ${item.table}/${item.entityId}`);
1467
- continue;
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 (error) {
1480
- handleSyncItemError(item, error);
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
- pullEgress = await withTimeout(pullRemoteChanges(), getSyncOperationTimeout(preflightItems.length), 'Pull remote changes');
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
- // CRITICAL for iOS PWA: Safari aggressively kills background tabs, which can expire
2739
- // the Supabase session. When the user returns, TOKEN_REFRESHED fires and we need
2740
- // to restart realtime + trigger a sync to catch up on missed changes.
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' || event === 'TOKEN_REFRESHED') {
2751
- // User signed in or token refreshed - restart sync
2752
- debugLog('[SYNC] Auth restored - resuming 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
- // Track when tab becomes hidden
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
- // If tab just became visible, check if we should sync
2916
- if (wasHidden && isTabVisible && navigator.onLine) {
2917
- // Only sync if user was away for > configured minutes AND realtime is not healthy
2918
- // If realtime is connected, we're already up-to-date
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
- // Skip sync if realtime is healthy (we're already up-to-date)
2926
- if (isRealtimeHealthy()) {
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)); // Quiet - no error shown if it fails
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
- runFullSync(true).catch((e) => debugError('[SYNC] Periodic sync failed:', e)); // Quiet background sync
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;