releases.ts

6.7 KB · raw

1import type { Database } from "bun:sqlite";2import type { Bucket } from "../bucket/reader.ts";3import { type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";4import { releaseRecords } from "../mirror/queries.ts";5import { forgetRelease } from "../mirror/store.ts";67/** A release as the public sees it: its notes are the operator's override when one is set (§24). */8export type Release = {9	version: string;10	save: string;11	publishedAt: number;12	notes: string;13	files: ReleaseFile[];14};1516type ReleaseSetting = { version: string; hidden: number; notes: string | null };1718/**19 * A repo's releases without the hidden ones, newest first by numeric dot-separated comparison (the20 * rule `koh update` uses, §12). Files are ordered by platform name, as Kepr 2 listed them.21 */22export function visibleReleases(kepr: Database, mirror: Database, repo: string): Release[] {23	const settings = new Map(24		kepr25			.query<ReleaseSetting, [string]>(26				"SELECT version, hidden, notes FROM release_settings WHERE repo = ?",27			)28			.all(repo)29			.map((row) => [row.version, row]),30	);31	return releaseRecords(mirror)32		.filter((record) => settings.get(record.version)?.hidden !== 1)33		.map((record) => ({34			version: record.version,35			save: record.save,36			publishedAt: record.published_at,37			notes: settings.get(record.version)?.notes ?? record.notes,38			files: record.files.toSorted((a, b) => (a.platform < b.platform ? -1 : 1)),39		}))40		.sort((a, b) => compareVersions(b.version, a.version));41}4243export function compareVersions(a: string, b: string): number {44	const left = a.split(".").map(digits);45	const right = b.split(".").map(digits);46	for (let i = 0; i < Math.max(left.length, right.length); i++) {47		const [x, y] = [left[i] ?? "0", right[i] ?? "0"];48		// Compared as digit strings, never as floats, so long components keep their precision.49		if (x.length !== y.length) return x.length - y.length;50		if (x !== y) return x < y ? -1 : 1;51	}52	// Numerically equal (`1.0` and `1.0.0`): compare the text, so the order is still total.53	return a < b ? -1 : a > b ? 1 : 0;54}5556// A component's leading digits without leading zeros ("0" when it has none), as parseInt reads it.57function digits(part: string): string {58	return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, "");59}6061/** §22.2's content types for a release file. */62export function releaseContentType(name: string): string {63	const lower = name.toLowerCase();64	if (lower.endsWith(".apk")) return "application/vnd.android.package-archive";65	if (lower.endsWith(".zip")) return "application/zip";66	return "application/octet-stream";67}6869export function countDownload(70	kepr: Database,71	repo: string,72	version: string,73	platform: string,74): void {75	kepr76		.query(77			`INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)78			ON CONFLICT DO UPDATE SET count = count + 1`,79		)80		.run(repo, version, platform);81}8283/** A Nix tarball fetch (§22.2). */84export function countInstall(kepr: Database, repo: string): void {85	kepr86		.query(87			"INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",88		)89		.run(repo);90}9192/** Download counts of a repo's files, keyed `<version>/<platform>`. */93export function downloadCounts(kepr: Database, repo: string): Map<string, number> {94	const rows = kepr95		.query<{ version: string; platform: string; count: number }, [string]>(96			"SELECT version, platform, count FROM downloads WHERE repo = ?",97		)98		.all(repo);99	return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));100}101102/** Downloads of every visible release of every public repo: the profile's count (§21.2). */103export function publicDownloadCount(kepr: Database): number {104	return (105		kepr106			.query<{ n: number }, []>(107				`SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d108				JOIN repos r ON r.name = d.repo AND r.public = 1109				LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version110				WHERE COALESCE(s.hidden, 0) = 0`,111			)112			.get()?.n ?? 0113	);114}115116/** A release as the operator sees it (§24): hidden ones too, and the record's own notes. */117export type OperatorRelease = {118	version: string;119	recordNotes: string;120	notes: string;121	hidden: boolean;122	downloads: [platform: string, count: number][];123};124125export function operatorReleases(126	kepr: Database,127	mirror: Database,128	repo: string,129): OperatorRelease[] {130	const counts = downloadCounts(kepr, repo);131	const settings = kepr132		.query<ReleaseSetting, [string]>(133			"SELECT version, hidden, notes FROM release_settings WHERE repo = ?",134		)135		.all(repo);136	return releaseRecords(mirror)137		.map((record) => {138			const setting = settings.find((row) => row.version === record.version);139			return {140				version: record.version,141				recordNotes: record.notes,142				notes: setting?.notes ?? record.notes,143				hidden: setting?.hidden === 1,144				downloads: record.files.map((file): [string, number] => [145					file.platform,146					counts.get(`${record.version}/${file.platform}`) ?? 0,147				]),148			};149		})150		.sort((a, b) => compareVersions(b.version, a.version));151}152153/** Hides or shows a version and sets its displayed notes; null notes fall back to the record's. */154export function setRelease(155	kepr: Database,156	repo: string,157	version: string,158	hidden: boolean,159	notes: string | null,160): void {161	kepr162		.query(163			`INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)164			ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,165		)166		.run(repo, version, hidden ? 1 : 0, notes);167}168169/**170 * Deletes a build from the bucket (June, 2026-09-30), since Koh has no way to: its record first,171 * so the version stops existing (§11.2) even if a later delete fails, then its files. When172 * `releases/latest` named it, latest moves to the newest version left, or goes with the last one.173 * Kepr's own settings and counts for it go too.174 */175export async function deleteRelease(176	bucket: Bucket,177	kepr: Database,178	mirror: Database,179	repo: string,180	version: string,181): Promise<void> {182	const prefix = `${repo}/releases/${version}/`;183	await bucket.remove(`${prefix}release.json`);184	for (const key of await bucket.list(prefix)) await bucket.remove(key);185	if ((await readReleasesLatest(bucket, repo)) === version) {186		const newest = releaseRecords(mirror)187			.map((record) => record.version)188			.filter((other) => other !== version)189			.sort((a, b) => compareVersions(b, a))[0];190		const latest = `${repo}/releases/latest`;191		if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");192		else await bucket.remove(latest);193	}194	forgetRelease(mirror, version);195	kepr.transaction(() => {196		for (const table of ["release_settings", "downloads"]) {197			// `table` is one of the two names above, never input.198			kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);199		}200	})();201}