jspsych-datamanager
Version:
A package to manage data for jsPsych experiments. Currently Firebase and Supabase are supported.
178 lines (157 loc) • 5.9 kB
text/typescript
import { initializeApp, FirebaseApp } from "firebase/app";
import { getFirestore, Firestore, collection, doc, setDoc, updateDoc, arrayUnion, DocumentReference } from "firebase/firestore";
import { DataManager, ExperimentData, TrialData, BaseManagerOptions } from "@jspsych-datamanager/core";
/**
* Configuration interface for Firebase initialization
*/
export interface FirebaseConfig {
/** Firebase API key */
apiKey: string;
/** Firebase authentication domain */
authDomain: string;
/** Firebase project ID */
projectId: string;
/** Firebase storage bucket */
storageBucket: string;
/** Firebase messaging sender ID */
messagingSenderId: string;
/** Firebase app ID */
appId: string;
}
/**
* Options specific to FirebaseManager initialization
*/
export interface FirebaseManagerOptions extends BaseManagerOptions {
/** Name of the Firestore collection to use (default: "experiments") */
collectionName?: string;
/** Specific document ID to use (optional) */
documentId?: string;
}
/**
* A class to manage Firebase operations for jsPsych experiments
*
* This class extends DataManager to provide Firebase-specific implementation
* for storing and managing experiment data.
*
* @example
* ```typescript
* const firebaseManager = new FirebaseManager(firebaseConfig, {
* collectionName: "my-experiments",
* metadata: { version: "1.0.0" }
* });
* ```
*/
export class FirebaseManager extends DataManager {
private readonly app: FirebaseApp;
private readonly db: Firestore;
private readonly docRef: DocumentReference;
private numberOfWrites: number = 0;
/**
* Creates a new FirebaseManager instance
* @param firebaseConfig Firebase configuration object
* @param options Additional options for initialization
*/
constructor(
firebaseConfig: FirebaseConfig,
options: FirebaseManagerOptions = {}
) {
super(options.metadata);
this.app = initializeApp(firebaseConfig);
this.db = getFirestore(this.app);
const collectionName = options.collectionName || "experiments";
const documentId = options.documentId || undefined;
this.docRef = documentId
? doc(this.db, collectionName, documentId)
: doc(collection(this.db, collectionName));
}
/**
* Initializes the experiment document in Firestore
* @param additionalData Additional data to include in the experiment document
* @throws {Error} If initialization fails
*/
public async initializeExperiment(additionalData: Partial<ExperimentData> = {}): Promise<void> {
const initialData: ExperimentData = {
...this.metadata,
trials: [],
...additionalData
};
try {
await setDoc(this.docRef, initialData);
this.numberOfWrites++;
console.log("[FirebaseManager] Document successfully created!");
} catch (error) {
console.error("[FirebaseManager] Error creating document:", error);
throw new Error("Failed to initialize experiment document");
}
}
/**
* Adds a new trial to the experiment document
* @param trialData The trial data to add
* @throws {Error} If storing the trial fails
*/
public async addTrialData(trialData: TrialData): Promise<void> {
const flattenedData = this.flattenNestedArrays(trialData);
try {
await updateDoc(this.docRef, {
trials: arrayUnion(flattenedData),
});
this.numberOfWrites++;
console.log("[FirebaseManager] Added trial data:", flattenedData);
} catch (error) {
console.error("[FirebaseManager] Error storing trial data:", error);
throw new Error("Failed to store trial data");
}
}
/**
* Gets the total number of writes to Firestore
* @returns The number of write operations performed
*/
public getNumberOfOperations(): number {
return this.numberOfWrites;
}
/**
* Creates a callback function for jsPsych's on_data_update event
* @returns A function that handles trial data updates
*/
public createDataUpdateCallback(): (data: TrialData) => TrialData {
return (data: TrialData) => {
if (data.no_upload) {
delete data.no_upload;
return data;
}
this.addTrialData(data).catch(error => {
console.error("[FirebaseManager] Error in data update callback:", error);
});
return data;
};
}
/**
* Creates a callback function for jsPsych's on_finish event
* @returns A function that handles experiment completion
*/
public createFinishCallback(): () => void {
return () => {
console.log("[FirebaseManager] Total writes to Firestore:", this.getNumberOfOperations());
};
}
/**
* Flattens nested arrays in an object to make it Firestore-compatible
* @param obj The object to flatten
* @returns A new object with flattened arrays
*/
private flattenNestedArrays<T extends object>(obj: T): T {
const result = { ...obj } as T;
for (const key in result) {
const value = result[key];
if (Array.isArray(value)) {
(result[key] as any) = value.reduce((acc: Record<number, any>, val: any, i: number) => {
acc[i] = val;
return acc;
}, {});
} else if (typeof value === "object" && value !== null) {
(result[key] as any) = this.flattenNestedArrays(value);
}
}
return result;
}
}