stellar-drive 1.2.14 → 1.2.15

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
  *
@@ -1145,148 +1165,322 @@ async function pushPendingOps() {
1145
1165
  // "pending" state between sync cycles instead of silently consuming them.
1146
1166
  const snapshotItems = await getPendingSync();
1147
1167
  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);
1168
+ // Start progress tracking for high-volume pushes so the UI can show
1169
+ // "1,200 of 2,500 changes synced…" instead of an opaque spinner that the
1170
+ // user interprets as "stuck". Threshold matches the realtime-suspend
1171
+ // threshold so the two cinematic "heavy sync" signals are in lockstep.
1172
+ //
1173
+ // A periodic monitor reads the live IndexedDB queue size every 400ms and
1174
+ // publishes it through the syncStatusStore. Polling IndexedDB is cheap
1175
+ // (indexed count query on a small table) and keeps the UI accurate no
1176
+ // matter which internal push path (batch, per-item fallback, singleton,
1177
+ // coalesced, duplicate-retry) the items take — we don't have to sprinkle
1178
+ // progress calls across every branch.
1179
+ const PROGRESS_THRESHOLD = 50;
1180
+ const trackProgress = snapshotItems.length >= PROGRESS_THRESHOLD;
1181
+ let progressMonitor = null;
1182
+ let lastReportedCompleted = 0;
1183
+ if (trackProgress) {
1184
+ syncStatusStore.startProgress(snapshotItems.length);
1185
+ syncStatusStore.setPendingCount(snapshotItems.length);
1186
+ progressMonitor = setInterval(async () => {
1187
+ try {
1188
+ const liveCount = await db.table('syncQueue').count();
1189
+ const completed = Math.max(0, snapshotItems.length - liveCount);
1190
+ syncStatusStore.setPendingCount(liveCount);
1191
+ const delta = completed - lastReportedCompleted;
1192
+ if (delta !== 0) {
1193
+ syncStatusStore.advanceProgress(delta);
1194
+ lastReportedCompleted = completed;
1195
+ }
1196
+ }
1197
+ catch (err) {
1198
+ debugWarn('[SYNC] Progress monitor tick failed:', err);
1175
1199
  }
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)
1200
+ }, 400);
1201
+ }
1202
+ try {
1203
+ while (iterations < maxIterations) {
1204
+ const pendingItems = (await getPendingSync()).filter((item) => snapshotIds.has(item.id));
1205
+ if (pendingItems.length === 0)
1206
+ break;
1207
+ iterations++;
1208
+ let processedAny = false;
1209
+ // ── Batch creates: group by table and INSERT in bulk ──
1210
+ // This is critical for performance: CSV imports with hundreds of transactions
1211
+ // push in a few batch calls instead of hundreds of individual HTTP requests.
1212
+ const createItems = pendingItems.filter((item) => item.operationType === 'create');
1213
+ const nonCreateItems = pendingItems.filter((item) => item.operationType !== 'create');
1214
+ if (createItems.length > 0) {
1215
+ // Group creates by table, preserving queue order within each group.
1216
+ // Bulk-read all sync queue IDs in one IndexedDB call instead of
1217
+ // per-item reads (N sequential reads → 1 bulk read).
1218
+ const createQueueIds = createItems.filter((item) => item.id).map((item) => item.id);
1219
+ const queuedRows = await db.table('syncQueue').bulkGet(createQueueIds);
1220
+ const stillQueuedIds = new Set(queuedRows
1221
+ .map((row, i) => (row ? createQueueIds[i] : null))
1222
+ .filter((id) => id !== null));
1223
+ const createsByTable = new Map();
1224
+ for (const item of createItems) {
1225
+ if (item.id && !stillQueuedIds.has(item.id))
1226
+ continue;
1227
+ const existing = createsByTable.get(item.table) || [];
1228
+ existing.push(item);
1229
+ createsByTable.set(item.table, existing);
1230
+ }
1231
+ // Sort table order: parent tables before child tables to satisfy RLS FK checks.
1232
+ const schema = getEngineConfig().schema;
1233
+ const sortedTableEntries = [...createsByTable.entries()].sort(([tableA], [tableB]) => {
1234
+ if (!schema)
1235
+ return 0;
1236
+ // Resolve schema keys from supabase names (strip prefix)
1237
+ const configA = getEngineConfig().tables.find((t) => t.supabaseName === tableA);
1238
+ const configB = getEngineConfig().tables.find((t) => t.supabaseName === tableB);
1239
+ const keyA = configA?.schemaKey || tableA;
1240
+ const keyB = configB?.schemaKey || tableB;
1241
+ const aIsChild = isChildTable(schema, keyA);
1242
+ const bIsChild = isChildTable(schema, keyB);
1243
+ if (aIsChild && !bIsChild)
1244
+ return 1;
1245
+ if (!aIsChild && bIsChild)
1246
+ return -1;
1180
1247
  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
1248
+ });
1249
+ for (const [tableName, items] of sortedTableEntries) {
1250
+ const supabase = getSupabase();
1251
+ const deviceId = getDeviceId();
1252
+ // Build batch payload filter to schema-defined columns only.
1253
+ // Ensure system field `deleted` is always present: create payloads
1254
+ // queued before the column defaulted locally (or by callers that
1255
+ // never set it) serialize with `undefined`/`null`, which violates
1256
+ // the Supabase NOT NULL constraint on `deleted`.
1257
+ const payloads = items.map((item) => {
1258
+ const rawPayload = {
1259
+ id: item.entityId,
1260
+ ...item.value,
1261
+ device_id: deviceId
1262
+ };
1263
+ ensureSystemFieldDefaults(rawPayload);
1264
+ return filterPayloadToSchema(tableName, rawPayload);
1265
+ });
1266
+ // Batch insert (up to 500 at a time to stay within Supabase limits)
1267
+ const BATCH_SIZE = 500;
1268
+ for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
1269
+ const batch = payloads.slice(i, i + BATCH_SIZE);
1270
+ const batchItems = items.slice(i, i + BATCH_SIZE);
1271
+ try {
1272
+ debugLog(`[SYNC] Batch upsert ${batch.length} rows into ${tableName}`);
1273
+ const { error } = await supabase
1274
+ .from(tableName)
1275
+ .upsert(batch, { onConflict: 'id', ignoreDuplicates: false });
1276
+ if (error) {
1277
+ if (error.code === '23505') {
1278
+ // Duplicate key on a SECONDARY unique constraint (e.g., csv_import_hash,
1279
+ // teller_transaction_id). The primary `id` column is unique by UUID
1280
+ // generation, so this means another row with a different id already has
1281
+ // the same value for a secondary unique field.
1282
+ //
1283
+ // Strategy: query Supabase for which entity IDs already exist, remove those
1284
+ // from the queue (they're true duplicates), then retry with only the new ones.
1285
+ // This avoids the catastrophic individual fallback (500 sequential HTTP requests).
1286
+ debugLog(`[SYNC] Batch create hit secondary unique constraint for ${tableName} — filtering duplicates`);
1287
+ try {
1288
+ // Query which IDs from this batch already exist in Supabase
1289
+ const batchEntityIds = batchItems.map((item) => item.entityId);
1290
+ const { data: existingRows } = await supabase
1247
1291
  .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;
1292
+ .select('id')
1293
+ .in('id', batchEntityIds);
1294
+ const existingIds = new Set((existingRows || []).map((r) => r.id));
1295
+ // Remove already-synced items from queue
1296
+ const duplicateQueueIds = batchItems
1297
+ .filter((item) => existingIds.has(item.entityId) && item.id)
1298
+ .map((item) => item.id);
1299
+ if (duplicateQueueIds.length > 0) {
1300
+ await bulkRemoveSyncItems(duplicateQueueIds);
1301
+ processedAny = true;
1302
+ actualPushed += duplicateQueueIds.length;
1303
+ debugLog(`[SYNC] Removed ${duplicateQueueIds.length} already-synced items from queue`);
1304
+ }
1305
+ // Retry with only the truly new items
1306
+ const newBatch = batch.filter((row) => !existingIds.has(row.id));
1307
+ const newBatchItems = batchItems.filter((item) => !existingIds.has(item.entityId));
1308
+ if (newBatch.length > 0) {
1309
+ const { error: retryError } = await supabase
1310
+ .from(tableName)
1311
+ .upsert(newBatch, { onConflict: 'id', ignoreDuplicates: false });
1312
+ if (!retryError) {
1313
+ const idsToRemove = newBatchItems
1314
+ .filter((item) => item.id)
1315
+ .map((item) => item.id);
1316
+ if (idsToRemove.length > 0) {
1317
+ await bulkRemoveSyncItems(idsToRemove);
1318
+ processedAny = true;
1319
+ actualPushed += idsToRemove.length;
1320
+ }
1321
+ debugLog(`[SYNC] Batch create retry success: ${newBatch.length} new rows into ${tableName}`);
1322
+ }
1323
+ else {
1324
+ // Retry still failed — likely another secondary constraint issue.
1325
+ // Remove all items from queue to prevent infinite retry loops.
1326
+ debugError(`[SYNC] Batch create retry failed for ${tableName} — removing from queue to prevent retry storm:`, retryError);
1327
+ const allQueueIds = newBatchItems
1328
+ .filter((item) => item.id)
1329
+ .map((item) => item.id);
1330
+ if (allQueueIds.length > 0) {
1331
+ await bulkRemoveSyncItems(allQueueIds);
1332
+ processedAny = true;
1333
+ }
1257
1334
  }
1258
- debugLog(`[SYNC] Batch create retry success: ${newBatch.length} new rows into ${tableName}`);
1259
1335
  }
1260
1336
  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);
1337
+ debugLog(`[SYNC] All ${batch.length} items were duplicates batch fully resolved`);
1338
+ }
1339
+ }
1340
+ catch (filterError) {
1341
+ // If the filter query itself fails, remove items to prevent retry storm
1342
+ debugError(`[SYNC] Duplicate filter query failed for ${tableName} — removing from queue:`, filterError);
1343
+ const allQueueIds = batchItems
1344
+ .filter((item) => item.id)
1345
+ .map((item) => item.id);
1346
+ if (allQueueIds.length > 0) {
1347
+ await bulkRemoveSyncItems(allQueueIds);
1348
+ processedAny = true;
1349
+ }
1350
+ }
1351
+ }
1352
+ else {
1353
+ // Non-duplicate error — fall back to individual to identify the problem row(s).
1354
+ // Common cause: RLS on child tables when parent hasn't synced yet.
1355
+ debugError(`[SYNC] Batch upsert failed for ${tableName}:`, error);
1356
+ for (const item of batchItems) {
1357
+ try {
1358
+ await processSyncItem(item);
1359
+ if (item.id) {
1360
+ await removeSyncItem(item.id);
1269
1361
  processedAny = true;
1362
+ actualPushed++;
1270
1363
  }
1271
1364
  }
1365
+ catch (itemError) {
1366
+ handleSyncItemError(item, itemError);
1367
+ }
1272
1368
  }
1273
- else {
1274
- debugLog(`[SYNC] All ${batch.length} items were duplicates — batch fully resolved`);
1369
+ }
1370
+ }
1371
+ else {
1372
+ // Batch succeeded — bulk-remove all items from queue in one transaction
1373
+ const idsToRemove = batchItems.filter((item) => item.id).map((item) => item.id);
1374
+ if (idsToRemove.length > 0) {
1375
+ await bulkRemoveSyncItems(idsToRemove);
1376
+ processedAny = true;
1377
+ actualPushed += idsToRemove.length;
1378
+ }
1379
+ debugLog(`[SYNC] Batch upsert success: ${batch.length} rows into ${tableName}`);
1380
+ }
1381
+ }
1382
+ catch (batchError) {
1383
+ // Network-level failure — fall back to individual
1384
+ debugError(`[SYNC] Batch insert threw for ${tableName}:`, batchError);
1385
+ for (const item of batchItems) {
1386
+ try {
1387
+ await processSyncItem(item);
1388
+ if (item.id) {
1389
+ await removeSyncItem(item.id);
1390
+ processedAny = true;
1391
+ actualPushed++;
1275
1392
  }
1276
1393
  }
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);
1394
+ catch (itemError) {
1395
+ handleSyncItemError(item, itemError);
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+ }
1401
+ }
1402
+ // ── Batch non-create operations: group by table and UPSERT in bulk ──
1403
+ // For set/delete/increment on non-singleton tables, read the full local entity
1404
+ // from IndexedDB and upsert in batches. This turns N sequential HTTP requests
1405
+ // into ceil(N/500) batch calls. Singleton tables need special ID reconciliation
1406
+ // and must be processed individually.
1407
+ const batchableItems = nonCreateItems.filter((item) => !isSingletonTable(item.table));
1408
+ const individualItems = nonCreateItems.filter((item) => isSingletonTable(item.table));
1409
+ if (batchableItems.length > 0) {
1410
+ // Bulk-read sync queue IDs for the still-queued check (same optimization as creates)
1411
+ const batchQueueIds = batchableItems.filter((item) => item.id).map((item) => item.id);
1412
+ const batchQueuedRows = await db.table('syncQueue').bulkGet(batchQueueIds);
1413
+ const batchStillQueuedIds = new Set(batchQueuedRows
1414
+ .map((row, i) => (row ? batchQueueIds[i] : null))
1415
+ .filter((id) => id !== null));
1416
+ // Group by table
1417
+ const itemsByTable = new Map();
1418
+ for (const item of batchableItems) {
1419
+ if (item.id && !batchStillQueuedIds.has(item.id))
1420
+ continue;
1421
+ const existing = itemsByTable.get(item.table) || [];
1422
+ existing.push(item);
1423
+ itemsByTable.set(item.table, existing);
1424
+ }
1425
+ for (const [tableName, items] of itemsByTable) {
1426
+ const supabase = getSupabase();
1427
+ const deviceId = getDeviceId();
1428
+ const dexieTable = getDexieTableName(tableName);
1429
+ // Build batch payload from local IndexedDB state (full entity rows).
1430
+ // Bulk-read all entities in one IndexedDB call instead of per-item reads.
1431
+ const entityIds = items.map((item) => item.entityId);
1432
+ const localEntities = await db.table(dexieTable).bulkGet(entityIds);
1433
+ const entityMap = new Map();
1434
+ localEntities.forEach((entity, i) => {
1435
+ if (entity)
1436
+ entityMap.set(entityIds[i], entity);
1437
+ });
1438
+ const payloads = [];
1439
+ const validItems = [];
1440
+ for (const item of items) {
1441
+ const localEntity = entityMap.get(item.entityId);
1442
+ if (!localEntity) {
1443
+ // Entity deleted locally — for delete ops this is expected (already gone),
1444
+ // for others skip it
1445
+ if (item.operationType === 'delete') {
1446
+ // Still need to ensure server-side deletion; fall back to individual
1447
+ try {
1448
+ await processSyncItem(item);
1449
+ if (item.id) {
1450
+ await removeSyncItem(item.id);
1283
1451
  processedAny = true;
1452
+ actualPushed++;
1284
1453
  }
1285
1454
  }
1455
+ catch (itemError) {
1456
+ handleSyncItemError(item, itemError);
1457
+ }
1286
1458
  }
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.
1459
+ continue;
1460
+ }
1461
+ // Strip internal Dexie fields, add device_id, filter to schema columns.
1462
+ // Ensure system field `deleted` is always present — IndexedDB rows created
1463
+ // before this field was set default to `undefined`, which serializes as `null`
1464
+ // and violates NOT NULL constraints on Supabase.
1465
+ const rawPayload = { ...localEntity, device_id: deviceId };
1466
+ ensureSystemFieldDefaults(rawPayload);
1467
+ delete rawPayload._version;
1468
+ payloads.push(filterPayloadToSchema(tableName, rawPayload));
1469
+ validItems.push(item);
1470
+ }
1471
+ if (payloads.length === 0)
1472
+ continue;
1473
+ const BATCH_SIZE = 500;
1474
+ for (let i = 0; i < payloads.length; i += BATCH_SIZE) {
1475
+ const batch = payloads.slice(i, i + BATCH_SIZE);
1476
+ const batchItems = validItems.slice(i, i + BATCH_SIZE);
1477
+ try {
1478
+ debugLog(`[SYNC] Batch upsert ${batch.length} rows into ${tableName}`);
1479
+ const { error } = await supabase
1480
+ .from(tableName)
1481
+ .upsert(batch, { onConflict: 'id', ignoreDuplicates: false });
1482
+ if (error) {
1483
+ // Batch failed — fall back to individual processing
1290
1484
  debugError(`[SYNC] Batch upsert failed for ${tableName}:`, error);
1291
1485
  for (const item of batchItems) {
1292
1486
  try {
@@ -1302,117 +1496,19 @@ async function pushPendingOps() {
1302
1496
  }
1303
1497
  }
1304
1498
  }
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);
1499
+ else {
1500
+ // Batch succeeded — bulk-remove all items from queue in one transaction
1501
+ const idsToRemove = batchItems.filter((item) => item.id).map((item) => item.id);
1502
+ if (idsToRemove.length > 0) {
1503
+ await bulkRemoveSyncItems(idsToRemove);
1386
1504
  processedAny = true;
1387
- actualPushed++;
1505
+ actualPushed += idsToRemove.length;
1388
1506
  }
1389
- }
1390
- catch (itemError) {
1391
- handleSyncItemError(item, itemError);
1507
+ debugLog(`[SYNC] Batch upsert success: ${batch.length} rows into ${tableName}`);
1392
1508
  }
1393
1509
  }
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);
1510
+ catch (batchError) {
1511
+ debugError(`[SYNC] Batch upsert threw for ${tableName}:`, batchError);
1416
1512
  for (const item of batchItems) {
1417
1513
  try {
1418
1514
  await processSyncItem(item);
@@ -1427,62 +1523,57 @@ async function pushPendingOps() {
1427
1523
  }
1428
1524
  }
1429
1525
  }
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
1526
  }
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
- }
1527
+ }
1528
+ }
1529
+ // ── Process singleton table operations individually (need ID reconciliation) ──
1530
+ for (const item of individualItems) {
1531
+ try {
1532
+ if (item.id) {
1533
+ const stillQueued = await db.table('syncQueue').get(item.id);
1534
+ if (!stillQueued) {
1535
+ debugLog(`[SYNC] Skipping purged item: ${item.operationType} ${item.table}/${item.entityId}`);
1536
+ continue;
1455
1537
  }
1456
1538
  }
1539
+ debugLog(`[SYNC] Processing: ${item.operationType} ${item.table}/${item.entityId}`);
1540
+ await processSyncItem(item);
1541
+ if (item.id) {
1542
+ await removeSyncItem(item.id);
1543
+ processedAny = true;
1544
+ actualPushed++;
1545
+ debugLog(`[SYNC] Success: ${item.operationType} ${item.table}/${item.entityId}`);
1546
+ }
1547
+ }
1548
+ catch (error) {
1549
+ handleSyncItemError(item, error);
1457
1550
  }
1458
1551
  }
1552
+ // If we didn't process anything (all items in backoff), stop iterating
1553
+ if (!processedAny)
1554
+ break;
1459
1555
  }
1460
- // ── Process singleton table operations individually (need ID reconciliation) ──
1461
- for (const item of individualItems) {
1556
+ }
1557
+ finally {
1558
+ if (progressMonitor) {
1559
+ clearInterval(progressMonitor);
1560
+ // Final count flush before clearing progress — ensures the UI lands on
1561
+ // the exact remaining count rather than whatever the last tick showed.
1562
+ // Wrapped in try/catch because this runs inside finally and must not
1563
+ // mask an upstream error.
1462
1564
  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
- }
1565
+ const finalLiveCount = await db.table('syncQueue').count();
1566
+ syncStatusStore.setPendingCount(finalLiveCount);
1567
+ const finalCompleted = Math.max(0, snapshotItems.length - finalLiveCount);
1568
+ const delta = finalCompleted - lastReportedCompleted;
1569
+ if (delta !== 0)
1570
+ syncStatusStore.advanceProgress(delta);
1478
1571
  }
1479
- catch (error) {
1480
- handleSyncItemError(item, error);
1572
+ catch (err) {
1573
+ debugWarn('[SYNC] Final progress flush failed:', err);
1481
1574
  }
1575
+ syncStatusStore.clearProgress();
1482
1576
  }
1483
- // If we didn't process anything (all items in backoff), stop iterating
1484
- if (!processedAny)
1485
- break;
1486
1577
  }
