Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sideshow-data-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Move the default data directory from the package-relative `<package-root>/data/` to a user-owned `~/.sideshow/`. The package-relative default was read-only under `sudo npm install -g` (crashing with `EACCES` even after the #157 mkdir guard) and was wiped on every `npm install -g` upgrade — silent data loss. `~/.sideshow/` is always writable and survives reinstalls. A one-time migration copies any existing `sideshow.{db,db-wal,db-shm,json}` from the old location to the new one on first boot (only when using default paths; `SIDESHOW_DATA`/`SIDESHOW_DB` overrides are unchanged and skip the migration).
18 changes: 16 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { serve } from "@hono/node-server";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createApp } from "./app.ts";
import { migrateLegacyDataDir } from "./migrateDataDir.ts";
import { SqlStore } from "./sqlStore.ts";
import { createSqliteStorage, migrateJsonToSqlite } from "./sqliteStorage.ts";
import { JsonFileStore } from "./storage.ts";
Expand Down Expand Up @@ -32,12 +34,24 @@ const publicRead = pr === "session" || pr === "full" ? pr : undefined;
// mirrors the Cloudflare Durable Object deploy — both run the same SqlStore.
// SIDESHOW_STORE=json selects the legacy single-file JSON store instead.
// SIDESHOW_DATA names the JSON file (and the one-time migration source);
// SIDESHOW_DB names the SQLite file.
const jsonPath = process.env.SIDESHOW_DATA ?? join(root, "data", "sideshow.json");
// SIDESHOW_DB names the SQLite file. Both default to ~/.sideshow/ — a
// user-owned dir that survives reinstalls and is writable regardless of how
// the package was installed (a package-relative default is read-only under
// `sudo npm i -g` and wiped on upgrade).
const dataDir = join(homedir(), ".sideshow");
const jsonPath = process.env.SIDESHOW_DATA ?? join(dataDir, "sideshow.json");
// The SQLite file defaults next to the JSON one (same dir, `.db` suffix) so a
// deploy that only sets SIDESHOW_DATA still gets an isolated, co-located db —
// and the migration source sits right beside it.
const dbPath = process.env.SIDESHOW_DB ?? `${jsonPath.replace(/\.json$/, "")}.db`;
// Migrate from the legacy package-relative `<root>/data/` location to the
// user-owned home dir, but only when using default paths — a user who set
// SIDESHOW_DATA or SIDESHOW_DB is managing their own location.
if (!process.env.SIDESHOW_DATA && !process.env.SIDESHOW_DB) {
if (migrateLegacyDataDir(join(root, "data"), dataDir)) {
console.log(`[sideshow] migrated existing data from ${join(root, "data")} to ${dataDir}`);
}
}
let store: Store;
if (process.env.SIDESHOW_STORE === "json") {
store = new JsonFileStore(jsonPath);
Expand Down
36 changes: 36 additions & 0 deletions server/migrateDataDir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cpSync, existsSync, mkdirSync, renameSync } from "node:fs";
import { join } from "node:path";

// One-time migration: the default data location moved from the package dir
// (`<root>/data/`) to a user-owned home dir (`~/.sideshow/`). Existing installs
// may have their SQLite db and/or legacy JSON file under the old path. This
// copies those files to the new location before the store opens, so an upgrade
// preserves data without a manual move.
//
// It's a no-op if the old dir doesn't exist or has no sideshow files, and it
// never overwrites files already present at the new location (idempotent —
// safe to call on every boot). The old files are left in place as a backup
// (rename on the same filesystem moves them; a cross-device copy leaves the
// original behind). Only the four sideshow data files are touched — anything
// else in the old dir stays put.
export function migrateLegacyDataDir(oldDir: string, newDir: string): boolean {
if (!existsSync(oldDir)) return false;
const names = ["sideshow.json", "sideshow.db", "sideshow.db-wal", "sideshow.db-shm"];
if (!names.some((n) => existsSync(join(oldDir, n)))) return false;
mkdirSync(newDir, { recursive: true });
let moved = false;
for (const name of names) {
const src = join(oldDir, name);
if (!existsSync(src)) continue;
const dst = join(newDir, name);
if (existsSync(dst)) continue; // idempotent — never overwrite
try {
renameSync(src, dst); // atomic + fast on the same filesystem
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "EXDEV") throw e;
cpSync(src, dst); // cross-device: copy, leave original as backup
}
moved = true;
}
return moved;
}
81 changes: 81 additions & 0 deletions test/migrateDataDir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, existsSync, readFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { migrateLegacyDataDir } from "../server/migrateDataDir.ts";

