UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

400 lines (358 loc) 12.1 kB
import crypto from "crypto"; import { DatabaseConnection, Logger } from "./core"; import { normalizeError } from "./utils/error-handler"; export interface ChangelogEntry { id: string; entity_type: string; entity_id: string; action: string; changes: Record<string, unknown>; user_id?: string; timestamp: Date; metadata?: Record<string, unknown>; } export interface CreateChangelogEntryParams { entity_type: string; entity_id: string; action: string; changes: Record<string, unknown>; user_id?: string; metadata?: Record<string, unknown>; } export interface ChangelogQueryOptions { entity_type?: string; entity_id?: string; user_id?: string; action?: string; start_date?: Date; end_date?: Date; limit?: number; offset?: number; } /** * Service for managing changelog entries to track changes to entities * * @class ChangelogService * @example * const changelogService = new ChangelogService(dbConnection, logger); * const entry = await changelogService.create({ * entity_type: 'project', * entity_id: 'project-id', * action: 'created', * changes: { name: 'New Project' } * }); */ export class ChangelogService { /** * Create a new ChangelogService instance * * @param {DatabaseConnection} dbConnection - Database connection * @param {Logger} logger - Logger instance */ constructor( private dbConnection: DatabaseConnection, private logger: Logger ) {} /** * Create a new changelog entry * * @param {CreateChangelogEntryParams} params - Changelog entry parameters * @param {string} params.entity_type - Entity type (e.g., 'project', 'collection') * @param {string} params.entity_id - Entity ID * @param {string} params.action - Action performed (e.g., 'created', 'updated', 'deleted') * @param {Record<string, unknown>} params.changes - Changes made * @param {string} [params.user_id] - User ID who performed the action * @param {Record<string, unknown>} [params.metadata] - Additional metadata * @returns {Promise<ChangelogEntry>} Created changelog entry * @throws {Error} If creation fails * * @example * const entry = await changelogService.create({ * entity_type: 'project', * entity_id: 'project-id', * action: 'updated', * changes: { name: 'New Name' }, * user_id: 'user-id' * }); */ async create(params: CreateChangelogEntryParams): Promise<ChangelogEntry> { try { const { entity_type, entity_id, action, changes, user_id, metadata } = params; // Generate entry ID (SQLite doesn't support RETURNING *) const entryId = crypto.randomUUID(); const now = new Date().toISOString(); // SQLite-compatible INSERT (no RETURNING *) // Note: Database table uses 'created_at' column, not 'timestamp' const query = ` INSERT INTO changelog (id, entity_type, entity_id, action, changes, user_id, metadata, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `; const values = [ entryId, entity_type, entity_id, action, JSON.stringify(changes), user_id || null, metadata ? JSON.stringify(metadata) : null, now, ]; await this.dbConnection.query(query, values); // Query back the inserted row const result = await this.dbConnection.query( "SELECT * FROM changelog WHERE id = $1", [entryId] ); if (result.rows.length === 0) { throw normalizeError(new Error("Failed to create changelog entry"), "INTERNAL_ERROR", { operation: "createEntry", entity_type: params.entity_type, entity_id: params.entity_id }); } const entry = result.rows[0] as Record<string, unknown>; // Database uses 'created_at' column, map it to 'timestamp' in the interface const timestampValue = entry.created_at || entry.timestamp; const changelogEntry: ChangelogEntry = { id: entry.id as string, entity_type: entry.entity_type as string, entity_id: entry.entity_id as string, action: entry.action as string, changes: entry.changes as Record<string, unknown>, timestamp: new Date(timestampValue as string), }; if (entry.user_id !== undefined && entry.user_id !== null) { changelogEntry.user_id = entry.user_id as string; } if (entry.metadata !== undefined && entry.metadata !== null) { changelogEntry.metadata = entry.metadata as Record<string, unknown>; } return changelogEntry; } catch (error) { this.logger.error("Failed to create changelog entry", { error, params }); throw error; } } /** * Get changelog entries by entity * * @param {string} entity_type - Entity type * @param {string} entity_id - Entity ID * @param {ChangelogQueryOptions} [options] - Query options * @returns {Promise<ChangelogEntry[]>} Array of changelog entries * @throws {Error} If query fails * * @example * const entries = await changelogService.getByEntity('project', 'project-id', { * limit: 10, * action: 'created' * }); */ async getByEntity( entity_type: string, entity_id: string, options: ChangelogQueryOptions = {} ): Promise<ChangelogEntry[]> { try { let query = ` SELECT * FROM changelog WHERE entity_type = $1 AND entity_id = $2 `; const values: unknown[] = [entity_type, entity_id]; let paramIndex = 3; if (options.user_id) { query += ` AND user_id = $${paramIndex}`; values.push(options.user_id); paramIndex++; } if (options.action) { query += ` AND action = $${paramIndex}`; values.push(options.action); paramIndex++; } if (options.start_date) { // Database table uses 'created_at' column, not 'timestamp' query += ` AND created_at >= $${paramIndex}`; values.push(options.start_date); paramIndex++; } if (options.end_date) { // Database table uses 'created_at' column, not 'timestamp' query += ` AND created_at <= $${paramIndex}`; values.push(options.end_date); paramIndex++; } // Database table uses 'created_at' column, not 'timestamp' query += ` ORDER BY created_at DESC`; if (options.limit) { query += ` LIMIT $${paramIndex}`; values.push(options.limit); paramIndex++; } if (options.offset) { query += ` OFFSET $${paramIndex}`; values.push(options.offset); paramIndex++; } const result = await this.dbConnection.query(query, values); return (result.rows || [] as unknown[]).map((row: unknown): ChangelogEntry => { const rowData = row as Record<string, unknown>; // Database uses 'created_at' column, map it to 'timestamp' in the interface const timestampValue = rowData.created_at || rowData.timestamp; const entry: ChangelogEntry = { id: rowData.id as string, entity_type: rowData.entity_type as string, entity_id: rowData.entity_id as string, action: rowData.action as string, changes: rowData.changes as Record<string, unknown>, timestamp: new Date(timestampValue as string), }; if (rowData.user_id !== undefined && rowData.user_id !== null) { entry.user_id = rowData.user_id as string; } if (rowData.metadata !== undefined && rowData.metadata !== null) { entry.metadata = rowData.metadata as Record<string, unknown>; } return entry; }); } catch (error) { this.logger.error("Failed to get changelog entries by entity", { error, entity_type, entity_id, options, }); throw error; } } /** * Get all changelog entries with filtering */ async getAll(options: ChangelogQueryOptions = {}): Promise<ChangelogEntry[]> { try { let query = `SELECT * FROM changelog WHERE 1=1`; const values: unknown[] = []; let paramIndex = 1; if (options.entity_type) { query += ` AND entity_type = $${paramIndex}`; values.push(options.entity_type); paramIndex++; } if (options.entity_id) { query += ` AND entity_id = $${paramIndex}`; values.push(options.entity_id); paramIndex++; } if (options.user_id) { query += ` AND user_id = $${paramIndex}`; values.push(options.user_id); paramIndex++; } if (options.action) { query += ` AND action = $${paramIndex}`; values.push(options.action); paramIndex++; } if (options.start_date) { // Database table uses 'created_at' column, not 'timestamp' query += ` AND created_at >= $${paramIndex}`; values.push(options.start_date); paramIndex++; } if (options.end_date) { // Database table uses 'created_at' column, not 'timestamp' query += ` AND created_at <= $${paramIndex}`; values.push(options.end_date); paramIndex++; } // Database table uses 'created_at' column, not 'timestamp' query += ` ORDER BY created_at DESC`; if (options.limit) { query += ` LIMIT $${paramIndex}`; values.push(options.limit); paramIndex++; } if (options.offset) { query += ` OFFSET $${paramIndex}`; values.push(options.offset); paramIndex++; } const result = await this.dbConnection.query(query, values); return (result.rows || [] as unknown[]).map((row: unknown): ChangelogEntry => { const rowData = row as Record<string, unknown>; // Database uses 'created_at' column, map it to 'timestamp' in the interface const timestampValue = rowData.created_at || rowData.timestamp; const entry: ChangelogEntry = { id: rowData.id as string, entity_type: rowData.entity_type as string, entity_id: rowData.entity_id as string, action: rowData.action as string, changes: rowData.changes as Record<string, unknown>, timestamp: new Date(timestampValue as string), }; if (rowData.user_id !== undefined && rowData.user_id !== null) { entry.user_id = rowData.user_id as string; } if (rowData.metadata !== undefined && rowData.metadata !== null) { entry.metadata = rowData.metadata as Record<string, unknown>; } return entry; }); } catch (error) { this.logger.error("Failed to get all changelog entries", { error, options, }); throw error; } } /** * Delete changelog entries by entity */ async deleteByEntity( entity_type: string, entity_id: string ): Promise<number> { try { const query = ` DELETE FROM changelog WHERE entity_type = $1 AND entity_id = $2 `; const result = await this.dbConnection.query(query, [ entity_type, entity_id, ]); return result.rowCount || 0; } catch (error) { this.logger.error("Failed to delete changelog entries by entity", { error, entity_type, entity_id, }); throw error; } } /** * Clear old changelog entries */ async clearOldEntries(daysOld: number): Promise<number> { try { // Calculate the cutoff date in JavaScript (SQLite doesn't support INTERVAL) const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysOld); const query = ` DELETE FROM changelog WHERE timestamp < $1 `; const result = await this.dbConnection.query(query, [cutoffDate.toISOString()]); return result.rowCount || 0; } catch (error) { this.logger.error("Failed to clear old changelog entries", { error, daysOld, }); throw error; } } }