1import type { Database } from "bun:sqlite";2import type { Bucket } from "../bucket/reader.ts";3import {4 checkRelease,5 listReleaseVersions,6 type ReleaseFile,7 readReleasesLatest,8} from "../bucket/repo.ts";9import { releaseRecords } from "../mirror/queries.ts";10import { forgetRelease, replaceRelease } from "../mirror/store.ts";111213export type Release = {14 version: string;15 save: string;16 publishedAt: number;17 notes: string;18 files: ReleaseFile[];19};2021type ReleaseSetting = { version: string; hidden: number; notes: string | null };222324252627export function visibleReleases(kepr: Database, mirror: Database, repo: string): Release[] {28 const settings = new Map(29 kepr30 .query<ReleaseSetting, [string]>(31 "SELECT version, hidden, notes FROM release_settings WHERE repo = ?",32 )33 .all(repo)34 .map((row) => [row.version, row]),35 );36 return releaseRecords(mirror)37 .filter((record) => settings.get(record.version)?.hidden !== 1)38 .map((record) => ({39 version: record.version,40 save: record.save,41 publishedAt: record.published_at,42 notes: settings.get(record.version)?.notes ?? record.notes,43 files: record.files.toSorted((a, b) => (a.platform < b.platform ? -1 : 1)),44 }))45 .sort((a, b) => compareVersions(b.version, a.version));46}4748export function compareVersions(a: string, b: string): number {49 const left = a.split(".").map(digits);50 const right = b.split(".").map(digits);51 for (let i = 0; i < Math.max(left.length, right.length); i++) {52 const [x, y] = [left[i] ?? "0", right[i] ?? "0"];53 54 if (x.length !== y.length) return x.length - y.length;55 if (x !== y) return x < y ? -1 : 1;56 }57 58 return a < b ? -1 : a > b ? 1 : 0;59}606162function digits(part: string): string {63 return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, "");64}656667export function releaseContentType(name: string): string {68 const lower = name.toLowerCase();69 if (lower.endsWith(".apk")) return "application/vnd.android.package-archive";70 if (lower.endsWith(".zip")) return "application/zip";71 return "application/octet-stream";72}7374export function countDownload(75 kepr: Database,76 repo: string,77 version: string,78 platform: string,79): void {80 kepr81 .query(82 `INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)83 ON CONFLICT DO UPDATE SET count = count + 1`,84 )85 .run(repo, version, platform);86}878889export function countInstall(kepr: Database, repo: string): void {90 kepr91 .query(92 "INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",93 )94 .run(repo);95}969798export function downloadCounts(kepr: Database, repo: string): Map<string, number> {99 const rows = kepr100 .query<{ version: string; platform: string; count: number }, [string]>(101 "SELECT version, platform, count FROM downloads WHERE repo = ?",102 )103 .all(repo);104 return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));105}106107108export function publicDownloadCount(kepr: Database): number {109 return (110 kepr111 .query<{ n: number }, []>(112 `SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d113 JOIN repos r ON r.name = d.repo AND r.public = 1114 LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version115 WHERE COALESCE(s.hidden, 0) = 0`,116 )117 .get()?.n ?? 0118 );119}120121122export type OperatorRelease = {123 version: string;124 recordNotes: string;125 notes: string;126 hidden: boolean;127 downloads: [platform: string, count: number][];128};129130export function operatorReleases(131 kepr: Database,132 mirror: Database,133 repo: string,134): OperatorRelease[] {135 const counts = downloadCounts(kepr, repo);136 const settings = kepr137 .query<ReleaseSetting, [string]>(138 "SELECT version, hidden, notes FROM release_settings WHERE repo = ?",139 )140 .all(repo);141 return releaseRecords(mirror)142 .map((record) => {143 const setting = settings.find((row) => row.version === record.version);144 return {145 version: record.version,146 recordNotes: record.notes,147 notes: setting?.notes ?? record.notes,148 hidden: setting?.hidden === 1,149 downloads: record.files.map((file): [string, number] => [150 file.platform,151 counts.get(`${record.version}/${file.platform}`) ?? 0,152 ]),153 };154 })155 .sort((a, b) => compareVersions(b.version, a.version));156}157158159export function setRelease(160 kepr: Database,161 repo: string,162 version: string,163 hidden: boolean,164 notes: string | null,165): void {166 kepr167 .query(168 `INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)169 ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,170 )171 .run(repo, version, hidden ? 1 : 0, notes);172}173174175let deleting: Promise<unknown> = Promise.resolve();176177178179180181182183export function deleteRelease(184 bucket: Bucket,185 kepr: Database,186 mirror: Database,187 repo: string,188 version: string,189): Promise<void> {190 const run = deleting.then(() => removeRelease(bucket, kepr, mirror, repo, version));191 deleting = run.catch(() => {});192 return run;193}194195async function removeRelease(196 bucket: Bucket,197 kepr: Database,198 mirror: Database,199 repo: string,200 version: string,201): Promise<void> {202 if ((await readReleasesLatest(bucket, repo)) === version) {203 const newest = (await listReleaseVersions(bucket, repo))204 .filter((other) => other !== version)205 .sort((a, b) => compareVersions(b, a))[0];206 const latest = `${repo}/releases/latest`;207 208 209 if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");210 else await bucket.remove(latest);211 }212 const prefix = `${repo}/releases/${version}/`;213 const record = `${prefix}release.json`;214 for (const key of await bucket.list(prefix)) {215 if (key !== record) await bucket.remove(key);216 }217 await bucket.remove(record);218 forgetRelease(mirror, version);219 kepr.transaction(() => {220 for (const table of ["release_settings", "downloads"]) {221 222 kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);223 }224 })();225}226227228export class LastReleaseFile extends Error {}229230231232233234235export function deleteReleaseFile(236 bucket: Bucket,237 kepr: Database,238 mirror: Database,239 repo: string,240 version: string,241 platform: string,242): Promise<void> {243 const run = deleting.then(() => removeReleaseFile(bucket, kepr, mirror, repo, version, platform));244 deleting = run.catch(() => {});245 return run;246}247248async function removeReleaseFile(249 bucket: Bucket,250 kepr: Database,251 mirror: Database,252 repo: string,253 version: string,254 platform: string,255): Promise<void> {256 const key = `${repo}/releases/${version}/release.json`;257 const bytes = await bucket.get(key);258 if (bytes === null) throw new Error(`${key} is missing`);259 260 const raw = JSON.parse(new TextDecoder().decode(bytes)) as { files: ReleaseFile[] };261 const file = raw.files.find((f) => f.platform === platform);262 if (file) {263 if (raw.files.length === 1) throw new LastReleaseFile("delete the version instead");264 raw.files = raw.files.filter((f) => f !== file);265 const json = `${JSON.stringify(raw, null, 2)}\n`;266 await bucket.put(key, new TextEncoder().encode(json), "application/json");267 replaceRelease(mirror, checkRelease(repo, version, raw));268 await bucket.remove(`${repo}/releases/${version}/${platform}/${file.name}`);269 }270 kepr271 .query("DELETE FROM downloads WHERE repo = ? AND version = ? AND platform = ?")272 .run(repo, version, platform);273}