releases.ts

10.9 KB · raw

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";1112/** A release as the public sees it: its notes are the operator's override when one is set (§24). */13export 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 };2223/**24 * A repo's releases without the hidden ones, newest first by numeric dot-separated comparison (the25 * rule `koh update` uses, §12). Files are ordered by platform name, as Kepr 2 listed them.26 */27export 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		// Compared as digit strings, never as floats, so long components keep their precision.54		if (x.length !== y.length) return x.length - y.length;55		if (x !== y) return x < y ? -1 : 1;56	}57	// Numerically equal (`1.0` and `1.0.0`): compare the text, so the order is still total.58	return a < b ? -1 : a > b ? 1 : 0;59}6061// A component's leading digits without leading zeros ("0" when it has none), as parseInt reads it.62function digits(part: string): string {63	return (/^\d+/.exec(part)?.[0] ?? "0").replace(/^0+(?=\d)/, "");64}6566/** §22.2's content types for a release file. */67export 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;7677/** A UTC day as `download_days` keys it. */78export 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}103104/** Downloads per UTC day from `since` on, for one repo or all of them (§24). */105export 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}116117/** Each repo's downloads ever and since a day, most downloaded first. */118export 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}131132/** The most downloaded files across every repo. */133export 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}143144/** A Nix tarball fetch (§22.2). */145export 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}152153/** Download counts of a repo's files, keyed `<version>/<platform>`. */154export 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}162163/** Downloads of every visible release of every public repo: the profile's count (§21.2). */164export 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}176177/** A release as the operator sees it (§24): hidden ones too, and the record's own notes. */178export 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}213214/** Hides or shows a version and sets its displayed notes; null notes fall back to the record's. */215export 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}229230// Deletes run one at a time, so two can't each move `latest` from a reading the other outdated.231let deleting: Promise<unknown> = Promise.resolve();232233/**234 * Deletes a build from the bucket (June, 2026-09-30), since Koh has no way to. `releases/latest`235 * moves first, to the newest other version with a record (or goes with the last), then the files,236 * then the record: until the record goes the version still exists (§11.2), so a failure part-way237 * leaves it whole and deleting it again finishes the job. Kepr's settings and counts for it go too.238 */239export 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		// ponytail: an unconditional write, as the reader has no put_if_match; a Koh publish landing264		// between the read above and this write loses `latest` until it offers again.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			// `table` is one of the two names above, never input.278			kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);279		}280	})();281}282283/** Why a single file can't be deleted: the last one goes with its version, never alone. */284export class LastReleaseFile extends Error {}285286/**287 * Deletes one file of a build (2026-09-30). The record is rewritten without it first, so a failure288 * part-way leaves at most a file nothing lists; its count goes too. Runs in the same queue as whole289 * deletes, so neither reads a record the other is rewriting.290 */291export 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	// Rewritten from the raw JSON, so fields Kepr doesn't read survive the edit.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}