import PocketBase from 'pocketbase';
import type {
AuthResponse,
AuthRecord,
SuperuserRecord,
Collection,
ListResult,
BaseRecord,
QueryOptions,
RecordCreateData,
RecordUpdateData,
AuthOptions,
HealthCheck,
LogRequest,
BackupInfo,
} from './types/pocketbase.js';
import { appConfig } from './utils/config.js';
import { logger } from './utils/logger.js';
export class PocketBaseError extends Error {
constructor(
message: string,
public status?: number,
public data?: unknown,
) {
super(message);
this.name = 'PocketBaseError';
}
}
export class PocketBaseService {
private userClient: PocketBase;
private adminClient: PocketBase;
private isAdminAuthenticated = false;
constructor() {
this.userClient = new PocketBase(appConfig.pocketbase.url);
this.adminClient = new PocketBase(appConfig.pocketbase.url);
// Disable auto cancellation for concurrent requests
this.adminClient.autoCancellation(false);
this.userClient.autoCancellation(false);
// Setup auth change listeners
this.setupAuthListeners();
}
private setupAuthListeners(): void {
this.userClient.authStore.onChange(() => {
logger.debug('User auth state changed', {
isValid: this.userClient.authStore.isValid,
model: (this.userClient.authStore.model as any)?.id,
});
});
this.adminClient.authStore.onChange(() => {
this.isAdminAuthenticated = this.adminClient.authStore.isValid;
logger.debug('Admin auth state changed', {
isValid: this.adminClient.authStore.isValid,
model: (this.adminClient.authStore.model as any)?.id,
});
});
}
private handlePocketBaseError(error: unknown): never {
if (error && typeof error === 'object' && 'response' in error) {
const pbError = error as {
response?: { code?: number; message?: string; data?: unknown };
};
throw new PocketBaseError(
pbError.response?.message ?? 'Unknown PocketBase error',
pbError.response?.code,
pbError.response?.data,
);
}
if (error instanceof Error) {
throw new PocketBaseError(error.message);
}
throw new PocketBaseError('Unknown error occurred');
}
// Admin authentication and management
async authenticateAdmin(): Promise<AuthResponse<SuperuserRecord>> {
try {
logger.info('Authenticating as superuser');
const authData = await this.adminClient
.collection('_superusers')
.authWithPassword(
appConfig.pocketbase.superuser.email,
appConfig.pocketbase.superuser.password,
{ autoRefreshThreshold: 30 * 60 }, // Auto refresh in 30 minutes
);
this.isAdminAuthenticated = true;
logger.info('Superuser authentication successful');
return authData as AuthResponse<SuperuserRecord>;
} catch (error) {
logger.error('Superuser authentication failed', error);
this.handlePocketBaseError(error);
}
}
async ensureAdminAuth(): Promise<void> {
if (!this.isAdminAuthenticated) {
await this.authenticateAdmin();
}
}
// User authentication methods
async loginUser(
email: string,
password: string,
options?: AuthOptions,
): Promise<AuthResponse<AuthRecord>> {
try {
logger.info('User login attempt', { email });
const authData = await this.userClient
.collection('users')
.authWithPassword(email, password, options);
logger.info('User login successful', { userId: authData.record.id });
return authData as AuthResponse<AuthRecord>;
} catch (error) {
logger.error('User login failed', { email, error });
this.handlePocketBaseError(error);
}
}
async registerUser(
email: string,
password: string,
passwordConfirm: string,
additionalData?: Record<string, unknown>,
): Promise<AuthResponse<AuthRecord>> {
try {
logger.info('User registration attempt', { email });
const authData = await this.userClient
.collection('users')
.create({
email,
password,
passwordConfirm,
...additionalData,
});
logger.info('User registration successful', { userId: authData.id });
// Auto-login after registration
return await this.loginUser(email, password);
} catch (error) {
logger.error('User registration failed', { email, error });
this.handlePocketBaseError(error);
}
}
async refreshUserAuth(options?: AuthOptions): Promise<AuthResponse<AuthRecord>> {
try {
if (!this.userClient.authStore.isValid) {
throw new PocketBaseError('No valid user session to refresh');
}
const authData = await this.userClient
.collection('users')
.authRefresh(options);
logger.info('User auth refresh successful');
return authData as AuthResponse<AuthRecord>;
} catch (error) {
logger.error('User auth refresh failed', error);
this.handlePocketBaseError(error);
}
}
logoutUser(): void {
this.userClient.authStore.clear();
logger.info('User logged out');
}
getCurrentUser(): AuthRecord | null {
return this.userClient.authStore.model as AuthRecord | null;
}
isUserAuthenticated(): boolean {
return this.userClient.authStore.isValid;
}
// Password management
async requestPasswordReset(email: string): Promise<boolean> {
try {
await this.userClient.collection('users').requestPasswordReset(email);
logger.info('Password reset requested', { email });
return true;
} catch (error) {
logger.error('Password reset request failed', { email, error });
this.handlePocketBaseError(error);
}
}
async confirmPasswordReset(
token: string,
password: string,
passwordConfirm: string,
): Promise<boolean> {
try {
await this.userClient
.collection('users')
.confirmPasswordReset(token, password, passwordConfirm);
logger.info('Password reset confirmed');
return true;
} catch (error) {
logger.error('Password reset confirmation failed', error);
this.handlePocketBaseError(error);
}
}
// Collection management (requires admin)
async listCollections(options?: QueryOptions): Promise<ListResult<Collection>> {
await this.ensureAdminAuth();
try {
const listOptions: any = {};
if (options?.sort) listOptions.sort = options.sort;
if (options?.filter) listOptions.filter = options.filter;
const result = await this.adminClient.collections.getList(
options?.page,
options?.perPage,
listOptions,
);
return result as ListResult<Collection>;
} catch (error) {
logger.error('Failed to list collections', error);
this.handlePocketBaseError(error);
}
}
async getCollection(idOrName: string): Promise<Collection> {
await this.ensureAdminAuth();
try {
const collection = await this.adminClient.collections.getOne(idOrName);
return collection as Collection;
} catch (error) {
logger.error('Failed to get collection', { idOrName, error });
this.handlePocketBaseError(error);
}
}
async createCollection(data: Record<string, unknown>): Promise<Collection> {
await this.ensureAdminAuth();
try {
const collection = await this.adminClient.collections.create(data);
logger.info('Collection created', { name: collection.name });
return collection as Collection;
} catch (error) {
logger.error('Failed to create collection', { data, error });
this.handlePocketBaseError(error);
}
}
async updateCollection(
idOrName: string,
data: Record<string, unknown>,
): Promise<Collection> {
await this.ensureAdminAuth();
try {
const collection = await this.adminClient.collections.update(idOrName, data);
logger.info('Collection updated', { idOrName });
return collection as Collection;
} catch (error) {
logger.error('Failed to update collection', { idOrName, data, error });
this.handlePocketBaseError(error);
}
}
async deleteCollection(idOrName: string): Promise<boolean> {
await this.ensureAdminAuth();
try {
await this.adminClient.collections.delete(idOrName);
logger.info('Collection deleted', { idOrName });
return true;
} catch (error) {
logger.error('Failed to delete collection', { idOrName, error });
this.handlePocketBaseError(error);
}
}
// Record management
async listRecords<T = BaseRecord>(
collection: string,
options?: QueryOptions,
): Promise<ListResult<T>> {
try {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
if (!this.isUserAuthenticated()) {
await this.ensureAdminAuth();
}
const listOptions: any = {};
if (options?.sort) listOptions.sort = options.sort;
if (options?.filter) listOptions.filter = options.filter;
if (options?.expand) listOptions.expand = options.expand;
if (options?.fields) listOptions.fields = options.fields;
const result = await client.collection(collection).getList(
options?.page,
options?.perPage,
listOptions,
);
return result as ListResult<T>;
} catch (error) {
logger.error('Failed to list records', { collection, options, error });
this.handlePocketBaseError(error);
}
}
async getRecord<T = BaseRecord>(
collection: string,
id: string,
options?: { expand?: string; fields?: string },
): Promise<T> {
try {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
if (!this.isUserAuthenticated()) {
await this.ensureAdminAuth();
}
const record = await client.collection(collection).getOne(id, options);
return record as T;
} catch (error) {
logger.error('Failed to get record', { collection, id, error });
this.handlePocketBaseError(error);
}
}
async createRecord<T = BaseRecord>(
collection: string,
data: RecordCreateData,
options?: { expand?: string; fields?: string },
): Promise<T> {
try {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
if (!this.isUserAuthenticated()) {
await this.ensureAdminAuth();
}
const record = await client.collection(collection).create(data, options);
logger.info('Record created', { collection, recordId: record.id });
return record as T;
} catch (error) {
logger.error('Failed to create record', { collection, data, error });
this.handlePocketBaseError(error);
}
}
async updateRecord<T = BaseRecord>(
collection: string,
id: string,
data: RecordUpdateData,
options?: { expand?: string; fields?: string },
): Promise<T> {
try {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
if (!this.isUserAuthenticated()) {
await this.ensureAdminAuth();
}
const record = await client.collection(collection).update(id, data, options);
logger.info('Record updated', { collection, recordId: id });
return record as T;
} catch (error) {
logger.error('Failed to update record', { collection, id, data, error });
this.handlePocketBaseError(error);
}
}
async deleteRecord(collection: string, id: string): Promise<boolean> {
try {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
if (!this.isUserAuthenticated()) {
await this.ensureAdminAuth();
}
await client.collection(collection).delete(id);
logger.info('Record deleted', { collection, recordId: id });
return true;
} catch (error) {
logger.error('Failed to delete record', { collection, id, error });
this.handlePocketBaseError(error);
}
}
// File management
getFileUrl(
record: BaseRecord,
filename: string,
options?: { thumb?: string },
): string {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
return client.files.getUrl(record, filename, options);
}
async getFileToken(): Promise<string> {
try {
const client = this.isUserAuthenticated() ? this.userClient : this.adminClient;
if (!this.isUserAuthenticated()) {
await this.ensureAdminAuth();
}
const tokenResponse = await client.files.getToken() as any;
return typeof tokenResponse === 'string' ? tokenResponse : tokenResponse.token;
} catch (error) {
logger.error('Failed to get file token', error);
this.handlePocketBaseError(error);
}
}
// System operations (require admin)
async healthCheck(): Promise<HealthCheck> {
try {
const health = await this.adminClient.health.check();
return health as HealthCheck;
} catch (error) {
logger.error('Health check failed', error);
this.handlePocketBaseError(error);
}
}
async getLogs(options?: QueryOptions): Promise<ListResult<LogRequest>> {
await this.ensureAdminAuth();
try {
const listOptions: any = {};
if (options?.sort) listOptions.sort = options.sort;
if (options?.filter) listOptions.filter = options.filter;
const result = await this.adminClient.logs.getList(
options?.page,
options?.perPage,
listOptions,
);
return result as ListResult<LogRequest>;
} catch (error) {
logger.error('Failed to get logs', error);
this.handlePocketBaseError(error);
}
}
async createBackup(name?: string): Promise<void> {
await this.ensureAdminAuth();
try {
await this.adminClient.backups.create(name || '');
logger.info('Backup created', { name });
} catch (error) {
logger.error('Failed to create backup', { name, error });
this.handlePocketBaseError(error);
}
}
async listBackups(): Promise<BackupInfo[]> {
await this.ensureAdminAuth();
try {
const backups = await this.adminClient.backups.getFullList();
return backups as BackupInfo[];
} catch (error) {
logger.error('Failed to list backups', error);
this.handlePocketBaseError(error);
}
}
// User impersonation (admin only)
async impersonateUser(
recordId: string,
duration?: number,
): Promise<PocketBase> {
await this.ensureAdminAuth();
try {
// Note: Impersonation may not be available in all PocketBase versions
// This is a placeholder implementation
const impersonateClient = new (await import('pocketbase')).default(
this.adminClient.baseUrl,
);
// For now, we'll create a new client and manually set auth
// In a real implementation, this would use the actual impersonate API
logger.warn('User impersonation is not fully implemented - using placeholder');
logger.info('User impersonation requested', { recordId, duration });
return impersonateClient;
} catch (error) {
logger.error('Failed to impersonate user', { recordId, error });
this.handlePocketBaseError(error);
}
}
// Utility methods
getUserClient(): PocketBase {
return this.userClient;
}
getAdminClient(): PocketBase {
return this.adminClient;
}
}
// Export singleton instance
export const pocketBaseService = new PocketBaseService();