@rapidrabbit/gitdb
Version:
A powerful, flexible database module for storing data in various formats with local file and GitHub storage options
339 lines (298 loc) • 9.28 kB
JavaScript
/**
* HistoryManager - Provides version history tracking for GitDB
* Uses GitHub API to track changes to data files and records
*/
class HistoryManager {
constructor (storage) {
this.storage = storage
this.octokit = storage.octokit
this.config = storage.getConfig()
}
/**
* Get complete commit history for a data file
*/
async getFileHistory (limit = 50) {
try {
const response = await this.octokit.repos.listCommits({
owner: this.config.owner,
repo: this.config.repo,
path: this.config.path,
per_page: limit
})
return response.data.map(commit => ({
sha: commit.sha,
message: commit.commit.message,
author: {
name: commit.commit.author.name,
email: commit.commit.author.email,
date: commit.commit.author.date
},
committer: {
name: commit.commit.committer.name,
email: commit.commit.committer.email,
date: commit.commit.committer.date
},
url: commit.html_url,
parents: commit.parents.map(p => p.sha)
}))
} catch (error) {
throw new Error(`Failed to get file history: ${error.message}`)
}
}
/**
* Get the content of a data file at a specific commit
*/
async getFileAtCommit (sha) {
try {
const response = await this.octokit.repos.getContent({
owner: this.config.owner,
repo: this.config.repo,
path: this.config.path,
ref: sha
})
if (response.data.type === 'file') {
const content = Buffer.from(response.data.content, 'base64').toString('utf8')
return {
content,
sha: response.data.sha,
size: response.data.size,
commit: sha
}
}
throw new Error('Path is not a file')
} catch (error) {
throw new Error(`Failed to get file at commit ${sha}: ${error.message}`)
}
}
/**
* Get the difference between two commits for a data file
*/
async getDiff (sha1, sha2) {
try {
const response = await this.octokit.repos.compareCommits({
owner: this.config.owner,
repo: this.config.repo,
base: sha1,
head: sha2
})
return {
ahead_by: response.data.ahead_by,
behind_by: response.data.behind_by,
total_commits: response.data.total_commits,
files: response.data.files.map(file => ({
filename: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
changes: file.changes,
patch: file.patch
}))
}
} catch (error) {
throw new Error(`Failed to get diff between ${sha1} and ${sha2}: ${error.message}`)
}
}
/**
* Get the history of a specific record by ID
* Tracks when a record was created, updated, or deleted
*/
async getRecordHistory (collection, recordId, limit = 50) {
try {
// Get commit history for the file
const commits = await this.getFileHistory(limit)
const recordHistory = []
// For each commit, check if the record exists and what changed
for (const commit of commits) {
try {
const fileData = await this.getFileAtCommit(commit.sha)
const data = JSON.parse(fileData.content)
if (data[collection]) {
const record = data[collection].find(item => item.id === recordId)
if (record) {
recordHistory.push({
commit: commit.sha,
message: commit.message,
author: commit.author,
date: commit.author.date,
record,
action: 'exists'
})
}
}
} catch (error) {
// Skip commits where file parsing fails
console.warn(`Failed to parse file at commit ${commit.sha}: ${error.message}`)
}
}
// Analyze the history to determine actions (create, update, delete)
return this.analyzeRecordHistory(recordHistory)
} catch (error) {
throw new Error(`Failed to get record history: ${error.message}`)
}
}
/**
* Analyze record history to determine actions
*/
analyzeRecordHistory (history) {
if (history.length === 0) return []
const analyzed = []
for (let i = 0; i < history.length; i++) {
const current = history[i]
const previous = i < history.length - 1 ? history[i + 1] : null
if (!previous) {
// First appearance - likely created
analyzed.push({
...current,
action: 'created'
})
} else {
// Check if record changed
const changed = this.hasRecordChanged(current.record, previous.record)
analyzed.push({
...current,
action: changed ? 'updated' : 'unchanged'
})
}
}
return analyzed
}
/**
* Check if a record has changed between two versions
*/
hasRecordChanged (record1, record2) {
if (!record1 || !record2) return true
const keys1 = Object.keys(record1)
const keys2 = Object.keys(record2)
if (keys1.length !== keys2.length) return true
for (const key of keys1) {
if (JSON.stringify(record1[key]) !== JSON.stringify(record2[key])) {
return true
}
}
return false
}
/**
* Search commit history by author, date range, or commit message
*/
async searchHistory (options = {}) {
try {
const { author, since, until, message } = options
const response = await this.octokit.repos.listCommits({
owner: this.config.owner,
repo: this.config.repo,
path: this.config.path,
author,
since,
until,
per_page: 100
})
let commits = response.data
// Filter by commit message if specified
if (message) {
commits = commits.filter(commit =>
commit.commit.message.toLowerCase().includes(message.toLowerCase())
)
}
return commits.map(commit => ({
sha: commit.sha,
message: commit.commit.message,
author: {
name: commit.commit.author.name,
email: commit.commit.author.email,
date: commit.commit.author.date
},
url: commit.html_url
}))
} catch (error) {
throw new Error(`Failed to search history: ${error.message}`)
}
}
/**
* Get statistics about the data file history
*/
async getHistoryStats () {
try {
const commits = await this.getFileHistory(100)
if (commits.length === 0) {
return {
totalCommits: 0,
firstCommit: null,
lastCommit: null,
authors: [],
averageCommitsPerDay: 0
}
}
const authors = [...new Set(commits.map(c => c.author.name))]
const firstCommit = commits[commits.length - 1]
const lastCommit = commits[0]
// Calculate average commits per day
const timeSpan = (lastCommit.author.date - firstCommit.author.date) / (1000 * 60 * 60 * 24)
const averageCommitsPerDay = timeSpan > 0 ? commits.length / timeSpan : 0
return {
totalCommits: commits.length,
firstCommit: {
sha: firstCommit.sha,
date: firstCommit.author.date,
message: firstCommit.message
},
lastCommit: {
sha: lastCommit.sha,
date: lastCommit.author.date,
message: lastCommit.message
},
authors,
averageCommitsPerDay: Math.round(averageCommitsPerDay * 100) / 100
}
} catch (error) {
throw new Error(`Failed to get history stats: ${error.message}`)
}
}
/**
* Get the timeline of changes for a collection
*/
async getCollectionTimeline (collection, limit = 50) {
try {
const commits = await this.getFileHistory(limit)
const timeline = []
for (const commit of commits) {
try {
const fileData = await this.getFileAtCommit(commit.sha)
const data = JSON.parse(fileData.content)
if (data[collection]) {
timeline.push({
commit: commit.sha,
message: commit.message,
author: commit.author,
date: commit.author.date,
recordCount: data[collection].length,
collection
})
}
} catch (error) {
console.warn(`Failed to parse file at commit ${commit.sha}: ${error.message}`)
}
}
return timeline
} catch (error) {
throw new Error(`Failed to get collection timeline: ${error.message}`)
}
}
/**
* Revert data file to a specific commit
*/
async revertToCommit (sha, commitMessage = `Revert to commit ${sha}`) {
try {
const fileData = await this.getFileAtCommit(sha)
// Write the old content back to the current branch
await this.storage.write(fileData.content, commitMessage)
return {
success: true,
revertedTo: sha,
message: commitMessage
}
} catch (error) {
throw new Error(`Failed to revert to commit ${sha}: ${error.message}`)
}
}
}
module.exports = HistoryManager