releases.ts

3.8 KB · raw

1import type { Database } from "bun:sqlite";2import type { ReleaseFile } from "../bucket/repo.ts";3import { releaseRecords } from "../mirror/queries.ts";45/** A release as the public sees it: its notes are the operator's override when one is set (§24). */6export type Release = {7	version: string;8	save: string;9	publishedAt: number;10	notes: string;11	files: ReleaseFile[];12};1314type ReleaseSetting = { version: string; hidden: number; notes: string | null };1516/**17 * A repo's releases without the hidden ones, newest first by numeric dot-separated comparison (the18 * rule `koh update` uses, §12). Files are ordered by platform name, as Kepr 2 listed them.19 */20export function visibleReleases(kepr: Database, mirror: Database, repo: string): Release[] {21	const settings = new Map(22		kepr23			.query<ReleaseSetting, [string]>(24				"SELECT version, hidden, notes FROM release_settings WHERE repo = ?",25			)26			.all(repo)27			.map((row) => [row.version, row]),28	);29	return releaseRecords(mirror)30		.filter((record) => settings.get(record.version)?.hidden !== 1)31		.map((record) => ({32			version: record.version,33			save: record.save,34			publishedAt: record.published_at,35			notes: settings.get(record.version)?.notes ?? record.notes,36			files: record.files.toSorted((a, b) => (a.platform < b.platform ? -1 : 1)),37		}))38		.sort((a, b) => compareVersions(b.version, a.version));39}4041export function compareVersions(a: string, b: string): number {42	const left = a.split(".").map(digits);43	const right = b.split(".").map(digits);44	for (let i = 0; i < Math.max(left.length, right.length); i++) {45		const [x, y] = [left[i] ?? "0", right[i] ?? "0"];46		// Compared as digit strings, never as floats, so long components keep their precision.47		if (x.length !== y.length) return x.length - y.length;48		if (x !== y) return x < y ? -1 : 1;49	}50	// Numerically equal (`1.0` and `1.0.0`): compare the text, so the order is still total.51	return a < b ? -1 : a > b ? 1 : 0;52}5354// A component's leading digits without leading zeros ("0" when it has none), as parseInt reads it.55function digits(part: string): string {56	return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, "");57}5859/** §22.2's content types for a release file. */60export function releaseContentType(name: string): string {61	const lower = name.toLowerCase();62	if (lower.endsWith(".apk")) return "application/vnd.android.package-archive";63	if (lower.endsWith(".zip")) return "application/zip";64	return "application/octet-stream";65}6667export function countDownload(68	kepr: Database,69	repo: string,70	version: string,71	platform: string,72): void {73	kepr74		.query(75			`INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)76			ON CONFLICT DO UPDATE SET count = count + 1`,77		)78		.run(repo, version, platform);79}8081/** A Nix tarball fetch (§22.2). */82export function countInstall(kepr: Database, repo: string): void {83	kepr84		.query(85			"INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",86		)87		.run(repo);88}8990/** Download counts of a repo's files, keyed `<version>/<platform>`. */91export function downloadCounts(kepr: Database, repo: string): Map<string, number> {92	const rows = kepr93		.query<{ version: string; platform: string; count: number }, [string]>(94			"SELECT version, platform, count FROM downloads WHERE repo = ?",95		)96		.all(repo);97	return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));98}99100/** Downloads of every visible release of every public repo: the profile's count (§21.2). */101export function publicDownloadCount(kepr: Database): number {102	return (103		kepr104			.query<{ n: number }, []>(105				`SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d106				JOIN repos r ON r.name = d.repo AND r.public = 1107				LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version108				WHERE COALESCE(s.hidden, 0) = 0`,109			)110			.get()?.n ?? 0111	);112}