Files
ttrpg-initiative-tracker/src/storage/firebase.js
T
david raistrick 84a8b78acd M2: refactor DisplayView to storage adapter (red test first)
DisplayView missed in original M2 refactor — raw onSnapshot(doc(db,path))
survived. In ws/memory mode db is a stub sentinel, so raw SDK calls crash
('Expected first argument to collection() to be a CollectionReference...').
Reported by human testing player display after start combat.

TDD:
1. RED: DisplayView.characterization.test.js asserts adapter.subscribeDoc
   called for campaign + encounter + activeDisplay paths. Adapter recorder
   (getAdapterCalls/resetAdapterCalls in firebase.js) instruments subscribe
   calls — catches raw-SDK bypass that firebase mock alone cannot (mock db
   satisfies raw onSnapshot, hiding the bug).
2. Fix: 2 raw onSnapshot sites in DisplayView -> storage.subscribeDoc.
3. GREEN: 2 new tests pass, 116 total green.

Audit confirmed DisplayView was the ONLY remaining raw SDK site in App.js.
2026-06-29 13:13:46 -04:00

147 lines
5.7 KiB
JavaScript

// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged).
// Matches interface of memory.js / ws.js so App.js calls stay identical.
//
// NOTE: App.js currently imports SDK directly. This adapter extracted verbatim.
// Two-phase refactor:
// Phase A (now): adapter exists, wraps SDK. Hooks/writes can switch incrementally.
// Phase B (later): App.js imports storage factory, drops direct SDK imports.
'use strict';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from 'firebase/auth';
import {
getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection,
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp,
} from 'firebase/firestore';
// Adapter call recorder (instrumentation, no behavior change).
// Tests assert adapter.subscribeDoc called (catches raw-SDK bypass like DisplayView).
const ADAPTER_CALLS = [];
function recordAdapterCall(entry) { ADAPTER_CALLS.push({ ...entry, ts: Date.now() }); }
export function getAdapterCalls() { return [...ADAPTER_CALLS]; }
export function resetAdapterCalls() { ADAPTER_CALLS.length = 0; }
// Path helpers mirror App.js getPath object.
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
export const getPath = {
campaigns: () => `${PUBLIC_DATA_PATH}/campaigns`,
campaign: (id) => `${PUBLIC_DATA_PATH}/campaigns/${id}`,
encounters: (campaignId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters`,
encounter: (campaignId, encounterId) => `${PUBLIC_DATA_PATH}/campaigns/${campaignId}/encounters/${encounterId}`,
activeDisplay: () => `${PUBLIC_DATA_PATH}/activeDisplay/status`,
logs: () => `${PUBLIC_DATA_PATH}/logs`
};
let firebaseApp = null;
let dbInstance = null;
let authInstance = null;
export function initFirebase() {
const config = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
const requiredKeys = ['apiKey', 'authDomain', 'projectId', 'appId'];
const missing = requiredKeys.filter(k => !config[k]);
if (missing.length > 0) {
console.error(`CRITICAL: Missing Firebase config: ${missing.join(', ')}`);
return false;
}
try {
firebaseApp = initializeApp(config);
dbInstance = getFirestore(firebaseApp);
authInstance = getAuth(firebaseApp);
return true;
} catch (err) {
console.error('Firebase init failed:', err);
return false;
}
}
export function getDb() { return dbInstance; }
export function getAuthInstance() { return authInstance; }
// ============================================================================
// STORAGE ADAPTER
// ============================================================================
// Wraps SDK in the storage interface (getDoc/setDoc/etc).
// App.js can now import { storage } and call storage.setDoc(path, data).
// Hooks (useFirestoreDocument etc) still use SDK directly for now.
export function createFirebaseStorage() {
const db = dbInstance;
if (!db) throw new Error('Firestore not initialized. Call initFirebase() first.');
return {
async getDoc(path) {
const snap = await getDocReal(doc(db, path));
return snap.exists() ? { id: snap.id, ...snap.data() } : null;
},
async setDoc(path, data, opts = {}) {
await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined);
},
async updateDoc(path, patch) {
await updateDoc(doc(db, path), patch);
},
async deleteDoc(path) {
await deleteDoc(doc(db, path));
},
async addDoc(collectionPath, data) {
const ref = await addDoc(collection(db, collectionPath), data);
return { id: ref.id, path: `${collectionPath}/${ref.id}` };
},
async getCollection(collectionPath) {
const snapshot = await getDocsReal(collection(db, collectionPath));
return snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
},
async batchWrite(ops) {
const batch = writeBatch(db);
for (const op of ops) {
if (op.type === 'set') batch.set(doc(db, op.path), op.data);
else if (op.type === 'delete') batch.delete(doc(db, op.path));
else if (op.type === 'update') batch.update(doc(db, op.path), op.data);
}
await batch.commit();
},
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
subscribeDoc(path, cb) {
recordAdapterCall({ fn: 'subscribeDoc', path });
return onSnapshot(doc(db, path), (snap) => {
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
}, (err) => console.error(`subscribeDoc ${path}:`, err));
},
subscribeCollection(collectionPath, cb, queryConstraints = []) {
recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
const q = queryConstraints.length > 0
? query(collection(db, collectionPath), ...queryConstraints)
: collection(db, collectionPath);
return onSnapshot(q, (snap) => {
cb(snap.docs.map(d => ({ id: d.id, ...d.data() })));
}, (err) => console.error(`subscribeCollection ${collectionPath}:`, err));
},
dispose() { /* SDK managed; no-op */ },
};
}
// Re-export SDK pieces App.js uses directly (until full refactor).
export {
doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot,
query, orderBy, limit, writeBatch,
};