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}7374const DAYS_KEPT = 400;75const DAY_MS = 24 * 60 * 60 * 1000;767778export const utcDay = (ms: number): string => new Date(ms).toISOString().slice(0, 10);7980export function countDownload(81 kepr: Database,82 repo: string,83 version: string,84 platform: string,85): void {86 const now = Date.now();87 kepr.transaction(() => {88 kepr89 .query(90 `INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)91 ON CONFLICT DO UPDATE SET count = count + 1`,92 )93 .run(repo, version, platform);94 kepr95 .query(96 `INSERT INTO download_days (repo, day, count) VALUES (?, ?, 1)97 ON CONFLICT DO UPDATE SET count = count + 1`,98 )99 .run(repo, utcDay(now));100 kepr.query("DELETE FROM download_days WHERE day < ?").run(utcDay(now - DAYS_KEPT * DAY_MS));101 })();102}103104105export function downloadsByDay(kepr: Database, since: string, repo: string): Map<string, number> {106 const rows = kepr107 .query<{ day: string; n: number }, [string, string, string]>(108 `SELECT day, SUM(count) AS n109 FROM download_days110 WHERE day >= ? AND (? = '' OR repo = ?)111 GROUP BY day`,112 )113 .all(since, repo, repo);114 return new Map(rows.map((row) => [row.day, row.n]));115}116117118export function repoDownloads(kepr: Database, since: string) {119 return kepr120 .query<{ repo: string; total: number; recent: number }, [string]>(121 `SELECT d.repo,122 SUM(d.count) AS total,123 COALESCE((SELECT SUM(count) FROM download_days WHERE repo = d.repo AND day >= ?), 0)124 AS recent125 FROM downloads d126 GROUP BY d.repo127 ORDER BY total DESC, d.repo`,128 )129 .all(since);130}131132133export function topFiles(kepr: Database, limit: number) {134 return kepr135 .query<{ repo: string; version: string; platform: string; count: number }, [number]>(136 `SELECT repo, version, platform, count137 FROM downloads138 ORDER BY count DESC, repo, version, platform139 LIMIT ?`,140 )141 .all(limit);142}143144145export function countInstall(kepr: Database, repo: string): void {146 kepr147 .query(148 "INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",149 )150 .run(repo);151}152153154export function downloadCounts(kepr: Database, repo: string): Map<string, number> {155 const rows = kepr156 .query<{ version: string; platform: string; count: number }, [string]>(157 "SELECT version, platform, count FROM downloads WHERE repo = ?",158 )159 .all(repo);160 return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));161}162163164export function publicDownloadCount(kepr: Database): number {165 return (166 kepr167 .query<{ n: number }, []>(168 `SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d169 JOIN repos r ON r.name = d.repo AND r.public = 1170 LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version171 WHERE COALESCE(s.hidden, 0) = 0`,172 )173 .get()?.n ?? 0174 );175}176177178export type OperatorRelease = {179 version: string;180 recordNotes: string;181 notes: string;182 hidden: boolean;183 downloads: [platform: string, count: number][];184};185186export function operatorReleases(187 kepr: Database,188 mirror: Database,189 repo: string,190): OperatorRelease[] {191 const counts = downloadCounts(kepr, repo);192 const settings = kepr193 .query<ReleaseSetting, [string]>(194 "SELECT version, hidden, notes FROM release_settings WHERE repo = ?",195 )196 .all(repo);197 return releaseRecords(mirror)198 .map((record) => {199 const setting = settings.find((row) => row.version === record.version);200 return {201 version: record.version,202 recordNotes: record.notes,203 notes: setting?.notes ?? record.notes,204 hidden: setting?.hidden === 1,205 downloads: record.files.map((file): [string, number] => [206 file.platform,207 counts.get(`${record.version}/${file.platform}`) ?? 0,208 ]),209 };210 })211 .sort((a, b) => compareVersions(b.version, a.version));212}213214215export function setRelease(216 kepr: Database,217 repo: string,218 version: string,219 hidden: boolean,220 notes: string | null,221): void {222 kepr223 .query(224 `INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)225 ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,226 )227 .run(repo, version, hidden ? 1 : 0, notes);228}229230231let deleting: Promise<unknown> = Promise.resolve();232233234235236237238239export function deleteRelease(240 bucket: Bucket,241 kepr: Database,242 mirror: Database,243 repo: string,244 version: string,245): Promise<void> {246 const run = deleting.then(() => removeRelease(bucket, kepr, mirror, repo, version));247 deleting = run.catch(() => {});248 return run;249}250251async function removeRelease(252 bucket: Bucket,253 kepr: Database,254 mirror: Database,255 repo: string,256 version: string,257): Promise<void> {258 if ((await readReleasesLatest(bucket, repo)) === version) {259 const newest = (await listReleaseVersions(bucket, repo))260 .filter((other) => other !== version)261 .sort((a, b) => compareVersions(b, a))[0];262 const latest = `${repo}/releases/latest`;263 264 265 if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");266 else await bucket.remove(latest);267 }268 const prefix = `${repo}/releases/${version}/`;269 const record = `${prefix}release.json`;270 for (const key of await bucket.list(prefix)) {271 if (key !== record) await bucket.remove(key);272 }273 await bucket.remove(record);274 forgetRelease(mirror, version);275 kepr.transaction(() => {276 for (const table of ["release_settings", "downloads"]) {277 278 kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);279 }280 })();281}282283284export class LastReleaseFile extends Error {}285286287288289290291export function deleteReleaseFile(292 bucket: Bucket,293 kepr: Database,294 mirror: Database,295 repo: string,296 version: string,297 platform: string,298): Promise<void> {299 const run = deleting.then(() => removeReleaseFile(bucket, kepr, mirror, repo, version, platform));300 deleting = run.catch(() => {});301 return run;302}303304async function removeReleaseFile(305 bucket: Bucket,306 kepr: Database,307 mirror: Database,308 repo: string,309 version: string,310 platform: string,311): Promise<void> {312 const key = `${repo}/releases/${version}/release.json`;313 const bytes = await bucket.get(key);314 if (bytes === null) throw new Error(`${key} is missing`);315 316 const raw = JSON.parse(new TextDecoder().decode(bytes)) as { files: ReleaseFile[] };317 const file = raw.files.find((f) => f.platform === platform);318 if (file) {319 if (raw.files.length === 1) throw new LastReleaseFile("delete the version instead");320 raw.files = raw.files.filter((f) => f !== file);321 const json = `${JSON.stringify(raw, null, 2)}\n`;322 await bucket.put(key, new TextEncoder().encode(json), "application/json");323 replaceRelease(mirror, checkRelease(repo, version, raw));324 await bucket.remove(`${repo}/releases/${version}/${platform}/${file.name}`);325 }326 kepr327 .query("DELETE FROM downloads WHERE repo = ? AND version = ? AND platform = ?")328 .run(repo, version, platform);329}