releases.ts

5.3 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}113114/** A release as the operator sees it (§24): hidden ones too, and the record's own notes. */115export type OperatorRelease = {116	version: string;117	recordNotes: string;118	notes: string;119	hidden: boolean;120	downloads: [platform: string, count: number][];121};122123export function operatorReleases(124	kepr: Database,125	mirror: Database,126	repo: string,127): OperatorRelease[] {128	const counts = downloadCounts(kepr, repo);129	const settings = kepr130		.query<ReleaseSetting, [string]>(131			"SELECT version, hidden, notes FROM release_settings WHERE repo = ?",132		)133		.all(repo);134	return releaseRecords(mirror)135		.map((record) => {136			const setting = settings.find((row) => row.version === record.version);137			return {138				version: record.version,139				recordNotes: record.notes,140				notes: setting?.notes ?? record.notes,141				hidden: setting?.hidden === 1,142				downloads: record.files.map((file): [string, number] => [143					file.platform,144					counts.get(`${record.version}/${file.platform}`) ?? 0,145				]),146			};147		})148		.sort((a, b) => compareVersions(b.version, a.version));149}150151/** Hides or shows a version and sets its displayed notes; null notes fall back to the record's. */152export function setRelease(153	kepr: Database,154	repo: string,155	version: string,156	hidden: boolean,157	notes: string | null,158): void {159	kepr160		.query(161			`INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)162			ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,163		)164		.run(repo, version, hidden ? 1 : 0, notes);165}