pulse-ai-utils
Version:
Utility functions and helpers for AI-powered applications
362 lines • 15.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeQueryFilters = normalizeQueryFilters;
const firebase_admin_1 = __importDefault(require("firebase-admin"));
const sanitizeId_1 = require("../utils/sanitizeId");
const load_service_account_1 = require("../config/load-service-account");
// Lazy initialization variables
let serviceAccount = null;
let firebaseInitialized = false;
let initializationPromise = null;
// Initialize Firebase Admin only when needed - now async with proper waiting
async function initializeFirebaseIfNeeded() {
// If already initialized, return immediately
if (firebaseInitialized || firebase_admin_1.default.apps.length > 0) {
return;
}
// If initialization is already in progress, wait for it
if (initializationPromise) {
return initializationPromise;
}
// Start initialization
initializationPromise = (async () => {
// In test mode, skip Firebase initialization
if (process.env.NODE_ENV === 'test') {
console.warn('Skipping Firebase initialization in test mode (firestore)');
firebaseInitialized = true;
return;
}
try {
console.log('[Firestore] Initializing Firebase Admin...');
const startTime = Date.now();
serviceAccount = (0, load_service_account_1.loadServiceAccount)();
// Ensure project ID has no stray whitespace / control chars that break gRPC metadata
const rawProjectId = process.env.GOOGLE_PROJECT_ID || serviceAccount.project_id;
const googleProjectId = rawProjectId
? rawProjectId.trim().replace(/[\r\n\t]/g, '')
: undefined;
firebase_admin_1.default.initializeApp({
credential: firebase_admin_1.default.credential.cert(serviceAccount),
// projectId must be a clean string; otherwise Firestore client will embed illegal chars
projectId: googleProjectId,
});
// Test the connection to ensure it's ready
const testDb = firebase_admin_1.default.firestore();
await testDb.settings({ ignoreUndefinedProperties: true });
const initTime = Date.now() - startTime;
console.log(`[Firestore] Firebase Admin initialized successfully in ${initTime}ms`);
firebaseInitialized = true;
}
catch (error) {
console.error('[Firestore] Firebase initialization failed:', error);
firebaseInitialized = false;
initializationPromise = null; // Reset so we can retry
throw error;
}
})();
return initializationPromise;
}
async function getFirestore(dbId) {
// Initialize Firebase if needed and wait for it to complete
await initializeFirebaseIfNeeded();
// Sanitize dbId to prevent metadata string errors
const sanitizedDbId = dbId ? dbId.trim().replace(/[\r\n\t]/g, '') : 'pulse';
// In test mode, return a mock Firestore instance
if (process.env.NODE_ENV === 'test') {
console.warn('Returning mock Firestore instance in test mode');
return {};
}
// Use the constructor directly, passing the original service account JSON
const cleanProjectId = firebase_admin_1.default.app().options.projectId.
toString().trim().replace(/[\r\n\t]/g, '');
return new firebase_admin_1.default.firestore.Firestore({
projectId: cleanProjectId, // Ensure no illegal characters
databaseId: sanitizedDbId,
credentials: serviceAccount, // Pass the loaded service account JSON here
});
}
function normalizeQueryFilters(filters, defaults = {}) {
const mergedFilters = { ...filters };
for (const [key, value] of Object.entries(defaults)) {
if (mergedFilters[key] === undefined) {
mergedFilters[key] = value;
}
}
if (mergedFilters.type !== undefined) {
if (mergedFilters.category === undefined) {
mergedFilters.category = mergedFilters.type;
}
delete mergedFilters.type;
}
if (mergedFilters.area !== undefined) {
if (mergedFilters.region === undefined) {
mergedFilters.region = mergedFilters.area;
}
delete mergedFilters.area;
}
return mergedFilters;
}
class FirestoreService {
async getDocuments(collection, id = null, dbId, timestamp = null, intervalMinutes = null, fields = null) {
const db = await getFirestore(dbId);
// If intervalMinutes is provided, calculate the upper bound
let lowerBound = null;
if (timestamp && intervalMinutes) {
const baseTime = new Date(timestamp);
lowerBound = new Date(baseTime.getTime() - intervalMinutes * 60 * 1000).toISOString();
}
if (id) {
id = (0, sanitizeId_1.sanitizeId)(id);
const docRef = db.collection(collection).doc(id);
const docSnapshot = await docRef.get();
if (!docSnapshot.exists)
throw new Error('Document not found');
let data = docSnapshot.data();
if (fields && fields.length > 0) {
data = Object.fromEntries(Object.entries(data ?? {}).filter(([k]) => fields.includes(k)));
}
if (timestamp && intervalMinutes) {
const docTimestamp = data.timestamp;
if (!docTimestamp || docTimestamp < lowerBound) {
if (!docTimestamp) {
//log docTimeStamp, lowerBound, basetime, id
console.log(`Document ${id}`);
console.log(`Lower bound: ${lowerBound}`);
console.log(`Timestamp: ${timestamp}`);
}
return null;
}
}
return data;
}
else {
let query = db.collection(collection);
if (timestamp && intervalMinutes) {
query = query.where('timestamp', '>=', lowerBound);
}
else if (timestamp) {
query = query.where('timestamp', '>', timestamp);
}
if (fields && fields.length > 0) {
query = query.select(...fields);
}
const snapshot = await query.get();
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}
}
async createOrUpdateDocument(collection, id, data, dbId) {
const db = await getFirestore(dbId);
// For content collection, use smart ID generation if ID not provided or invalid
if (collection === 'content' && data.title && (!id || id.trim() === '')) {
id = (0, sanitizeId_1.generateContentId)({
title: data.title,
location: data.location,
source_url: data.source_url,
page: data.page,
category: data.category,
type: data.type
});
}
else {
id = (0, sanitizeId_1.sanitizeId)(id);
}
// Add or update the timestamp field
data.id = id;
if (!data.timestamp) {
data.timestamp = new Date().toISOString();
}
await db.collection(collection).doc(id).set(data, { merge: true });
return { message: 'Document created/updated successfully' };
}
async updateDocument(collection, id, data, dbId) {
if (!collection) {
throw new Error('Collection is required');
}
if (!id) {
throw new Error('id is required');
}
const db = await getFirestore(dbId);
id = (0, sanitizeId_1.sanitizeId)(id);
data.timestamp = data.timestamp || new Date().toISOString();
await db.collection(collection).doc(id).set(data, { merge: true });
return { message: 'Document updated successfully' };
}
async batchUpsertDocuments(collection, docs, dbId) {
// docs should be an array of objects, each with an `id` and `data` property
const upserts = docs.map((doc) => this.createOrUpdateDocument(collection, doc.id, doc, dbId));
await Promise.all(upserts);
return { message: 'Batch upsert completed successfully' };
}
async getDocumentsByFields(collection, dbId, filters = {}, defaults = {}) {
if (!collection) {
throw new Error('Collection is required');
}
const db = await getFirestore(dbId);
const mergedFilters = normalizeQueryFilters(filters, defaults);
let query = db.collection(collection);
for (const [field, value] of Object.entries(mergedFilters)) {
query = query.where(field, '==', value);
}
const snapshot = await query.get();
return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
}
/**
* Delete documents from a collection using arbitrary field filters.
* A collection and at least one filter are required to avoid mass deletions.
* @param {string} dbId Firestore database ID
* @param {string} collection Firestore collection name
* @param {object} filters - key/value pairs to filter on
* @returns {Promise<number>} - Number of deleted documents
*/
async deleteDocumentsByFields(dbId, collection, filters = {}) {
if (!collection) {
throw new Error('Collection is required');
}
if (!filters || Object.keys(filters).length === 0) {
throw new Error('At least one filter must be provided');
}
const db = await getFirestore(dbId);
let query = db.collection(collection);
for (const [field, value] of Object.entries(filters)) {
query = query.where(field, '==', value);
}
const snapshot = await query.get();
const batch = db.batch();
snapshot.forEach((doc) => batch.delete(doc.ref));
await batch.commit();
return snapshot.size;
}
/**
* Delete documents older than the specified age in minutes.
* Optionally filters by page field if provided.
* @param {string} dbId Firestore database ID
* @param {string} collection Firestore collection name
* @param {number} maxAgeMinutes Age in minutes
* @param {string} [page] Optional page filter
* @returns {Promise<number>} Number of deleted documents
*/
async deleteStaleDocuments(dbId, collection, maxAgeMinutes, page) {
if (!collection) {
throw new Error('Collection is required');
}
if (!maxAgeMinutes || maxAgeMinutes <= 0) {
throw new Error('maxAgeMinutes must be greater than 0');
}
const db = await getFirestore(dbId);
const thresholdDate = new Date(Date.now() - maxAgeMinutes * 60 * 1000);
const thresholdDateField = new Date(Date.now() - maxAgeMinutes * 2 * 60 * 1000); // date field gets double threshold
let query = db.collection(collection);
if (page) {
query = query.where('page', '==', page);
}
// Remove invalid documentId query boundary, just fetch all docs (optionally filtered by page)
const snapshot = await query.get();
const batch = db.batch();
let deleteCount = 0;
snapshot.forEach(doc => {
const data = doc.data();
// Only check date if present, otherwise just use timestamp
const timestampExpired = data.timestamp && new Date(data.timestamp) < thresholdDate;
const dateExpired = data.date ? (new Date(data.date) < thresholdDateField) : false;
if (timestampExpired || dateExpired) {
batch.delete(doc.ref);
deleteCount++;
}
});
if (deleteCount > 0) {
await batch.commit();
}
return deleteCount;
}
/**
* Cleanup stale contents based on refresh rate from Remote Config.
* @param {string} dbId Firestore database ID
* @param {string} page Page name used for config parameter and filtering
* @param {Function} [fetchConfig] optional injection for tests
* @returns {Promise<number>} Number of deleted documents
*/
async cleanupStaleContents(dbId, collection, page, fetchConfig) {
if (!fetchConfig) {
// Dynamically load to avoid TypeScript path issues
const mod = await Promise.resolve().then(() => __importStar(require('../config/firebase-config')));
fetchConfig = mod.getRemoteConfigParam;
}
const paramName = `recommended_sources`;
const configJson = await fetchConfig(paramName);
if (!configJson) {
throw new Error('Config not found');
}
// Parse the JSON string into an array of configs
let configs = JSON.parse(configJson);
if (page) {
configs = configs.filter(cfg => cfg.url === page);
}
// For each config, call deleteStaleDocuments with its refresh_rate
const deleteCounts = await Promise.all(configs.map(cfg => this.deleteStaleDocuments(dbId, collection, cfg.refresh_rate, cfg.url)));
// Sum the deleted counts
return deleteCounts.reduce((sum, count) => sum + count, 0);
}
async deleteContents(dbId, collection, page) {
if (!collection) {
throw new Error('Collection is required');
}
if (!page) {
throw new Error('page is required');
}
const db = await getFirestore(dbId);
const snapshot = await db.collection(collection).get();
const batch = db.batch();
const encodedTarget = encodeURIComponent(page);
let deleted = 0;
snapshot.forEach(doc => {
const docPage = doc.get('page');
if (docPage && encodeURIComponent(docPage) === encodedTarget) {
batch.delete(doc.ref);
deleted++;
}
});
if (deleted > 0) {
await batch.commit();
}
return deleted;
}
}
const firestoreService = new FirestoreService();
exports.default = firestoreService;
//# sourceMappingURL=firestore.js.map