@simplepg/repo
Version:
SimplePage repository
371 lines (336 loc) • 12.5 kB
JavaScript
import { concat } from 'uint8arrays/concat'
import * as dagPb from '@ipld/dag-pb'
import all from 'it-all'
import { CID } from 'multiformats/cid'
import { addFile, rm, ls, lsFull, assert, CidSet, cp } from '@simplepg/common'
import { CHANGE_TYPE } from './constants.js'
export const FILES_FOLDER = '_files'
const CHANGE_ROOT_KEY = 'spg_files_change_root'
/**
* A class for managing file operations in a SimplePage repository.
* Provides methods for listing, adding, and removing files with staging support.
*
* @param {object} fs - The unixfs filesystem instance.
* @param {object} blockstore - The blockstore instance.
* @param {function} ensureRepoData - A function to ensure the repo data is loaded.
*/
export class Files {
#ensureRepoData
#blockstore
#fs
#root
#changeRoot
#dservice
#storage
constructor(fs, blockstore, dservice, ensureRepoData, storage) {
this.#fs = fs
this.#blockstore = blockstore
this.#dservice = dservice
this.#ensureRepoData = ensureRepoData
this.#storage = storage
this.#root = null
this.#changeRoot = null
}
/**
* Sets the repo root CID for file operations.
* @param {CID} root - The root CID to use for file operations.
*/
async unsafeSetRepoRoot(root) {
// Check if /_files exists in the root
const refs = await ls(this.#blockstore, root)
const filesCid = refs.find(([name]) => name === FILES_FOLDER)?.[1]
this.#root = filesCid || (await this.#fs.addDirectory())
await this.#initializeChangeRoot()
}
/**
* Initializes the changeRoot from storage or creates a new one.
*/
async #initializeChangeRoot() {
const storedChangeRoot = JSON.parse(await this.#storage.getItem(CHANGE_ROOT_KEY))
const changeRoot = storedChangeRoot ? CID.parse(storedChangeRoot.changeRoot) : this.#root
this.#changeRoot = changeRoot
}
/**
* Returns true if the file changes are based on an old repo root.
* @returns {boolean} Whether the file changes are based on an old repo root.
*/
async isOutdated() {
await this.#isReady()
const storedChangeRoot = JSON.parse(await this.#storage.getItem(CHANGE_ROOT_KEY))
if (!storedChangeRoot) {
return false
}
return storedChangeRoot.root !== this.#root.toString() && storedChangeRoot.changeRoot !== this.#root.toString()
}
/**
* Saves the changeRoot to storage.
*/
async #setChangeRoot(changeRoot) {
this.#changeRoot = changeRoot
await this.#storage.setItem(CHANGE_ROOT_KEY, JSON.stringify({
root: this.#root.toString(),
changeRoot: changeRoot.toString()
}))
}
/**
* Checks if a file or folder exists in the filesystem.
* @param {string} path - The file path.
* @returns {Promise<CID | undefined>} The file entry CID if found, undefined if not found.
*/
async #fileExists(path, inChangeRoot = false) {
const split = path.split('/').filter(Boolean)
let ref = ['', inChangeRoot ? this.#changeRoot : this.#root]
for (const path of split) {
const refs = await ls(this.#blockstore, ref[1])
ref = refs.find(f => f[0] === path)
if (!ref) return undefined
}
return ref[1]
}
async #isReady() {
await this.#ensureRepoData()
if (!this.#root) {
throw new Error('Root not set. Call unsafeSetRoot() first.')
}
}
async #ensureContent(cid) {
if (!(await this.#blockstore.has(cid))) {
const response = await this.#dservice.fetch(`/file?cid=${cid.toString()}`)
if (response.status === 200) {
const content = new Uint8Array(await response.arrayBuffer())
await this.#blockstore.put(cid, content)
return content
}
throw new Error('Failed to fetch file from dservice')
}
}
/**
* Lists the contents of a directory.
* @param {string} path - The path of the directory to list.
* @returns {Promise<UnixFSEntry[]>} The list of contents of the directory.
*/
async ls(path) {
await this.#isReady()
const refsByPath = async (pathSplit, refs, inChangeRoot = false) => {
for (const path of pathSplit) {
const loc = refs.find(({ Name }) => Name === path)
assert(!inChangeRoot || loc, `Folder not found: ${path}`)
if (!loc) continue // we are calling ls in a folder that hasn't been created in fs yet
await this.#ensureContent(loc.Hash)
refs = await lsFull(this.#blockstore, loc.Hash)
}
return refs
}
// Get entries from original filesystem
const pathSplit = path.split('/').filter(Boolean)
let refs = await lsFull(this.#blockstore, this.#root)
refs = await refsByPath(pathSplit, refs)
// Get entries from change filesystem
let changeRefs = await lsFull(this.#blockstore, this.#changeRoot)
changeRefs = await refsByPath(pathSplit, changeRefs, true)
// Combine entries from both filesystems
const allEntries = new Map()
const refToEntry = (ref, fullPath, stat, change) => ({
name: ref.Name,
cid: ref.Hash,
size: ref.Tsize,
path: fullPath,
type: stat.type === 'directory' ? 'directory' : 'file',
change: change
})
// Add original entries
for (const ref of refs) {
const fullPath = [...pathSplit, ref.Name].join('/')
await this.#ensureContent(ref.Hash)
const stat = await this.#fs.stat(ref.Hash)
// set all changes to delete, next loop will set the correct change if file still exists in changeRoot
allEntries.set(ref.Name, refToEntry(ref, fullPath, stat, CHANGE_TYPE.DELETE))
}
// Add or update with change entries
for (const ref of changeRefs) {
const fullPath = [...pathSplit, ref.Name].join('/')
const entry = allEntries.get(ref.Name)
if (entry) {
if (entry.cid.equals(ref.Hash)) {
delete entry.change
} else {
entry.change = CHANGE_TYPE.EDIT
}
allEntries.set(ref.Name, entry)
} else {
const stat = await this.#fs.stat(ref.Hash)
allEntries.set(ref.Name, refToEntry(ref, fullPath, stat, CHANGE_TYPE.NEW))
}
}
return Array.from(allEntries.values())
}
/**
* Adds a file to the change filesystem.
* @param {string} path - The path where to add the file.
* @param {Uint8Array} content - The file content as a Uint8Array.
* @returns {Promise<void>} Resolves when the file is staged.
*/
async add(path, content, { forceAvatar = false } = {}) {
await this.#isReady()
path = path.startsWith('/') ? path : `/${path}`
assert(!path.startsWith('/.avatar.') || forceAvatar, `${path} is a reserved filename`)
// if the folder exist under changeRoot, we need to ensure it's in the local blockstore
const split = path.split('/').filter(Boolean)
split.pop() // remove file name
const revSplit = split.reverse()
let tmpPath = []
while (revSplit.length > 0) {
tmpPath.push(revSplit.pop())
try { // ls will ensure the folder is in the local blockstore, if it's under changeRoot
await this.ls(tmpPath.join('/'))
} catch (e) {}
}
// Add file to changeRoot
await this.#setChangeRoot(await addFile(this.#fs, this.#changeRoot, path, content))
}
/**
* Sets the avatar for the website.
* @param {Uint8Array} content - The avatar content as a Uint8Array.
* @param {string} fileExt - The file extension of the avatar.
* @returns {Promise<void>} Resolves when the avatar is set.
*/
async setAvatar(content, fileExt) {
await this.#isReady()
const avatarPath = await this.getAvatarPath(true)
if (avatarPath) {
try {
await this.rm(avatarPath)
} catch (e) {}
}
await this.add('/.avatar.' + fileExt, content, { forceAvatar: true })
}
/**
* Gets the avatar for the website.
* @returns {Promise<Uint8Array | null>} The avatar content as a Uint8Array, or null if no avatar is set.
*/
async getAvatarPath(noPrefix = false) {
await this.#isReady()
const files = await this.ls('/')
const avatarPath = files.find(f => f.name.startsWith('.avatar.'))?.path
return !noPrefix && avatarPath ? `/${FILES_FOLDER}/${avatarPath}` : avatarPath
}
/**
* Removes a file from the change filesystem or commited filesystem.
* @param {string} path - The path of the file to remove.
* @returns {Promise<void>} Resolves when the file is staged for deletion or removed.
*/
async rm(path) {
await this.#isReady()
// Remove from changeRoot
await this.#setChangeRoot(await rm(this.#fs, this.#changeRoot, path, { recursive: false }))
}
/**
* Creates a directory in the change filesystem.
* @param {string} path - The path of the directory to create.
* @returns {Promise<void>} Resolves when the directory is created.
*/
async mkdir(path) {
await this.#isReady()
// filter removes ""
const split = path.split('/').filter(Boolean)
if (await this.#fileExists(split.join('/'), true)) {
throw new Error(`Directory or file already exists: ${path}`)
}
let changePointer = await this.#fs.addDirectory()
do {
const name = split.pop()
const reminderPath = split.join('/')
const parentCid = await this.#fileExists(reminderPath, true)
if (parentCid) {
changePointer = await this.#fs.cp(changePointer, parentCid, name, { force: true })
} else {
throw new Error(`Parent directory ${reminderPath} does not exist`)
}
} while (split.length > 0)
await this.#setChangeRoot(changePointer)
}
/**
* Restores a file from the change filesystem to its commited state.
* @param {string} path - The path of the file to restore.
*/
async restore(path) {
await this.#isReady()
path = path.startsWith('/') ? path : `/${path}`
// First check if file exists in root
const fileCid = await this.#fileExists(path)
if (!fileCid) {
// If file doesn't exist in root, just remove it from changeRoot
await this.#setChangeRoot(await rm(this.#fs, this.#changeRoot, path))
} else {
// Copy the file from root to changeRoot
const newChangeRoot = await cp(this.#fs, fileCid, this.#changeRoot, path, { force: true })
await this.#setChangeRoot(newChangeRoot)
}
}
/**
* Reads the content of a file from the change filesystem.
* @param {string} path - The path of the file to read.
* @returns {Promise<Uint8Array>} The file content as a Uint8Array.
*/
async cat(path) {
await this.#isReady()
path = path.startsWith('/') ? path : `/${path}`
const fileCid = await this.#fileExists(path, true)
assert(fileCid, `File not found: ${path}`)
await this.#ensureContent(fileCid)
return concat(await all(this.#fs.cat(this.#changeRoot, { path })))
}
async hasChanges() {
await this.#isReady()
return !this.#changeRoot.equals(this.#root)
}
async #getUnchangedRoots(newCid, oldCid) {
if (newCid.equals(oldCid)) {
return new CidSet([newCid])
}
if (newCid.code === dagPb.code) {
const getNode = async (cid) => {
await this.#ensureContent(cid)
return dagPb.decode(await this.#blockstore.get(cid))
}
const [newNode, oldNode] = await Promise.all([ getNode(newCid), getNode(oldCid) ])
let cids = new CidSet()
for (const link of newNode.Links) {
const oldLink = oldNode.Links.find(l => l.Name === link.Name)
if (oldLink) {
const childCids = await this.#getUnchangedRoots(link.Hash, oldLink.Hash)
cids = new CidSet([...cids, ...childCids])
}
}
return cids
}
return new CidSet()
}
/**
* Stages all changes and returns a new CID of the updated filesystem root.
* @returns {Promise<{ cid: CID, unchangedCids: CidSet }>} The CID of the new root after staging changes.
*/
async stage() {
await this.#isReady()
// The changeRoot already contains all the staged changes
const unchangedCids = await this.#getUnchangedRoots(this.#changeRoot, this.#root)
return { cid: this.#changeRoot, unchangedCids }
}
/**
* Finalizes a commit.
* Clears out all edits and updates the repo state.
* @param {string} cid - The CID of the new repo root.
*/
async finalizeCommit(cid) {
await this.#isReady()
this.#root = cid
await this.#setChangeRoot(cid)
}
/**
* Clears all staged changes.
*/
async clearChanges() {
await this.#isReady()
await this.#setChangeRoot(this.#root)
}
}