Persist remaining client state in disk cache files
Release rolling / release (push) Has been cancelled

Move templates, notifications, backups, table columns, peer envs, and
sidebar/first-connect prefs onto ~/.config/peardock/cache (settings.json
or dedicated JSON files) with legacy localStorage migration.
This commit is contained in:
Raven Scott
2026-07-17 17:41:11 -04:00
parent 9da9345e32
commit ec96356855
11 changed files with 990 additions and 185 deletions
+302
View File
@@ -0,0 +1,302 @@
/**
* Generic client JSON cache under ~/.config/peardock/cache/{name}.json
*
* Mirrors the peers/settings cache pattern:
* - Primary: filesystem (desktop / Pear / Electron / Node)
* - Fallback + session mirror: localStorage when FS unavailable
* - One-time migration from a legacy localStorage key when provided
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
export const JSON_CACHE_VERSION = 1
export const JSON_CACHE_LS_PREFIX = 'peardock.cache.'
/**
* @returns {string}
*/
export function getPeardockHome() {
return (
process.env.PEARDOCK_HOME ||
process.env.HOME ||
process.env.USERPROFILE ||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
''
)
}
/**
* @returns {string}
*/
export function getPeardockCacheDir() {
return path.join(getPeardockHome(), '.config', 'peardock', 'cache')
}
/**
* Sanitize a cache name to a safe file basename (no path segments).
* @param {string} name
* @returns {string}
*/
export function sanitizeCacheName(name) {
const s = String(name || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!s || s === '.' || s === '..') {
throw new Error(`Invalid cache name: ${name}`)
}
return s
}
/**
* @param {string} name
* @returns {string}
*/
export function getCacheFilePath(name) {
return path.join(getPeardockCacheDir(), `${sanitizeCacheName(name)}.json`)
}
/**
* @param {string} name
* @returns {string}
*/
export function getCacheLocalStorageKey(name) {
return JSON_CACHE_LS_PREFIX + sanitizeCacheName(name)
}
/**
* @param {unknown} raw
* @returns {unknown}
*/
export function parseCachePayload(raw) {
let parsed = raw
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
return null
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
// Allow top-level arrays (e.g. notifications history)
if (Array.isArray(parsed)) return parsed
return null
}
// Versioned envelope { version, updatedAt, data }
if (
Object.prototype.hasOwnProperty.call(parsed, 'data') &&
(parsed.version != null || parsed.updatedAt != null)
) {
return parsed.data
}
return parsed
}
/**
* @param {unknown} data
* @returns {{ version: number, updatedAt: string, data: unknown }}
*/
export function buildCachePayload(data) {
return {
version: JSON_CACHE_VERSION,
updatedAt: new Date().toISOString(),
data,
}
}
function ensureCacheDir() {
const dir = getPeardockCacheDir()
fs.mkdirSync(dir, { recursive: true })
return dir
}
/**
* @param {string} name
* @returns {unknown|null}
*/
export function readCacheFromFile(name) {
const file = getCacheFilePath(name)
try {
if (!fs.existsSync(file)) return null
const raw = fs.readFileSync(file, 'utf8')
return parseCachePayload(raw)
} catch (err) {
console.warn('[WARN] jsonCache: failed to read', name, err?.message || err)
return null
}
}
/**
* Atomic write of versioned envelope.
* @param {string} name
* @param {unknown} data
* @returns {boolean}
*/
export function writeCacheToFile(name, data) {
const file = getCacheFilePath(name)
const json = JSON.stringify(buildCachePayload(data), null, 2)
try {
ensureCacheDir()
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
fs.writeFileSync(tmp, json, { encoding: 'utf8', mode: 0o600 })
fs.renameSync(tmp, file)
try {
fs.chmodSync(file, 0o600)
} catch {
// ignore
}
return true
} catch (err) {
console.error('[ERROR] jsonCache: failed to write', name, err?.message || err)
return false
}
}
/**
* @param {string} name
* @returns {unknown|null}
*/
export function readCacheFromLocalStorage(name) {
try {
if (typeof localStorage === 'undefined') return null
const raw = localStorage.getItem(getCacheLocalStorageKey(name))
if (raw == null) return null
return parseCachePayload(raw)
} catch {
return null
}
}
/**
* @param {string} name
* @param {unknown} data
*/
export function mirrorCacheToLocalStorage(name, data) {
try {
if (typeof localStorage === 'undefined') return
localStorage.setItem(
getCacheLocalStorageKey(name),
JSON.stringify(buildCachePayload(data))
)
} catch {
// ignore
}
}
/**
* @param {unknown} value
* @returns {boolean}
*/
function isEmptyData(value) {
if (value == null) return true
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'object') return Object.keys(value).length === 0
return false
}
/**
* Load cache: file first, then optional legacy localStorage key, then mirror key.
* Migrates legacy → file when found.
*
* @param {string} name — cache file basename (e.g. "templates")
* @param {{
* fallback?: unknown,
* legacyLocalStorageKey?: string,
* isEmpty?: (v: unknown) => boolean,
* }} [opts]
* @returns {unknown}
*/
export function loadJsonCache(name, opts = {}) {
const empty = typeof opts.isEmpty === 'function' ? opts.isEmpty : isEmptyData
const fallback = opts.fallback !== undefined ? opts.fallback : null
let data = readCacheFromFile(name)
if (!empty(data)) {
mirrorCacheToLocalStorage(name, data)
return data
}
// Migrate explicit legacy key (e.g. peardock_templates)
if (opts.legacyLocalStorageKey && typeof localStorage !== 'undefined') {
try {
const raw = localStorage.getItem(opts.legacyLocalStorageKey)
if (raw != null && raw !== '') {
const migrated = parseCachePayload(raw)
if (!empty(migrated)) {
writeCacheToFile(name, migrated)
mirrorCacheToLocalStorage(name, migrated)
console.log(
'[INFO] jsonCache: migrated',
opts.legacyLocalStorageKey,
'→',
getCacheFilePath(name)
)
return migrated
}
}
} catch {
// ignore
}
}
// Secondary mirror
const fromLs = readCacheFromLocalStorage(name)
if (!empty(fromLs)) {
writeCacheToFile(name, fromLs)
return fromLs
}
return fallback
}
/**
* Persist cache to file (+ localStorage mirror).
* @param {string} name
* @param {unknown} data
* @returns {{ ok: boolean, path: string }}
*/
export function saveJsonCache(name, data) {
const ok = writeCacheToFile(name, data)
mirrorCacheToLocalStorage(name, data)
return { ok, path: getCacheFilePath(name) }
}
/**
* Remove file + mirrors.
* @param {string} name
* @param {{ legacyLocalStorageKey?: string }} [opts]
*/
export function clearJsonCache(name, opts = {}) {
try {
const file = getCacheFilePath(name)
if (fs.existsSync(file)) fs.unlinkSync(file)
} catch (err) {
console.warn('[WARN] jsonCache: failed to clear file', name, err?.message || err)
}
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(getCacheLocalStorageKey(name))
if (opts.legacyLocalStorageKey) {
localStorage.removeItem(opts.legacyLocalStorageKey)
}
}
} catch {
// ignore
}
}
export default {
getPeardockHome,
getPeardockCacheDir,
getCacheFilePath,
loadJsonCache,
saveJsonCache,
clearJsonCache,
parseCachePayload,
buildCachePayload,
JSON_CACHE_VERSION,
JSON_CACHE_LS_PREFIX,
}