From f1703b74ece53e14258d01cd945d8ba453564087 Mon Sep 17 00:00:00 2001 From: Zio-4 Date: Wed, 15 Oct 2025 14:49:51 -0700 Subject: [PATCH 1/3] Refactoring offline persistence. --- src/firekit.ts | 7 +++- src/firestore/util.ts | 59 +++++++++++++++--------------- src/index.ts | 2 +- src/levante-firekit.code-workspace | 11 ------ 4 files changed, 37 insertions(+), 42 deletions(-) delete mode 100644 src/levante-firekit.code-workspace diff --git a/src/firekit.ts b/src/firekit.ts index 38709738..8efcc7b0 100644 --- a/src/firekit.ts +++ b/src/firekit.ts @@ -40,7 +40,7 @@ import { } from 'firebase/firestore'; import { httpsCallable, HttpsCallableResult } from 'firebase/functions'; -import { AuthPersistence, MarkRawConfig, emptyOrgList, initializeFirebaseProject } from './firestore/util'; +import { AuthPersistence, MarkRawConfig, enableOfflineConfig, emptyOrgList, initializeFirebaseProject } from './firestore/util'; import { Assessment, FirebaseProject, @@ -178,6 +178,7 @@ export class RoarFirekit { private _idTokens: { admin?: string; app?: string }; private _initialized: boolean; private _markRawConfig: MarkRawConfig; + private _enableOfflineConfig: enableOfflineConfig = false; private _roarUid?: string; private _superAdmin?: boolean; private _verboseLogging?: boolean; @@ -194,6 +195,7 @@ export class RoarFirekit { markRawConfig = {}, listenerUpdateCallback, emulatorConfig, + enableOfflineConfig, }: { roarConfig: RoarConfig; emulatorConfig?: Emulators; @@ -202,12 +204,14 @@ export class RoarFirekit { markRawConfig?: MarkRawConfig; verboseLogging: boolean; listenerUpdateCallback?: (...args: unknown[]) => void; + enableOfflineConfig?: enableOfflineConfig; }) { this.roarConfig = roarConfig; this.emulatorConfig = emulatorConfig; this._verboseLogging = verboseLogging; this._authPersistence = authPersistence; this._markRawConfig = markRawConfig; + this._enableOfflineConfig = enableOfflineConfig ?? false; this._initialized = false; this._idTokens = {}; // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -240,6 +244,7 @@ export class RoarFirekit { this.emulatorConfig, this._authPersistence, this._markRawConfig, + this._enableOfflineConfig, ); this._initialized = true; diff --git a/src/firestore/util.ts b/src/firestore/util.ts index bbbd5ae8..92854580 100644 --- a/src/firestore/util.ts +++ b/src/firestore/util.ts @@ -1,4 +1,4 @@ -import { getApp, initializeApp } from 'firebase/app'; +import { FirebaseApp, getApp, initializeApp } from 'firebase/app'; import { Auth, browserLocalPersistence, @@ -21,7 +21,7 @@ import _remove from 'lodash/remove'; import { markRaw } from 'vue'; import { str as crc32 } from 'crc-32'; import { OrgLists } from '../interfaces'; -import { connectFirestoreEmulator, Firestore, getFirestore, enableIndexedDbPersistence } from 'firebase/firestore'; +import { connectFirestoreEmulator, Firestore, getFirestore, initializeFirestore, persistentLocalCache } from 'firebase/firestore'; import { type Emulators } from '../firekit'; /** Remove null attributes from an object @@ -116,33 +116,38 @@ export interface MarkRawConfig { auth?: boolean; db?: boolean; functions?: boolean; -} +} -export interface OfflineConfig { - enablePersistence?: boolean; -} +export type enableOfflineConfig = boolean type FirebaseProduct = Auth | Firestore | Functions | FirebaseStorage; /** * Enable offline persistence for Firestore if requested - * @param db - Firestore instance - * @param offlineConfig - Offline configuration options + * @param app - Firebase app instance + * @param enableOfflineConfig - Offline configuration options */ -const enableOfflinePersistence = async (db: Firestore, offlineConfig: OfflineConfig): Promise => { - if (offlineConfig.enablePersistence) { - try { - await enableIndexedDbPersistence(db); - console.log('Firestore offline persistence enabled'); - } catch (error: any) { - if (error.code === 'failed-precondition') { - console.warn('Persistence failed: Multiple tabs open, persistence can only be enabled in one tab at a time'); - } else if (error.code === 'unimplemented') { - console.warn('Persistence is not available in this browser'); +const enableOfflinePersistence = (app: FirebaseApp): Firestore | undefined => { + try { + // Defaults to single-tab persistence if no tab manager is specified. + const firestore = initializeFirestore(app, { localCache: persistentLocalCache(/* settings */ {}) }); + console.log('Firestore offline persistence enabled'); + return firestore; + } catch (error: any) { + if (error.code === 'failed-precondition') { + if (typeof error.message === 'string' && error.message.includes('initializeFirestore() has already been called')) { + console.warn( + `Persistence skipped: Firestore already initialized for app ${app.name}. Ensure persistence is configured before calling getFirestore().`, + ); } else { - console.error('Failed to enable persistence:', error); + console.warn('Persistence failed: Multiple tabs open, persistence can only be enabled in one tab at a time'); } + } else if (error.code === 'unimplemented') { + console.warn('Persistence is not available in this browser'); + } else { + console.error('Failed to enable persistence:', error); } + return undefined; } }; @@ -152,7 +157,7 @@ export const initializeFirebaseProject = async ( emulatorConfig?: Emulators | undefined, authPersistence = AuthPersistence.session, markRawConfig: MarkRawConfig = {}, - offlineConfig: OfflineConfig = {}, + enableOfflineConfig: enableOfflineConfig = false, ) => { const optionallyMarkRaw = (productKey: string, productInstance: T): T => { if (_get(markRawConfig, productKey)) { @@ -165,17 +170,14 @@ export const initializeFirebaseProject = async ( if (emulatorConfig) { console.log('Initializing Firebase emulator', emulatorConfig); const app = initializeApp({ projectId: emulatorConfig ? 'demo-emulator' : config.projectId, apiKey: config.apiKey }, name); + const offlineDb = enableOfflineConfig ? enableOfflinePersistence(app) : undefined; const auth = optionallyMarkRaw('auth', getAuth(app)); - const db = optionallyMarkRaw('db', getFirestore(app)); + const db = optionallyMarkRaw('db', offlineDb ?? getFirestore(app)); const functions = optionallyMarkRaw('functions', getFunctions(app)); const storage = optionallyMarkRaw('storage', getStorage(app)); connectFirestoreEmulator(db, emulatorConfig.firestore.host, emulatorConfig.firestore.port); connectFunctionsEmulator(functions, emulatorConfig.functions.host, emulatorConfig.functions.port); - - // Enable offline persistence if requested - await enableOfflinePersistence(db, offlineConfig); - const originalInfo = console.info; // eslint-disable-next-line @typescript-eslint/no-empty-function console.info = () => {}; @@ -209,19 +211,18 @@ export const initializeFirebaseProject = async ( } } + const offlineDb = enableOfflineConfig ? enableOfflinePersistence(app) : undefined; + const kit = { firebaseApp: app, // appCheckToken: appCheckToken, auth: optionallyMarkRaw('auth', getAuth(app)), - db: optionallyMarkRaw('db', getFirestore(app)), + db: optionallyMarkRaw('db', offlineDb ?? getFirestore(app)), functions: optionallyMarkRaw('functions', getFunctions(app)), storage: optionallyMarkRaw('storage', getStorage(app)), perf: performance, }; - // Enable offline persistence if requested - await enableOfflinePersistence(kit.db, offlineConfig); - // Auth state persistence is set with ``setPersistence`` and specifies how a // user session is persisted on a device. We choose in session persistence by // default because many students will access the ROAR on shared devices in the diff --git a/src/index.ts b/src/index.ts index 217c3834..eec39ca1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,4 @@ export { RoarFirekit } from './firekit'; export { RoarAppkit } from './firestore/app/appkit'; export { RoarAppUser } from './firestore/app/user'; export { RoarTaskVariant } from './firestore/app/task'; -export { emptyOrg, emptyOrgList, getTreeTableOrgs, initializeFirebaseProject, type OfflineConfig } from './firestore/util'; +export { emptyOrg, emptyOrgList, getTreeTableOrgs, initializeFirebaseProject, type enableOfflineConfig } from './firestore/util'; diff --git a/src/levante-firekit.code-workspace b/src/levante-firekit.code-workspace deleted file mode 100644 index e1a7f684..00000000 --- a/src/levante-firekit.code-workspace +++ /dev/null @@ -1,11 +0,0 @@ -{ - "folders": [ - { - "path": ".." - }, - { - "path": "../../firebase-functions" - } - ], - "settings": {} -} \ No newline at end of file From 84eaecd1d667cdae2168c0d6dbbeead14572d895 Mon Sep 17 00:00:00 2001 From: Zio-4 Date: Wed, 15 Oct 2025 15:03:30 -0700 Subject: [PATCH 2/3] Testing de-serializing objects. --- src/firestore/app/task.ts | 6 ++--- src/firestore/util.ts | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/firestore/app/task.ts b/src/firestore/app/task.ts index bf6fcd88..75679266 100644 --- a/src/firestore/app/task.ts +++ b/src/firestore/app/task.ts @@ -14,7 +14,7 @@ import { where, getDoc, } from 'firebase/firestore'; -import { mergeGameParams, removeUndefined, replaceValues } from '../util'; +import { mergeGameParams, removeUndefined, replaceValues, sanitizeForFirestore } from '../util'; export interface TaskVariantBase { taskId: string; @@ -119,11 +119,11 @@ export class RoarTaskVariant { this.taskImage = taskImage; this.taskURL = taskURL; this.taskVersion = taskVersion; - this.gameConfig = gameConfig; + this.gameConfig = sanitizeForFirestore(gameConfig); this.registered = registered; this.external = external; this.variantName = variantName; - this.variantParams = variantParams; + this.variantParams = sanitizeForFirestore(variantParams); this.taskRef = doc(this.db, 'tasks', this.taskId); this.variantsCollectionRef = collection(this.taskRef, 'variants'); this.variantId = undefined; diff --git a/src/firestore/util.ts b/src/firestore/util.ts index 92854580..f8afdc39 100644 --- a/src/firestore/util.ts +++ b/src/firestore/util.ts @@ -64,6 +64,63 @@ export const replaceValues = ( ); }; +const isSerializableObject = (value: unknown): value is { [key: string]: unknown } => { + if (value === null || value === undefined) { + return false; + } + if (Array.isArray(value)) { + return false; + } + if (value instanceof Date || value instanceof URL || value instanceof Map || value instanceof Set) { + return false; + } + if (typeof value !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null || _isPlainObject(value); +}; + +const sanitizeCollection = (entries: Iterable<[string, unknown]>) => { + const sanitized: Record = {}; + for (const [key, value] of entries) { + const cleaned = sanitizeForFirestore(value); + if (cleaned !== undefined) { + sanitized[key] = cleaned; + } + } + return sanitized; +}; + +export function sanitizeForFirestore(input: T): T { + if (input === null) { + return input; + } + + if (Array.isArray(input)) { + return input.map((item) => sanitizeForFirestore(item)) as T; + } + + if (input instanceof URL) { + return input.toString() as T; + } + + if (input instanceof Map) { + return sanitizeCollection(input.entries()) as T; + } + + if (input instanceof Set) { + return Array.from(input, (item) => sanitizeForFirestore(item)) as T; + } + + if (isSerializableObject(input)) { + return sanitizeCollection(Object.entries(input)) as T; + } + + return input; +} + export interface CommonFirebaseConfig { projectId: string; apiKey: string; From d719bf82b3cf028cc3b3bb2e189cd9708406b4cf Mon Sep 17 00:00:00 2001 From: Zio-4 Date: Wed, 15 Oct 2025 15:09:02 -0700 Subject: [PATCH 3/3] revert to minimal working offline persistance --- src/firestore/app/task.ts | 6 +-- src/firestore/util.ts | 105 +++++++++----------------------------- 2 files changed, 27 insertions(+), 84 deletions(-) diff --git a/src/firestore/app/task.ts b/src/firestore/app/task.ts index 75679266..bf6fcd88 100644 --- a/src/firestore/app/task.ts +++ b/src/firestore/app/task.ts @@ -14,7 +14,7 @@ import { where, getDoc, } from 'firebase/firestore'; -import { mergeGameParams, removeUndefined, replaceValues, sanitizeForFirestore } from '../util'; +import { mergeGameParams, removeUndefined, replaceValues } from '../util'; export interface TaskVariantBase { taskId: string; @@ -119,11 +119,11 @@ export class RoarTaskVariant { this.taskImage = taskImage; this.taskURL = taskURL; this.taskVersion = taskVersion; - this.gameConfig = sanitizeForFirestore(gameConfig); + this.gameConfig = gameConfig; this.registered = registered; this.external = external; this.variantName = variantName; - this.variantParams = sanitizeForFirestore(variantParams); + this.variantParams = variantParams; this.taskRef = doc(this.db, 'tasks', this.taskId); this.variantsCollectionRef = collection(this.taskRef, 'variants'); this.variantId = undefined; diff --git a/src/firestore/util.ts b/src/firestore/util.ts index f8afdc39..6a0aee80 100644 --- a/src/firestore/util.ts +++ b/src/firestore/util.ts @@ -64,63 +64,6 @@ export const replaceValues = ( ); }; -const isSerializableObject = (value: unknown): value is { [key: string]: unknown } => { - if (value === null || value === undefined) { - return false; - } - if (Array.isArray(value)) { - return false; - } - if (value instanceof Date || value instanceof URL || value instanceof Map || value instanceof Set) { - return false; - } - if (typeof value !== 'object') { - return false; - } - - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null || _isPlainObject(value); -}; - -const sanitizeCollection = (entries: Iterable<[string, unknown]>) => { - const sanitized: Record = {}; - for (const [key, value] of entries) { - const cleaned = sanitizeForFirestore(value); - if (cleaned !== undefined) { - sanitized[key] = cleaned; - } - } - return sanitized; -}; - -export function sanitizeForFirestore(input: T): T { - if (input === null) { - return input; - } - - if (Array.isArray(input)) { - return input.map((item) => sanitizeForFirestore(item)) as T; - } - - if (input instanceof URL) { - return input.toString() as T; - } - - if (input instanceof Map) { - return sanitizeCollection(input.entries()) as T; - } - - if (input instanceof Set) { - return Array.from(input, (item) => sanitizeForFirestore(item)) as T; - } - - if (isSerializableObject(input)) { - return sanitizeCollection(Object.entries(input)) as T; - } - - return input; -} - export interface CommonFirebaseConfig { projectId: string; apiKey: string; @@ -184,28 +127,20 @@ type FirebaseProduct = Auth | Firestore | Functions | FirebaseStorage; * @param app - Firebase app instance * @param enableOfflineConfig - Offline configuration options */ -const enableOfflinePersistence = (app: FirebaseApp): Firestore | undefined => { - try { - // Defaults to single-tab persistence if no tab manager is specified. - const firestore = initializeFirestore(app, { localCache: persistentLocalCache(/* settings */ {}) }); - console.log('Firestore offline persistence enabled'); - return firestore; - } catch (error: any) { - if (error.code === 'failed-precondition') { - if (typeof error.message === 'string' && error.message.includes('initializeFirestore() has already been called')) { - console.warn( - `Persistence skipped: Firestore already initialized for app ${app.name}. Ensure persistence is configured before calling getFirestore().`, - ); - } else { +const enableOfflinePersistence = (app: FirebaseApp): void => { + try { + // Defaults to single-tab persistence if no tab manager is specified. + initializeFirestore(app, { localCache: persistentLocalCache(/*settings*/{}) }); + console.log('Firestore offline persistence enabled'); + } catch (error: any) { + if (error.code === 'failed-precondition') { console.warn('Persistence failed: Multiple tabs open, persistence can only be enabled in one tab at a time'); + } else if (error.code === 'unimplemented') { + console.warn('Persistence is not available in this browser'); + } else { + console.error('Failed to enable persistence:', error); } - } else if (error.code === 'unimplemented') { - console.warn('Persistence is not available in this browser'); - } else { - console.error('Failed to enable persistence:', error); } - return undefined; - } }; export const initializeFirebaseProject = async ( @@ -227,14 +162,19 @@ export const initializeFirebaseProject = async ( if (emulatorConfig) { console.log('Initializing Firebase emulator', emulatorConfig); const app = initializeApp({ projectId: emulatorConfig ? 'demo-emulator' : config.projectId, apiKey: config.apiKey }, name); - const offlineDb = enableOfflineConfig ? enableOfflinePersistence(app) : undefined; const auth = optionallyMarkRaw('auth', getAuth(app)); - const db = optionallyMarkRaw('db', offlineDb ?? getFirestore(app)); + const db = optionallyMarkRaw('db', getFirestore(app)); const functions = optionallyMarkRaw('functions', getFunctions(app)); const storage = optionallyMarkRaw('storage', getStorage(app)); connectFirestoreEmulator(db, emulatorConfig.firestore.host, emulatorConfig.firestore.port); connectFunctionsEmulator(functions, emulatorConfig.functions.host, emulatorConfig.functions.port); + + // Enable offline persistence if requested + if (enableOfflineConfig) { + enableOfflinePersistence(app); + } + const originalInfo = console.info; // eslint-disable-next-line @typescript-eslint/no-empty-function console.info = () => {}; @@ -268,18 +208,21 @@ export const initializeFirebaseProject = async ( } } - const offlineDb = enableOfflineConfig ? enableOfflinePersistence(app) : undefined; - const kit = { firebaseApp: app, // appCheckToken: appCheckToken, auth: optionallyMarkRaw('auth', getAuth(app)), - db: optionallyMarkRaw('db', offlineDb ?? getFirestore(app)), + db: optionallyMarkRaw('db', getFirestore(app)), functions: optionallyMarkRaw('functions', getFunctions(app)), storage: optionallyMarkRaw('storage', getStorage(app)), perf: performance, }; + // Enable offline persistence if requested + if (enableOfflineConfig) { + enableOfflinePersistence(app); + } + // Auth state persistence is set with ``setPersistence`` and specifies how a // user session is persisted on a device. We choose in session persistence by // default because many students will access the ROAR on shared devices in the