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/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 +374 -281
- 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
|
*
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
.
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
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 (
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
1461
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 (
|
|
1480
|
-
|
|
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)
|