|
1297 | 1297 | try { await firebase.auth().signOut(); } catch (_) {} // clean up DEFAULT too |
1298 | 1298 | } |
1299 | 1299 |
|
| 1300 | + /* ── UID format migration — decimal big-endian → hex uppercase ───────── |
| 1301 | + The legacy mobile app (still in production at the time this code was |
| 1302 | + written) creates `inventory/{spoolId}` doc ids in DECIMAL big-endian |
| 1303 | + form, e.g. "8307741719072896". The canonical format going forward is |
| 1304 | + HEX uppercase, no separators, e.g. "1D895E7C004A80". Both forms decode |
| 1305 | + to the same integer. |
| 1306 | +
|
| 1307 | + SHARED RESPONSIBILITY — every TigerTag client that has write access and |
| 1308 | + sees a decimal-format inventory doc converts it to hex on the fly: |
| 1309 | + - Tiger Studio Manager (here) |
| 1310 | + - The new mobile app version (once deployed) — same algorithm, ported |
| 1311 | + - TigerScale firmware writes hex from day one; for old decimal docs it |
| 1312 | + encounters, it does a fallback lookup via `uidMigrationMap` (see |
| 1313 | + tigerscale-doc-schema.md §"Mixed-format tolerance"). |
| 1314 | +
|
| 1315 | + The lookup table `users/{uid}/uidMigrationMap/{decimal_uid}` → |
| 1316 | + `{ hex_uid, migrated_at }` lets external clients holding old decimal |
| 1317 | + ids resolve them to the new hex doc ids without scanning the inventory. |
| 1318 | +
|
| 1319 | + Properties of this implementation: |
| 1320 | + 1. Idempotent. If the hex doc already exists (re-run, partial |
| 1321 | + migration, or another client beat us to it), we just clean up |
| 1322 | + the decimal stub and write the map entry. |
| 1323 | + 2. Atomic per spool. One Firestore batch handles: SET hex doc, |
| 1324 | + UPDATE every other doc whose `twin_tag_uid` pointed at this |
| 1325 | + decimal id, SET map entry, DELETE decimal doc. All-or-nothing. |
| 1326 | + 3. Safe vs concurrent mobile-app writes. If the mobile app PATCHes |
| 1327 | + the just-deleted decimal doc, Firestore creates a stub with |
| 1328 | + partial data; the next snapshot re-queues it, we merge it back |
| 1329 | + into the hex doc with `{merge: true}`, no data loss. |
| 1330 | + 4. Background, polite. Drains one spool every ~200 ms so we don't |
| 1331 | + burst Firestore quota during a big initial sweep. |
| 1332 | + 5. Owner-only. Never runs while previewing a friend's inventory |
| 1333 | + (state.friendView short-circuit). |
| 1334 | + */ |
| 1335 | + const _uidMigrationQueue = []; // [decimalId, ...] — pending |
| 1336 | + let _uidMigrationDraining = false; |
| 1337 | + const _uidMigrationStats = { migrated: 0, skipped: 0, failed: 0 }; |
| 1338 | + // Pure decimal string check. We exclude leading zeros (other than the |
| 1339 | + // standalone "0") because a real BigInt's toString() never has them — |
| 1340 | + // a leading zero would mean someone wrote a malformed id we shouldn't |
| 1341 | + // touch. |
| 1342 | + function isDecimalSpoolId(id) { |
| 1343 | + return typeof id === "string" && /^\d+$/.test(id) && (id === "0" || id[0] !== "0"); |
| 1344 | + } |
| 1345 | + function decimalSpoolIdToHex(decimal) { |
| 1346 | + try { return BigInt(decimal).toString(16).toUpperCase(); } |
| 1347 | + catch { return null; } |
| 1348 | + } |
| 1349 | + |
| 1350 | + function maybeMigrateDecimalSpoolIds(ownerUid) { |
| 1351 | + if (state.friendView) return; |
| 1352 | + if (!ownerUid || !state.inventory) return; |
| 1353 | + for (const docId of Object.keys(state.inventory)) { |
| 1354 | + if (!isDecimalSpoolId(docId)) continue; |
| 1355 | + if (_uidMigrationQueue.includes(docId)) continue; |
| 1356 | + _uidMigrationQueue.push(docId); |
| 1357 | + } |
| 1358 | + drainUidMigrationQueue(ownerUid); |
| 1359 | + } |
| 1360 | + |
| 1361 | + async function drainUidMigrationQueue(ownerUid) { |
| 1362 | + if (_uidMigrationDraining) return; |
| 1363 | + _uidMigrationDraining = true; |
| 1364 | + try { |
| 1365 | + while (_uidMigrationQueue.length > 0) { |
| 1366 | + // Bail out cleanly if the user switched account / signed out |
| 1367 | + // mid-sweep — never write to a different user's data. |
| 1368 | + if (state.activeAccountId !== ownerUid) break; |
| 1369 | + if (state.friendView) break; |
| 1370 | + const decimalId = _uidMigrationQueue.shift(); |
| 1371 | + try { |
| 1372 | + await migrateOneSpoolDecimalToHex(ownerUid, decimalId); |
| 1373 | + } catch (e) { |
| 1374 | + console.warn("[uidMigration] failed", decimalId, e?.message || e); |
| 1375 | + _uidMigrationStats.failed++; |
| 1376 | + } |
| 1377 | + // Politeness — small gap between writes so we don't dominate the |
| 1378 | + // user's Firestore quota during initial backfill. |
| 1379 | + await new Promise(r => setTimeout(r, 200)); |
| 1380 | + } |
| 1381 | + } finally { |
| 1382 | + _uidMigrationDraining = false; |
| 1383 | + } |
| 1384 | + } |
| 1385 | + |
| 1386 | + async function migrateOneSpoolDecimalToHex(ownerUid, decimalId) { |
| 1387 | + const hexId = decimalSpoolIdToHex(decimalId); |
| 1388 | + if (!hexId) { |
| 1389 | + console.warn("[uidMigration] cannot convert", decimalId); |
| 1390 | + _uidMigrationStats.failed++; |
| 1391 | + return; |
| 1392 | + } |
| 1393 | + const db = fbDb(ownerUid); |
| 1394 | + const invRef = db.collection("users").doc(ownerUid).collection("inventory"); |
| 1395 | + const mapRef = db.collection("users").doc(ownerUid).collection("uidMigrationMap"); |
| 1396 | + const decimalRef = invRef.doc(decimalId); |
| 1397 | + const hexRef = invRef.doc(hexId); |
| 1398 | + |
| 1399 | + // Re-read the decimal doc — it may have been migrated by another |
| 1400 | + // client (mobile app on another device, etc.) since we queued it. |
| 1401 | + const decimalSnap = await decimalRef.get(); |
| 1402 | + if (!decimalSnap.exists) { |
| 1403 | + // Already deleted — just make sure the map entry is there in case |
| 1404 | + // the previous migrator didn't write it, then move on. |
| 1405 | + await mapRef.doc(decimalId).set({ |
| 1406 | + hex_uid: hexId, |
| 1407 | + migrated_at: firebase.firestore.FieldValue.serverTimestamp(), |
| 1408 | + migrated_by: "studio-manager", |
| 1409 | + }, { merge: true }).catch(() => {}); |
| 1410 | + _uidMigrationStats.skipped++; |
| 1411 | + return; |
| 1412 | + } |
| 1413 | + |
| 1414 | + const data = decimalSnap.data(); |
| 1415 | + // If twin_tag_uid is decimal, convert it too. The other side's doc |
| 1416 | + // will get its own twin_tag_uid retargeted via the reverseTwins query |
| 1417 | + // below, so the pair stays consistent. |
| 1418 | + const newData = { ...data, uid: hexId }; |
| 1419 | + if (data.twin_tag_uid && isDecimalSpoolId(String(data.twin_tag_uid))) { |
| 1420 | + newData.twin_tag_uid = decimalSpoolIdToHex(String(data.twin_tag_uid)); |
| 1421 | + } |
| 1422 | + |
| 1423 | + // Find every OTHER inventory doc whose twin_tag_uid pointed at this |
| 1424 | + // decimal id — typically one (the twin partner) but theoretically zero |
| 1425 | + // or more. |
| 1426 | + const reverseTwins = await invRef.where("twin_tag_uid", "==", decimalId).get(); |
| 1427 | + |
| 1428 | + const batch = db.batch(); |
| 1429 | + // merge:true so a partial decimal stub re-written by the mobile app |
| 1430 | + // doesn't wipe fields we already migrated to hex. The hex doc keeps |
| 1431 | + // the union of fields. |
| 1432 | + batch.set(hexRef, newData, { merge: true }); |
| 1433 | + batch.set(mapRef.doc(decimalId), { |
| 1434 | + hex_uid: hexId, |
| 1435 | + migrated_at: firebase.firestore.FieldValue.serverTimestamp(), |
| 1436 | + migrated_by: "studio-manager", |
| 1437 | + }, { merge: true }); |
| 1438 | + reverseTwins.forEach(twin => { |
| 1439 | + // Skip the doc we're about to delete (would race with the delete) |
| 1440 | + if (twin.id === decimalId) return; |
| 1441 | + batch.update(twin.ref, { twin_tag_uid: hexId }); |
| 1442 | + }); |
| 1443 | + batch.delete(decimalRef); |
| 1444 | + |
| 1445 | + await batch.commit(); |
| 1446 | + _uidMigrationStats.migrated++; |
| 1447 | + console.log(`[uidMigration] ${decimalId} → ${hexId}` + |
| 1448 | + (reverseTwins.size > 0 ? ` (twins retargeted: ${reverseTwins.size})` : "")); |
| 1449 | + } |
| 1450 | + |
1300 | 1451 | /* ── Firestore inventory subscription ── */ |
1301 | 1452 | function subscribeInventory(uid) { |
1302 | 1453 | unsubscribeInventory(); |
|
1340 | 1491 | // trigger a fresh snapshot that re-renders. |
1341 | 1492 | maybeAutoUnstoreDepletedSpools(); |
1342 | 1493 | maybeAutoStoreUnrankedSpools(); |
| 1494 | + // Lazy migration of decimal-format spool ids → hex uppercase. |
| 1495 | + // Picks up any decimal doc the mobile app may have just created |
| 1496 | + // and migrates it in the background. Idempotent + safe vs |
| 1497 | + // concurrent mobile-app writes (see the function header). |
| 1498 | + maybeMigrateDecimalSpoolIds(uid); |
1343 | 1499 | saveInventory(raw); |
1344 | 1500 | preCacheImages(state.rows).then(() => { |
1345 | 1501 | sortRows(); renderStats(); renderInventory(); |
|
0 commit comments