@simplepg/repo
Version:
SimplePage repository
297 lines (258 loc) • 9.07 kB
JavaScript
import { concat } from 'uint8arrays/concat'
import all from 'it-all'
import { CID } from 'multiformats/cid'
import { ls, assert } from '@simplepg/common'
export const SETTINGS_FILE = 'settings.json'
const CHANGE_ROOT_KEY = 'spg_settings_change_root'
/**
* Validates a dot notation key and throws an error if invalid.
* @param {string} key - The key to validate.
* @throws {Error} If the key contains invalid dot notation patterns.
*/
function validateDotNotation(key) {
assert(typeof key === 'string', 'Key must be a string');
assert(!key.startsWith('.'), 'Key cannot start with a dot');
assert(!key.endsWith('.'), 'Key cannot end with a dot');
assert(!key.includes('..'), 'Key cannot contain consecutive dots');
// Check for empty segments (which would result from consecutive dots or leading/trailing dots)
const segments = key.split('.');
assert(!segments.some(segment => segment === ''), 'Key cannot contain empty segments');
}
/**
* A class for managing settings in a SimplePage repository.
* Provides methods for reading and writing a single settings.json file
* with staging support for top-level properties.
*
* @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.
* @param {object} storage - The storage object for persisting change state.
*/
export class Settings {
#ensureRepoData
#blockstore
#fs
#persistedCid
#changeCid
#storage
constructor(fs, blockstore, ensureRepoData, storage) {
this.#fs = fs
this.#blockstore = blockstore
this.#ensureRepoData = ensureRepoData
this.#storage = storage
this.#persistedCid = null
this.#changeCid = null
}
/**
* Sets the repo root CID for settings operations.
* @param {CID} root - The root CID to use for settings operations.
*/
async unsafeSetRepoRoot(root) {
const refs = await ls(this.#blockstore, root)
const settingsCid = refs.find(([name]) => name === SETTINGS_FILE)?.[1]
this.#persistedCid = settingsCid || (await this.#writeJson({}))
await this.#initializeChangeCid()
}
/**
* Initializes the changeCid from storage or creates a new one.
*/
async #initializeChangeCid() {
const storedChangeCid = this.#storage.getItem(CHANGE_ROOT_KEY)
if (storedChangeCid) {
const parsed = JSON.parse(storedChangeCid)
const changeCid = CID.parse(parsed.changeCid)
this.#changeCid = changeCid
} else {
this.#changeCid = this.#persistedCid
}
}
/**
* Returns true if the settings changes are based on an old repo persistedCid.
* @returns {boolean} Whether the settings changes are based on an old repo persistedCid.
*/
async isOutdated() {
await this.#isReady()
const storedChangeRoot = this.#storage.getItem(CHANGE_ROOT_KEY)
if (!storedChangeRoot) {
return false
}
const parsed = JSON.parse(storedChangeRoot)
return parsed.persistedCid !== this.#persistedCid.toString() && parsed.changeCid !== this.#persistedCid.toString()
}
/**
* Saves the changeCid to storage.
*/
async #setChangeCid(changeCid) {
this.#changeCid = changeCid
await this.#storage.setItem(CHANGE_ROOT_KEY, JSON.stringify({
persistedCid: this.#persistedCid.toString(),
changeCid: changeCid.toString()
}))
}
/**
* Reads the entire settings object from the current change root.
* @returns {Promise<object>} The settings object.
*/
async read() {
await this.#isReady()
return this.#readJson()
}
/**
* Reads a specific property from settings, supporting nested keys with dot notation.
* @param {string} key - The property key to read (supports dot notation for nested properties).
* @returns {Promise<any>} The property value, or undefined if not found.
*/
async readProperty(key) {
validateDotNotation(key)
const settings = await this.read()
return key.split('.').reduce((current, k) => {
return current && current[k] !== undefined ? current[k] : undefined
}, settings)
}
/**
* Writes a specific property to settings, supporting nested keys with dot notation.
* @param {string} key - The property key to write (supports dot notation for nested properties).
* @param {any} value - The property value to write.
*/
async writeProperty(key, value) {
validateDotNotation(key)
await this.#isReady()
const settings = await this.read()
const keys = key.split('.')
const lastKey = keys.pop()
const target = keys.reduce((current, k) => {
if (!current[k] || typeof current[k] !== 'object') {
current[k] = {}
}
return current[k]
}, settings)
target[lastKey] = value
return this.write(settings)
}
/**
* Writes the entire settings object.
* @param {object} settings - The settings object to write.
*/
async write(settings) {
await this.#isReady()
const cid = await this.#writeJson(settings)
await this.#setChangeCid(cid)
}
async #writeJson(json) {
const jsonString = JSON.stringify(json, null, 2)
const content = new TextEncoder().encode(jsonString)
return this.#fs.addBytes(content)
}
/**
* Deletes a specific property from settings, supporting nested keys with dot notation.
* @param {string} key - The property key to delete (supports dot notation for nested properties).
*/
async deleteProperty(key) {
validateDotNotation(key)
await this.#isReady()
const settings = await this.read()
const keys = key.split('.')
const lastKey = keys.pop()
const target = keys.reduce((current, k) => {
return current && current[k] ? current[k] : null
}, settings)
if (target && target.hasOwnProperty(lastKey)) {
delete target[lastKey]
}
return this.write(settings)
}
/**
* Reads the content of the settings file from the change root.
* @returns {Promise<Uint8Array>} The file content as a Uint8Array.
*/
async #readJson(persisted = false) {
await this.#isReady()
const bytes = concat(await all(this.#fs.cat(persisted ? this.#persistedCid : this.#changeCid)))
return JSON.parse(new TextDecoder().decode(bytes))
}
/**
* Checks if there are any changes to the settings.
* @returns {Promise<boolean>} Whether there are changes.
*/
async hasChanges() {
await this.#isReady()
return !this.#changeCid.equals(this.#persistedCid)
}
/**
* Returns an array representing the changes to the settings
* based on the persisted and change CIDs.
* @returns {Promise<Array<string|{path:string,from:any,to:any}>>}
*/
async changeDiff() {
await this.#isReady()
const persistedJson = await this.#readJson(true)
const changeJson = await this.#readJson()
const compareValues = (persistedVal, changeVal, path = '') => {
if (persistedVal === changeVal) return []
if (typeof persistedVal === 'object' && typeof changeVal === 'object' &&
persistedVal !== null && changeVal !== null) {
const diffs = []
const allKeys = new Set([...Object.keys(persistedVal), ...Object.keys(changeVal)])
for (const key of allKeys) {
const newPath = path ? `${path}.${key}` : key
diffs.push(...compareValues(persistedVal[key], changeVal[key], newPath))
}
return diffs
} else if (persistedVal === undefined) {
if (typeof changeVal === 'object' && changeVal !== null) {
return compareValues({}, changeVal, path)
} else {
return [{ path, to: changeVal }]
}
} else if (changeVal === undefined) {
if (typeof persistedVal === 'object' && persistedVal !== null) {
return compareValues(persistedVal, {}, path)
} else {
return [{ path, from: persistedVal }]
}
}
return [{ path, from: persistedVal, to: changeVal }]
}
return compareValues(persistedJson, changeJson)
}
/**
* Restores the settings to their committed state.
*/
async restore() {
await this.#isReady()
await this.#setChangeCid(this.#persistedCid)
}
/**
* Stages all changes and returns a new CID of the updated root.
* @returns {Promise<CID>} The CID of the new root after staging changes.
*/
async stage() {
await this.#isReady()
return this.#changeCid
}
/**
* Finalizes a commit.
* Clears out all changes and updates the settings state.
* @param {CID} cid - The CID of the new repo root.
*/
async finalizeCommit(cid) {
await this.#isReady()
this.#persistedCid = cid
await this.#setChangeCid(cid)
}
/**
* Clears all staged changes.
*/
async clearChanges() {
await this.restore()
}
/**
* Ensures the settings instance is ready for operations.
*/
async #isReady() {
await this.#ensureRepoData()
if (!this.#persistedCid) {
throw new Error('Root not set. Call unsafeSetRepoRoot() first.')
}
}
}