Skip to content

Commit 810e4f5

Browse files
feat: lazy on-the-fly migration of decimal spool ids → hex uppercase
The legacy mobile app still creates inventory doc ids in DECIMAL big- endian form (e.g. "8307741719072896"). The canonical format going forward is HEX uppercase, no separators (e.g. "1D895E7C004A80"). Both encode the same integer; the choice is representational, not data. Studio Manager now migrates decimal ids to hex on-the-fly whenever it sees them in a snapshot. The migration is: - Lazy and continuous: triggered from the existing inventory onSnapshot, no extra Firestore reads. As soon as the mobile app creates a new decimal doc, Studio sees it on the next snapshot and migrates. - Idempotent: re-runs are safe. If the hex doc already exists (another client beat us, partial recovery, etc.), we just clean up the decimal stub and write/refresh the map entry. - Atomic per spool: one Firestore batch per migration, handling the hex doc creation, the lookup-map entry, the twin partner's twin_tag_uid retargeting, and the decimal doc deletion in a single commit. - Polite: drains the queue serially with a 200 ms gap between writes so we don't burst Firestore quota during a big initial sweep. - Owner-only: the friend-view guard short-circuits the function so we never try to migrate someone else's data (which would 403 anyway). - Concurrent-write safe: if the mobile app PATCHes the just-deleted decimal doc, Firestore creates a stub with partial data; the next snapshot picks it up and we re-migrate with merge:true semantics, so no fields are lost. Migration responsibility is shared — Tiger Studio Manager today, the new mobile app once deployed, and any future clients that gain write access. Documented in the public data-model.md, with the accompanying `uidMigrationMap` lookup-table sub-collection serving as the bridge for clients still holding decimal UIDs. The lookup table itself lives at users/{uid}/uidMigrationMap/{decimal} → { hex_uid, migrated_at, migrated_by }. Read access is granted to owner AND accepted friends (a friend may hold a stale decimal UID from a past snapshot and need to resolve it). Write is owner-only.
1 parent 417ae4c commit 810e4f5

1 file changed

Lines changed: 156 additions & 0 deletions

File tree

‎renderer/inventory.js‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,157 @@
12971297
try { await firebase.auth().signOut(); } catch (_) {} // clean up DEFAULT too
12981298
}
12991299

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+
13001451
/* ── Firestore inventory subscription ── */
13011452
function subscribeInventory(uid) {
13021453
unsubscribeInventory();
@@ -1340,6 +1491,11 @@
13401491
// trigger a fresh snapshot that re-renders.
13411492
maybeAutoUnstoreDepletedSpools();
13421493
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);
13431499
saveInventory(raw);
13441500
preCacheImages(state.rows).then(() => {
13451501
sortRows(); renderStats(); renderInventory();

0 commit comments

Comments
 (0)