SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
3.5 KB · 124 lines javascript
Raw Blame History
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