// Claude usage history (issue #55): the closed days of the Claude usage
// snapshot, kept past its 30-day window, one file per month.
//
// Layout:
/history/YYYY-MM.json, each file
// { "schemaVersion": 1, "month": "YYYY-MM", "days": [{ "date", "models" }] }
// with the days sorted oldest first, in the very shape of a `days` entry of the
// snapshot (same per-day cleaner, exported by claude-usage.js).
//
// Two writers, two merge rules:
// - POST /claude-usage (index.js): the LATEST SNAPSHOT WINS. Every day the
// snapshot carries, except the last one (today, still open), replaces the
// archived value. A day leaving the window keeps the last value it had,
// the one the agent froze at D+2.
// - the one-shot import below (`--import`): the ARCHIVED DATE WINS. The live
// archive is the reference; a backfill only fills the dates it lacks.
//
// A month file that cannot be read back is never overwritten: it may hold days
// that left the window long ago, and rewriting it from one snapshot would lose
// them. That month is skipped and reported; the other months are written.
//
// No config and no environment read outside the CLI block at the bottom: every
// function takes the history directory as an argument. Dates are grouped by
// the YYYY-MM prefix of their string, never by a clock computation.
const fs = require("node:fs");
const path = require("node:path");
const { sanitizeDay } = require("./claude-usage.js");
const HISTORY_SCHEMA_VERSION = 1;
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
const MONTH_FILE_RE = /^\d{4}-(0[1-9]|1[0-2])\.json$/;
// GET /claude-usage/history: default span, and the widest one it serves.
const DEFAULT_MONTHS = 12;
const MAX_MONTHS = 36;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isMonth(value) {
return typeof value === "string" && MONTH_RE.test(value);
}
function monthOf(date) {
return date.slice(0, 7);
}
// Calendar arithmetic on the two numbers of the string, no Date involved.
function addMonths(month, n) {
const index = Number(month.slice(0, 4)) * 12 + Number(month.slice(5, 7)) - 1 + n;
const year = Math.floor(index / 12);
return `${String(year).padStart(4, "0")}-${String((index % 12) + 1).padStart(2, "0")}`;
}
// Inclusive: 2026-01 to 2026-12 spans 12 months.
function monthSpan(from, to) {
const index = (m) => Number(m.slice(0, 4)) * 12 + Number(m.slice(5, 7));
return index(to) - index(from) + 1;
}
function byDate(a, b) {
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
}
// Replace or add each incoming date (preferExisting: false, the POST rule), or
// add only the dates not there yet (preferExisting: true, the import rule).
// Returns a new array sorted oldest first; neither input is modified.
function mergeDays(existing, incoming, { preferExisting = false } = {}) {
const merged = new Map();
for (const day of existing) merged.set(day.date, day);
for (const day of incoming) {
if (preferExisting && merged.has(day.date)) continue;
merged.set(day.date, day);
}
return [...merged.values()].sort(byDate);
}
// The days POST /claude-usage archives: every day of the snapshot except the
// most recent date (today, still open), whatever the order they were sent in,
// and never a date past `receivedDay` (the server's UTC date of reception,
// never behind the workstation's Toronto date): a day from a skewed clock
// would otherwise open a future month file that the read route would take
// as its default end.
function closedDays(days, receivedDay) {
const dated = days.filter((day) => day.date <= receivedDay);
if (!dated.length) return [];
const latest = dated.reduce((max, day) => (day.date > max ? day.date : max), dated[0].date);
return dated.filter((day) => day.date !== latest);
}
// YYYY-MM -> days of that month, in input order.
function groupByMonth(days) {
const months = new Map();
for (const day of days) {
const month = monthOf(day.date);
if (!months.has(month)) months.set(month, []);
months.get(month).push(day);
}
return months;
}
// A parsed month file, re-read through the snapshot's whitelist: the file
// lives on a writable bind-mount and is no more trusted than a payload.
// Returns the clean days, or null when the file is off-format as a whole (it
// is then "unreadable": served without it, and never overwritten). A single
// invalid day, or one dated outside the month, is dropped; on a repeated
// date, the later entry wins.
function sanitizeMonthFile(parsed, month) {
if (!isPlainObject(parsed)) return null;
if (parsed.schemaVersion !== HISTORY_SCHEMA_VERSION) return null;
if (parsed.month !== month || !Array.isArray(parsed.days)) return null;
const days = [];
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day !== undefined && monthOf(day.date) === month) days.push(day);
}
return mergeDays([], days);
}
function historyDir(claudeUsagePath) {
return path.join(path.dirname(claudeUsagePath), "history");
}
function monthPath(dir, month) {
return path.join(dir, `${month}.json`);
}
// { state: "absent" } | { state: "ok", days } | { state: "unreadable", code }
function readMonth(dir, month) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(monthPath(dir, month), "utf-8"));
} catch (err) {
if (err.code === "ENOENT") return { state: "absent" };
return { state: "unreadable", code: err.code || "parse_error" };
}
const days = sanitizeMonthFile(parsed, month);
if (days === null) return { state: "unreadable", code: "off_format" };
return { state: "ok", days };
}
// Same atomic pattern as writeClaudeUsage() in index.js: temp file in the SAME
// directory (pid-suffixed, so two containers of a rolling redeploy never share
// one), mode 600, then renameSync. A concurrent read sees the old month or the
// new one, never a truncated JSON. No sweep covers these temp files: an orphan
// is harmless, the reader only opens YYYY-MM.json.
function writeMonth(dir, month, days) {
const finalPath = monthPath(dir, month);
const tmpPath = path.join(dir, `.${month}.json.${process.pid}.${Date.now()}.tmp`);
const record = { schemaVersion: HISTORY_SCHEMA_VERSION, month, days };
try {
fs.writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Merge `days` into their month files. Each month stands alone: an unreadable
// one is skipped (never overwritten), a failed write is reported, and the
// other months go on. Only a failure to create the directory throws.
//
// Returns { written, skipped, failed, added, kept }: month lists, plus the
// number of dates added and, under preferExisting, of dates already archived.
function archiveDays(dir, days, { preferExisting = false } = {}) {
const report = { written: [], skipped: [], failed: [], added: 0, kept: 0 };
if (!days.length) return report;
fs.mkdirSync(dir, { recursive: true });
for (const [month, incoming] of [...groupByMonth(days)].sort(([a], [b]) => (a < b ? -1 : 1))) {
const current = readMonth(dir, month);
if (current.state === "unreadable") {
report.skipped.push({ month, code: current.code });
continue;
}
const existing = current.state === "ok" ? current.days : [];
const known = new Set(existing.map((day) => day.date));
const merged = mergeDays(existing, incoming, { preferExisting });
try {
writeMonth(dir, month, merged);
} catch (err) {
report.failed.push({ month, code: err.code || "write_error" });
continue;
}
report.written.push(month);
for (const day of incoming) {
if (!known.has(day.date)) report.added += 1;
else if (preferExisting) report.kept += 1;
}
}
return report;
}
// The most recent month that has a file, or null (no directory, no file).
function lastArchivedMonth(dir) {
let names;
try {
names = fs.readdirSync(dir);
} catch {
return null;
}
const months = names.filter((name) => MONTH_FILE_RE.test(name)).map((name) => name.slice(0, 7));
return months.length ? months.sort().at(-1) : null;
}
// Query string of GET /claude-usage/history -> { from, to } or { error }.
// Defaults: `to` is the month of the last archived day (the current month when
// nothing is archived yet, or when the last file is dated past it), or `from`
// itself when `from` is later; `from` is DEFAULT_MONTHS months back from `to`,
// `to` included.
function parseRange(fromRaw, toRaw, { lastArchived, currentMonth }) {
if (fromRaw !== null && !isMonth(fromRaw)) return { error: "from must be YYYY-MM" };
if (toRaw !== null && !isMonth(toRaw)) return { error: "to must be YYYY-MM" };
let to = toRaw;
if (to === null) {
to = lastArchived && lastArchived <= currentMonth ? lastArchived : currentMonth;
if (fromRaw !== null && fromRaw > to) to = fromRaw;
}
const from = fromRaw !== null ? fromRaw : addMonths(to, -(DEFAULT_MONTHS - 1));
if (from > to) return { error: "from must not be after to" };
if (monthSpan(from, to) > MAX_MONTHS) return { error: `range must not exceed ${MAX_MONTHS} months` };
return { from, to };
}
// Every archived day from `from` to `to` (both YYYY-MM, inclusive), oldest
// first. An absent month contributes nothing; an unreadable one is listed in
// `unreadable` and contributes nothing either.
function readHistory(dir, from, to) {
const days = [];
const unreadable = [];
for (let month = from; month <= to; month = addMonths(month, 1)) {
const current = readMonth(dir, month);
if (current.state === "ok") days.push(...current.days);
else if (current.state === "unreadable") unreadable.push({ month, code: current.code });
}
return { days, unreadable };
}
// Import file of the one-shot backfill: { days: [...] }. Strict, since it is
// written once by hand: any entry that is not a clean day, or a repeated date,
// refuses the whole file. Returns the days, or null.
function parseImport(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isPlainObject(parsed) || !Array.isArray(parsed.days)) return null;
const days = [];
const seen = new Set();
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day === undefined || seen.has(day.date)) return null;
seen.add(day.date);
days.push(day);
}
return days;
}
// `node claude-usage-history.js --import `, run inside the container
// (docker exec, as `node`): merges a backfill into the live archive, the
// archived dates winning. Prints counts only, never a day's content.
function runImport(argv, env, io) {
if (argv.length !== 2 || argv[0] !== "--import") {
io.err("usage: node claude-usage-history.js --import ");
return 2;
}
let raw;
try {
raw = argv[1] === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(argv[1], "utf-8");
} catch (err) {
io.err(`import: cannot read the import file (${err.code || "read_error"})`);
return 1;
}
const days = parseImport(raw);
if (days === null) {
io.err("import: invalid import file, nothing written");
return 1;
}
const dir = historyDir(env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json");
let report;
try {
report = archiveDays(dir, days, { preferExisting: true });
} catch (err) {
io.err(`import: cannot create the history directory (${err.code || "error"})`);
return 1;
}
io.out(
`import: ${days.length} day(s) read, ${report.added} added, ${report.kept} already archived, ` +
`${report.written.length} month(s) written`,
);
for (const { month, code } of report.skipped) io.err(`import: month ${month} skipped, unreadable (${code})`);
for (const { month, code } of report.failed) io.err(`import: month ${month} not written (${code})`);
return report.skipped.length || report.failed.length ? 1 : 0;
}
if (require.main === module) {
process.exitCode = runImport(process.argv.slice(2), process.env, {
out: (line) => console.log(line),
err: (line) => console.error(line),
});
}
module.exports = {
isMonth,
monthOf,
addMonths,
monthSpan,
mergeDays,
groupByMonth,
closedDays,
sanitizeMonthFile,
historyDir,
readMonth,
writeMonth,
archiveDays,
lastArchivedMonth,
parseRange,
readHistory,
parseImport,
runImport,
HISTORY_SCHEMA_VERSION,
DEFAULT_MONTHS,
MAX_MONTHS,
};