1487
1578
  return { originalCount, coalescedCount, actualPushed };
1488
1579
  }
@@ -1615,11 +1706,11 @@ async function processSyncItem(item) {
1615
1706
  // INSERT the full entity payload with the originating device_id.
1616
1707
  // Uses .select('id').maybeSingle() to verify the row was actually created
1617
1708
  // (RLS can silently block inserts, returning success with no data).
1618
- const payload = filterPayloadToSchema(table, {
1709
+ const payload = filterPayloadToSchema(table, ensureSystemFieldDefaults({
1619
1710
  id: entityId,
1620
1711
  ...value,
1621
1712
  device_id: deviceId
1622
- });
1713
+ }));
1623
1714
  const { data, error } = await supabase.from(table).insert(payload).select('id').maybeSingle();
1624
1715
  // Duplicate key = another device already created this entity.
1625
1716
  // For regular tables, this is a no-op (the entity exists, which is what we wanted).
@@ -1724,6 +1815,7 @@ async function processSyncItem(item) {
1724
1815
  debugLog(`[SYNC] Increment fallback to insert for missing row: ${table}/${entityId}`);
1725
1816
  const rawInsertPayload = { ...localInc, device_id: deviceId };
1726
1817
  delete rawInsertPayload._version;
1818
+ ensureSystemFieldDefaults(rawInsertPayload);
1727
1819
  const insertPayload = filterPayloadToSchema(table, rawInsertPayload);
1728
1820
  const { error: insertError } = await supabase
1729
1821
  .from(table)
@@ -1810,6 +1902,7 @@ async function processSyncItem(item) {
1810
1902
  const rawSetPayload = { ...localEntity, device_id: deviceId };
1811
1903
  // Remove Dexie internal keys
1812
1904
  delete rawSetPayload._version;
1905
+ ensureSystemFieldDefaults(rawSetPayload);
1813
1906
  const insertPayload = filterPayloadToSchema(table, rawSetPayload);
1814
1907
  const { data: inserted, error: insertError } = await supabase
1815
1908
  .from(table)