spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/lib/store.mjs8 * Purpose : meta.json repo index + activity feed (filesystem is the database)9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';14import { join } from 'node:path';15import { atomicWriteJSON } from './util.mjs';1617const META_FILE = 'meta.json';18const ACTIVITY_FILE = 'activity.jsonl';19const ACTIVITY_MAX_READ = 500;2021/**22 * Repo metadata store backed by `data/meta.json`.23 * Shape: { version: 1, repos: { [name]: { description, topics, homepage, pinned, created, defaultBranch } } }24 */25export class MetaStore {26 /** @param {string} dataDir */27 constructor(dataDir) {28 this.dataDir = dataDir;29 this.path = join(dataDir, META_FILE);30 }3132 /** @returns {{version: number, repos: Record<string, object>}} */33 load() {34 if (!existsSync(this.path)) return { version: 1, repos: {} };35 try {36 const parsed = JSON.parse(readFileSync(this.path, 'utf8'));37 if (parsed && typeof parsed.repos === 'object') return parsed;38 } catch {39 /* corrupted meta falls back to empty — bare repos remain the truth */40 }41 return { version: 1, repos: {} };42 }4344 /** @param {object} meta */45 save(meta) {46 atomicWriteJSON(this.path, meta);47 }4849 /**50 * @param {string} name51 * @returns {object|null}52 */53 get(name) {54 return this.load().repos[name] ?? null;55 }5657 /**58 * Create or merge a repo entry.59 * @param {string} name60 * @param {object} fields61 * @returns {object} the updated entry62 */63 upsert(name, fields = {}) {64 const meta = this.load();65 const existing = meta.repos[name] ?? {66 description: '',67 topics: [],68 homepage: '',69 pinned: false,70 created: new Date().toISOString(),71 defaultBranch: 'main',72 };73 meta.repos[name] = { ...existing, ...fields };74 this.save(meta);75 return meta.repos[name];76 }7778 /** @param {string} name */79 remove(name) {80 const meta = this.load();81 delete meta.repos[name];82 this.save(meta);83 }84}8586/**87 * Append-only push activity feed backed by `data/activity.jsonl`.88 */89export class ActivityFeed {90 /** @param {string} dataDir */91 constructor(dataDir) {92 this.path = join(dataDir, ACTIVITY_FILE);93 this.dataDir = dataDir;94 }9596 /**97 * @param {{repo: string, ref: string, commits: number, sha: string}} event98 */99 append(event) {100 mkdirSync(this.dataDir, { recursive: true });101 const record = { ...event, at: new Date().toISOString() };102 appendFileSync(this.path, JSON.stringify(record) + '\n');103 }104105 /**106 * @param {number} [limit]107 * @returns {object[]} newest-first events108 */109 recent(limit = 15) {110 if (!existsSync(this.path)) return [];111 const lines = readFileSync(this.path, 'utf8').trim().split('\n');112 const slice = lines.slice(-ACTIVITY_MAX_READ);113 const events = [];114 for (const line of slice) {115 try {116 events.push(JSON.parse(line));117 } catch {118 /* skip torn line */119 }120 }121 return events.reverse().slice(0, limit);122 }123}124