const tmpDir = () => mkdtempSync(join(tmpdir(), "sideshow-datadir-"));

test("migrates db, wal, shm, and json files from old dir to new dir", () => {
const old = tmpDir();
const neu = join(tmpDir(), "sideshow");
writeFileSync(join(old, "sideshow.db"), "DB");
writeFileSync(join(old, "sideshow.db-wal"), "WAL");
writeFileSync(join(old, "sideshow.db-shm"), "SHM");
writeFileSync(join(old, "sideshow.json"), "JSON");

const moved = migrateLegacyDataDir(old, neu);

assert.equal(moved, true);
assert.equal(readFileSync(join(neu, "sideshow.db"), "utf8"), "DB");
assert.equal(readFileSync(join(neu, "sideshow.db-wal"), "utf8"), "WAL");
assert.equal(readFileSync(join(neu, "sideshow.db-shm"), "utf8"), "SHM");
assert.equal(readFileSync(join(neu, "sideshow.json"), "utf8"), "JSON");
});

test("is idempotent — a second run does not overwrite the new dir", () => {
const old = tmpDir();
const neu = join(tmpDir(), "sideshow");
writeFileSync(join(old, "sideshow.db"), "ORIGINAL");
writeFileSync(join(old, "sideshow.json"), "ORIGINAL_JSON");

migrateLegacyDataDir(old, neu);
// Simulate the user having newer data at the new location on a later boot
writeFileSync(join(neu, "sideshow.db"), "NEWER");
const moved = migrateLegacyDataDir(old, neu);

assert.equal(moved, false);
assert.equal(readFileSync(join(neu, "sideshow.db"), "utf8"), "NEWER");
});

test("is a no-op when the old dir does not exist", () => {
const neu = join(tmpDir(), "sideshow");
const moved = migrateLegacyDataDir(join(tmpDir(), "nonexistent"), neu);
assert.equal(moved, false);
assert.equal(existsSync(neu), false);
});

test("is a no-op when the old dir has no sideshow files", () => {
const old = tmpDir();
const neu = join(tmpDir(), "sideshow");
writeFileSync(join(old, "unrelated.txt"), "ignore me");
const moved = migrateLegacyDataDir(old, neu);
assert.equal(moved, false);
assert.equal(existsSync(neu), false);
});

test("only migrates the sideshow files, leaving other files behind", () => {
const old = tmpDir();
const neu = join(tmpDir(), "sideshow");
writeFileSync(join(old, "sideshow.db"), "DB");
writeFileSync(join(old, "other.txt"), "stays");
migrateLegacyDataDir(old, neu);
assert.equal(existsSync(join(neu, "other.txt")), false);
assert.equal(existsSync(join(old, "other.txt")), true);
});

test("handles a partially-populated new dir (migrates only missing files)", () => {
const old = tmpDir();
const neu = tmpDir();
writeFileSync(join(old, "sideshow.db"), "OLD_DB");
writeFileSync(join(old, "sideshow.json"), "OLD_JSON");
// new dir already has a db — must not be overwritten
mkdirSync(neu, { recursive: true });
writeFileSync(join(neu, "sideshow.db"), "EXISTING_DB");

migrateLegacyDataDir(old, neu);

assert.equal(readFileSync(join(neu, "sideshow.db"), "utf8"), "EXISTING_DB");
assert.equal(readFileSync(join(neu, "sideshow.json"), "utf8"), "OLD_JSON");
});
Loading