// firebase.js — storage adapter wrapping Firebase SDK. Default impl (upstream-unchanged). // Matches interface of memory.js / ws.js so App.js calls stay identical. // // App.js imports SDK via this module (doc, setDoc, etc re-exported) for both // the adapter (createFirebaseStorage) and direct SDK calls. ONE import path. import { initializeApp } from 'firebase/app'; import { getAuth } from 'firebase/auth'; import { getFirestore, doc, setDoc, getDoc as getDocReal, getDocs as getDocsReal, addDoc, collection, onSnapshot, updateDoc, deleteDoc, query, where, orderBy, limit, writeBatch, getCountFromServer, } 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. // Translate neutral {__type} constraints (src/storage/index.js builders) to // SDK builders. The client SDK has no offset, so emulate it: widen limit to // offset+limit and report offsetN for the caller to slice off the leading // docs. Read cost matches native offset (admin SDK also bills skipped docs); // the server adapter pushes offset into SQL instead. Unknown constraint types // are dropped — a raw object handed to query() throws and white-screens. function toSdkConstraints(queryConstraints) { let offsetN = 0; let limitN = null; const fbConstraints = []; for (const c of queryConstraints || []) { if (!c || !c.__type) continue; if (c.__type === 'where') fbConstraints.push(where(c.field, c.op, c.value)); else if (c.__type === 'orderBy') fbConstraints.push(orderBy(c.field, c.dir || 'asc')); else if (c.__type === 'limit') limitN = c.n; else if (c.__type === 'offset') offsetN = c.offset || 0; } if (limitN !== null) fbConstraints.push(limit(limitN + offsetN)); return { fbConstraints, offsetN }; } 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 = {}) { recordAdapterCall({ fn: 'setDoc', path, data, opts }); await setDoc(doc(db, path), data, opts.merge ? { merge: true } : undefined); }, async updateDoc(path, patch) { recordAdapterCall({ fn: '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, queryConstraints = []) { const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints); const q = fbConstraints.length ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath); const snapshot = await getDocsReal(q); return snapshot.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() })); }, async countCollection(collectionPath) { const snapshot = await getCountFromServer(collection(db, collectionPath)); return snapshot.data().count; }, 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(); }, // Bulk delete collection (optionally where-filtered). Firestore has no // single bulk-by-path op: fetch matching docs, batch-delete in chunks // of 500 (firestore batch limit). DEV ONLY. async deleteCollection(path, whereField, whereValue) { if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'test') { throw new Error('deleteCollection: dev only'); } let q = collection(db, path); if (whereField) q = query(q, where(whereField, '==', whereValue)); const snap = await getDocsReal(q); const docs = snap.docs; let count = 0; for (let i = 0; i < docs.length; i += 500) { const batch = writeBatch(db); for (const d of docs.slice(i, i + 500)) batch.delete(d.ref); await batch.commit(); count += Math.min(500, docs.length - i); } return count; }, // Transactional undo via batch (atomic in firestore). Apply updates + // flip undone flag in single commit. async undo({ logPath, undo, redo = false }) { const batch = writeBatch(db); const updates = redo ? (undo.redo || undo.updates) : undo.updates; batch.update(doc(db, undo.encounterPath), updates); batch.update(doc(db, logPath), { undone: !redo }); await batch.commit(); }, // Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe. subscribeDoc(path, cb, errCb) { 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); if (typeof errCb === 'function') errCb(err); }); }, subscribeCollection(collectionPath, cb, queryConstraints = [], errCb) { recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath }); const { fbConstraints, offsetN } = toSdkConstraints(queryConstraints); const q = fbConstraints.length > 0 ? query(collection(db, collectionPath), ...fbConstraints) : collection(db, collectionPath); return onSnapshot(q, (snap) => { cb(snap.docs.slice(offsetN).map(d => ({ id: d.id, ...d.data() }))); }, (err) => { console.error(`subscribeCollection ${collectionPath}:`, err); if (typeof errCb === 'function') errCb(err); }); }, dispose() { /* SDK managed; no-op */ }, }; } // Re-export SDK pieces App.js uses directly (until full refactor). // orderBy/limit NOT re-exported: App uses neutral builders from index.js. export { doc, setDoc, updateDoc, deleteDoc, addDoc, collection, onSnapshot, query, writeBatch, };