Files
ttrpg-initiative-tracker/src/storage/firebase.js
T
david raistrick 79af61cb8b match main firebase behavior; fix contract + mismatches
Contract rewritten to match main prod truth:
- require id injection in all doc/collection results
- honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites)
- drop invented bare<->prefixed cross-lookup tests (main never does this)
- add setDoc{merge}, batch set-only/update-only contract coverage

Fix mismatches vs main:
- subscribeCollection hook now forwards queryConstraints (was dropped;
  LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit.
  2 new contract tests prove the chain.
- subscribeDoc/subscribeCollection adapters now forward errCb. App hooks +
  DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg).
- ws adapter signatures accept queryConstraints + errCb (interface match).

activeDisplay writes match main:
- 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc)
- 3 guarded sites stay updateDoc

Delete memory adapter: third storage system added complexity, zero value.
firebase-mock covers fast tests, ws covers server path. Factory throws on
unknown mode now.

Delete phantom 'Phase A/B' comment in firebase.js header.

Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes.
83/83 green.
2026-07-04 12:22:19 -04:00

153 lines
5.8 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.
//
// App.js imports SDK via this module (doc, setDoc, etc re-exported) for both
// the adapter (createFirebaseStorage) and direct SDK calls. ONE import path.
'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 = {}) {
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) {
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, 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 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);
if (typeof errCb === 'function') errCb(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,
};