473 lines
12 KiB
JavaScript
473 lines
12 KiB
JavaScript
// _opt/pbutils.js
|
|
|
|
/**
|
|
* PocketBase utilities module - extends PocketBase client with useful shortcuts
|
|
*/
|
|
|
|
/**
|
|
* Initializes the PocketBase utilities module
|
|
* @param {Object} client - Discord client with attached PocketBase instance
|
|
* @param {Object} config - Client configuration
|
|
*/
|
|
export const init = async (client, config) => {
|
|
const { pb, logger } = client;
|
|
|
|
logger.info('Initializing PocketBase utilities module');
|
|
|
|
// Attach utility methods to the pb object
|
|
extendPocketBase(pb, logger);
|
|
|
|
// Add connection state handling
|
|
setupConnectionHandling(pb, logger);
|
|
|
|
logger.info('PocketBase utilities module initialized');
|
|
};
|
|
|
|
/**
|
|
* Extends the PocketBase instance with utility methods
|
|
* @param {Object} pb - PocketBase instance
|
|
* @param {Object} logger - Winston logger
|
|
*/
|
|
const extendPocketBase = (pb, logger) => {
|
|
// ===== COLLECTION OPERATIONS =====
|
|
|
|
/**
|
|
* Get a single record with better error handling
|
|
* @param {string} collection - Collection name
|
|
* @param {string} id - Record ID
|
|
* @param {Object} options - Additional options
|
|
* @returns {Promise<Object>} The record or null
|
|
*/
|
|
pb.getOne = async (collection, id, options = {}) => {
|
|
try {
|
|
return await pb.collection(collection).getOne(id, options);
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
return null;
|
|
}
|
|
logger.error(`Failed to get record ${id} from ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates a record with validation and error handling
|
|
* @param {string} collection - Collection name
|
|
* @param {Object} data - Record data
|
|
* @returns {Promise<Object>} Created record
|
|
*/
|
|
pb.createOne = async (collection, data) => {
|
|
try {
|
|
return await pb.collection(collection).create(data);
|
|
} catch (error) {
|
|
logger.error(`Failed to create record in ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates a record with better error handling
|
|
* @param {string} collection - Collection name
|
|
* @param {string} id - Record ID
|
|
* @param {Object} data - Record data
|
|
* @returns {Promise<Object>} Updated record
|
|
*/
|
|
pb.updateOne = async (collection, id, data) => {
|
|
try {
|
|
return await pb.collection(collection).update(id, data);
|
|
} catch (error) {
|
|
logger.error(`Failed to update record ${id} in ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Deletes a record with better error handling
|
|
* @param {string} collection - Collection name
|
|
* @param {string} id - Record ID
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
pb.deleteOne = async (collection, id) => {
|
|
try {
|
|
await pb.collection(collection).delete(id);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
logger.warn(`Record ${id} not found in ${collection} for deletion`);
|
|
return false;
|
|
}
|
|
logger.error(`Failed to delete record ${id} from ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Upsert - creates or updates a record based on whether it exists
|
|
* @param {string} collection - Collection name
|
|
* @param {string} id - Record ID or null for new record
|
|
* @param {Object} data - Record data
|
|
* @returns {Promise<Object>} Created/updated record
|
|
*/
|
|
pb.upsert = async (collection, id, data) => {
|
|
if (id) {
|
|
const exists = await pb.getOne(collection, id);
|
|
if (exists) {
|
|
return await pb.updateOne(collection, id, data);
|
|
}
|
|
}
|
|
return await pb.createOne(collection, data);
|
|
};
|
|
|
|
// ===== QUERY SHORTCUTS =====
|
|
|
|
/**
|
|
* Get first record matching a filter
|
|
* @param {string} collection - Collection name
|
|
* @param {string} filter - Filter query
|
|
* @param {Object} options - Additional options
|
|
* @returns {Promise<Object>} First matching record or null
|
|
*/
|
|
pb.getFirst = async (collection, filter, options = {}) => {
|
|
try {
|
|
const result = await pb.collection(collection).getList(1, 1, {
|
|
filter,
|
|
...options
|
|
});
|
|
|
|
return result.items.length > 0 ? result.items[0] : null;
|
|
} catch (error) {
|
|
if (error.status === 404) {
|
|
return null;
|
|
}
|
|
logger.error(`Failed to get first record from ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get all records from a collection (handles pagination)
|
|
* @param {string} collection - Collection name
|
|
* @param {Object} options - Query options
|
|
* @returns {Promise<Array>} Array of records
|
|
*/
|
|
pb.getAll = async (collection, options = {}) => {
|
|
const records = [];
|
|
const pageSize = options.pageSize || 200;
|
|
let page = 1;
|
|
|
|
try {
|
|
while (true) {
|
|
const result = await pb.collection(collection).getList(page, pageSize, options);
|
|
records.push(...result.items);
|
|
|
|
if (records.length >= result.totalItems) {
|
|
break;
|
|
}
|
|
|
|
page++;
|
|
}
|
|
|
|
return records;
|
|
} catch (error) {
|
|
logger.error(`Failed to get all records from ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Count records matching a filter
|
|
* @param {string} collection - Collection name
|
|
* @param {string} filter - Filter query
|
|
* @returns {Promise<number>} Count of matching records
|
|
*/
|
|
pb.count = async (collection, filter = '') => {
|
|
try {
|
|
const result = await pb.collection(collection).getList(1, 1, {
|
|
filter,
|
|
fields: 'id'
|
|
});
|
|
|
|
return result.totalItems;
|
|
} catch (error) {
|
|
logger.error(`Failed to count records in ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// ===== BATCH OPERATIONS =====
|
|
|
|
/**
|
|
* Perform batch create
|
|
* @param {string} collection - Collection name
|
|
* @param {Array<Object>} items - Array of items to create
|
|
* @returns {Promise<Array>} Created records
|
|
*/
|
|
pb.batchCreate = async (collection, items) => {
|
|
if (!items || items.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const results = [];
|
|
|
|
try {
|
|
// Process in chunks to avoid rate limits
|
|
const chunkSize = 50;
|
|
|
|
for (let i = 0; i < items.length; i += chunkSize) {
|
|
const chunk = items.slice(i, i + chunkSize);
|
|
const promises = chunk.map(item => pb.createOne(collection, item));
|
|
const chunkResults = await Promise.all(promises);
|
|
results.push(...chunkResults);
|
|
}
|
|
|
|
return results;
|
|
} catch (error) {
|
|
logger.error(`Failed batch create in ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Perform batch update
|
|
* @param {string} collection - Collection name
|
|
* @param {Array<Object>} items - Array of items with id field
|
|
* @returns {Promise<Array>} Updated records
|
|
*/
|
|
pb.batchUpdate = async (collection, items) => {
|
|
if (!items || items.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const results = [];
|
|
|
|
try {
|
|
// Process in chunks to avoid rate limits
|
|
const chunkSize = 50;
|
|
|
|
for (let i = 0; i < items.length; i += chunkSize) {
|
|
const chunk = items.slice(i, i + chunkSize);
|
|
const promises = chunk.map(item => {
|
|
const { id, ...data } = item;
|
|
return pb.updateOne(collection, id, data);
|
|
});
|
|
const chunkResults = await Promise.all(promises);
|
|
results.push(...chunkResults);
|
|
}
|
|
|
|
return results;
|
|
} catch (error) {
|
|
logger.error(`Failed batch update in ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Perform batch delete
|
|
* @param {string} collection - Collection name
|
|
* @param {Array<string>} ids - Array of record IDs to delete
|
|
* @returns {Promise<Array>} Results of deletion operations
|
|
*/
|
|
pb.batchDelete = async (collection, ids) => {
|
|
if (!ids || ids.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const results = [];
|
|
|
|
try {
|
|
// Process in chunks to avoid rate limits
|
|
const chunkSize = 50;
|
|
|
|
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
const chunk = ids.slice(i, i + chunkSize);
|
|
const promises = chunk.map(id => pb.deleteOne(collection, id));
|
|
const chunkResults = await Promise.all(promises);
|
|
results.push(...chunkResults);
|
|
}
|
|
|
|
return results;
|
|
} catch (error) {
|
|
logger.error(`Failed batch delete in ${collection}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// ===== CACHE MANAGEMENT =====
|
|
|
|
// Simple in-memory cache
|
|
pb.cache = {
|
|
_store: new Map(),
|
|
_ttls: new Map(),
|
|
|
|
/**
|
|
* Get a value from cache
|
|
* @param {string} key - Cache key
|
|
* @returns {*} Cached value or undefined
|
|
*/
|
|
get(key) {
|
|
if (this._ttls.has(key) && this._ttls.get(key) < Date.now()) {
|
|
this.delete(key);
|
|
return undefined;
|
|
}
|
|
return this._store.get(key);
|
|
},
|
|
|
|
/**
|
|
* Set a value in cache
|
|
* @param {string} key - Cache key
|
|
* @param {*} value - Value to store
|
|
* @param {number} ttlSeconds - Time to live in seconds
|
|
*/
|
|
set(key, value, ttlSeconds = 300) {
|
|
this._store.set(key, value);
|
|
if (ttlSeconds > 0) {
|
|
this._ttls.set(key, Date.now() + (ttlSeconds * 1000));
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete a value from cache
|
|
* @param {string} key - Cache key
|
|
*/
|
|
delete(key) {
|
|
this._store.delete(key);
|
|
this._ttls.delete(key);
|
|
},
|
|
|
|
/**
|
|
* Clear all cache
|
|
*/
|
|
clear() {
|
|
this._store.clear();
|
|
this._ttls.clear();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get a record with caching
|
|
* @param {string} collection - Collection name
|
|
* @param {string} id - Record ID
|
|
* @param {number} ttlSeconds - Cache TTL in seconds
|
|
* @returns {Promise<Object>} Record or null
|
|
*/
|
|
pb.getCached = async (collection, id, ttlSeconds = 60) => {
|
|
const cacheKey = `${collection}:${id}`;
|
|
const cached = pb.cache.get(cacheKey);
|
|
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
|
|
const record = await pb.getOne(collection, id);
|
|
pb.cache.set(cacheKey, record, ttlSeconds);
|
|
|
|
return record;
|
|
};
|
|
|
|
/**
|
|
* Get list with caching
|
|
* @param {string} collection - Collection name
|
|
* @param {Object} options - Query options
|
|
* @param {number} ttlSeconds - Cache TTL in seconds
|
|
* @returns {Promise<Object>} List result
|
|
*/
|
|
pb.getListCached = async (collection, options = {}, ttlSeconds = 30) => {
|
|
const cacheKey = `${collection}:list:${JSON.stringify(options)}`;
|
|
const cached = pb.cache.get(cacheKey);
|
|
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
|
|
const { page = 1, perPage = 50, ...restOptions } = options;
|
|
const result = await pb.collection(collection).getList(page, perPage, restOptions);
|
|
pb.cache.set(cacheKey, result, ttlSeconds);
|
|
|
|
return result;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Setup connection state handling
|
|
* @param {Object} pb - PocketBase instance
|
|
* @param {Object} logger - Winston logger
|
|
*/
|
|
const setupConnectionHandling = (pb, logger) => {
|
|
// Add connection state tracking
|
|
pb.isConnected = true;
|
|
pb.lastSuccessfulAuth = null;
|
|
|
|
// Add auto-reconnect and token refresh
|
|
pb.authStore.onChange(() => {
|
|
pb.isConnected = pb.authStore.isValid;
|
|
|
|
if (pb.isConnected) {
|
|
pb.lastSuccessfulAuth = new Date();
|
|
logger.info('PocketBase authentication successful');
|
|
} else {
|
|
logger.warn('PocketBase auth token expired or invalid');
|
|
}
|
|
});
|
|
|
|
// Helper to check health and reconnect if needed
|
|
pb.ensureConnection = async () => {
|
|
if (!pb.isConnected || !pb.authStore.isValid) {
|
|
try {
|
|
logger.info('Reconnecting to PocketBase...');
|
|
// Attempt to refresh the auth if we have a refresh token
|
|
if (pb.authStore.token && pb.authStore.model?.id) {
|
|
await pb.admins.authRefresh();
|
|
} else if (pb._config.username && pb._config.password) {
|
|
// Fall back to full re-authentication if credentials available
|
|
await pb.admins.authWithPassword(
|
|
pb._config.username,
|
|
pb._config.password
|
|
);
|
|
} else {
|
|
logger.error('No credentials available to reconnect PocketBase');
|
|
pb.isConnected = false;
|
|
return false;
|
|
}
|
|
|
|
pb.isConnected = true;
|
|
pb.lastSuccessfulAuth = new Date();
|
|
logger.info('Successfully reconnected to PocketBase');
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logger.error(`Failed to reconnect to PocketBase: ${error.message}`);
|
|
pb.isConnected = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
// Store credentials for reconnection
|
|
pb._config = pb._config || {};
|
|
// Ensure only if env provided
|
|
if (process.env.SHARED_POCKETBASE_USERNAME && process.env.SHARED_POCKETBASE_PASSWORD) {
|
|
pb._config.username = process.env.SHARED_POCKETBASE_USERNAME;
|
|
pb._config.password = process.env.SHARED_POCKETBASE_PASSWORD;
|
|
}
|
|
|
|
// Heartbeat function to check connection periodically
|
|
const heartbeatInterval = setInterval(async () => {
|
|
try {
|
|
// Simple health check
|
|
await pb.health.check();
|
|
pb.isConnected = true;
|
|
} catch (error) {
|
|
logger.warn(`PocketBase connection issue: ${error.message}`);
|
|
pb.isConnected = false;
|
|
await pb.ensureConnection();
|
|
}
|
|
}, 5 * 60 * 1000); // Check every 5 minutes
|
|
|
|
// Clean up on client disconnect
|
|
pb.cleanup = () => {
|
|
clearInterval(heartbeatInterval);
|
|
};
|
|
};
|