/** * Git-backed plugin storage collection: each entry is a JSON file at * `content///.json` in the site's git repo, written * through the GitHub Contents API with the site's connection — so a plugin's * definitions (a form's fields, say) are versioned with the site and readable * by the static frontend build, while high-churn data (submissions) stays in * the database. Queries are answered in memory over the directory listing; * these collections are small by design. * * Unavailable until GitHub is connected: every call throws `GitStoreError` * with code NOT_CONNECTED, which routes surface as a 409. */ import type { Kysely } from "kysely"; import { GitStoreError, gitConnection, type GitRepoConnection } from "../content/git-store.js"; import type { Database } from "../database/types.js"; import type { PaginatedResult, QueryOptions, StorageCollection, WhereClause, WhereValue, } from "./types.js"; const GH = "https://api.github.com"; const B64_NEWLINES = /\n/g; const SAFE_ID = /^[A-Za-z0-9_.-]{1,120}$/; interface GhFile { name: string; sha: string; type: string; content?: string; } function b64encode(text: string): string { return btoa(String.fromCharCode(...new TextEncoder().encode(text))); } function b64decode(b64: string): string { const bin = atob(b64.replace(B64_NEWLINES, "")); return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))); } function matches(value: unknown, want: WhereValue): boolean { if (want === null || typeof want !== "object") return value === want; if ("in" in want) return want.in.includes(value as string | number); if ("startsWith" in want) return typeof value === "string" && value.startsWith(want.startsWith); const v = value as number | string; if (want.gt !== undefined && !(v > want.gt)) return false; if (want.gte !== undefined && !(v >= want.gte)) return false; if (want.lt !== undefined && !(v < want.lt)) return false; if (want.lte !== undefined && !(v <= want.lte)) return false; return true; } export class GitStorageCollection implements StorageCollection { private cache: { at: number; items: Array<{ id: string; data: T; sha: string }> } | null = null; constructor( private readonly db: Kysely, private readonly pluginId: string, private readonly collection: string, ) {} private get dir(): string { return `content/${this.pluginId}/${this.collection}`; } private async conn(): Promise { const c = await gitConnection(this.db); if (!c) { throw new GitStoreError( `"${this.collection}" is stored in git — connect GitHub in Settings → General first.`, "NOT_CONNECTED", 409, ); } return c; } private async gh( method: string, path: string, body?: unknown, ): Promise<{ ok: boolean; status: number; json: R | null }> { const c = await this.conn(); const res = await fetch(`${GH}/repos/${c.owner}/${c.repo}${path}`, { method, headers: { Authorization: `Bearer ${c.token}`, Accept: "application/vnd.github+json", "User-Agent": "premium-cms", "X-GitHub-Api-Version": "2022-11-28", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body === undefined ? undefined : JSON.stringify({ branch: c.branch, ...(body as object) }), }); let json: R | null = null; try { json = (await res.json()) as R; } catch { json = null; } if (res.status === 401 || res.status === 403) { throw new GitStoreError("GitHub rejected the site's token — reconnect GitHub.", "GITHUB", 502); } return { ok: res.ok, status: res.status, json }; } private assertId(id: string): void { if (!SAFE_ID.test(id)) throw new GitStoreError(`Invalid id "${id}"`, "GITHUB", 400); } private async readOne(id: string): Promise<{ data: T; sha: string } | null> { this.assertId(id); const c = await this.conn(); const r = await this.gh("GET", `/contents/${this.dir}/${id}.json?ref=${encodeURIComponent(c.branch)}`); if (r.status === 404 || !r.json?.content) return null; try { return { data: JSON.parse(b64decode(r.json.content)) as T, sha: r.json.sha }; } catch { return null; } } private async readAll(): Promise> { if (this.cache && Date.now() - this.cache.at < 2000) return this.cache.items; const c = await this.conn(); const r = await this.gh("GET", `/contents/${this.dir}?ref=${encodeURIComponent(c.branch)}`); const items: Array<{ id: string; data: T; sha: string }> = []; if (r.status !== 404 && Array.isArray(r.json)) { for (const f of r.json) { if (f.type !== "file" || !f.name.endsWith(".json")) continue; const id = f.name.slice(0, -5); const one = await this.readOne(id); if (one) items.push({ id, ...one }); } } this.cache = { at: Date.now(), items }; return items; } async get(id: string): Promise { return (await this.readOne(id))?.data ?? null; } async put(id: string, data: T): Promise { this.assertId(id); const existing = await this.readOne(id); const r = await this.gh<{ message?: string }>("PUT", `/contents/${this.dir}/${id}.json`, { message: `${this.pluginId}(${this.collection}): ${existing ? "update" : "add"} ${id}`, content: b64encode(`${JSON.stringify(data, null, "\t")}\n`), ...(existing ? { sha: existing.sha } : {}), }); this.cache = null; if (r.status === 409 || r.status === 422) throw new GitStoreError("The file changed in git since it was loaded — reload and try again.", "CONFLICT", 409); if (!r.ok) throw new GitStoreError(r.json?.message || `GitHub ${r.status}`, "GITHUB", 502); } async delete(id: string): Promise { const existing = await this.readOne(id); if (!existing) return false; const r = await this.gh<{ message?: string }>("DELETE", `/contents/${this.dir}/${id}.json`, { message: `${this.pluginId}(${this.collection}): remove ${id}`, sha: existing.sha, }); this.cache = null; if (!r.ok) throw new GitStoreError(r.json?.message || `GitHub ${r.status}`, "GITHUB", 502); return true; } async exists(id: string): Promise { return (await this.readOne(id)) !== null; } async getMany(ids: string[]): Promise> { const out = new Map(); for (const id of ids) { const one = await this.readOne(id); if (one) out.set(id, one.data); } return out; } async putMany(items: Array<{ id: string; data: T }>): Promise { for (const it of items) await this.put(it.id, it.data); } async deleteMany(ids: string[]): Promise { let n = 0; for (const id of ids) if (await this.delete(id)) n++; return n; } private filter(items: Array<{ id: string; data: T }>, where?: WhereClause) { if (!where) return items; return items.filter((it) => Object.entries(where).every(([k, want]) => matches((it.data as Record)[k], want), ), ); } async query(options?: QueryOptions): Promise> { let items = this.filter(await this.readAll(), options?.where); const [field, dir] = Object.entries(options?.orderBy ?? {})[0] ?? []; if (field) { items = items.toSorted((a, b) => { const av = (a.data as Record)[field] as string | number; const bv = (b.data as Record)[field] as string | number; const cmp = av === bv ? 0 : av > bv ? 1 : -1; return dir === "desc" ? -cmp : cmp; }); } const limit = Math.min(Math.max(options?.limit ?? 50, 1), 1000); const offset = options?.cursor ? Number(options.cursor) || 0 : 0; const page = items.slice(offset, offset + limit); const hasMore = offset + limit < items.length; return { items: page.map(({ id, data }) => ({ id, data })), cursor: hasMore ? String(offset + limit) : undefined, hasMore, }; } async count(where?: WhereClause): Promise { return this.filter(await this.readAll(), where).length; } }