releases.ts

9.2 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}7374export function countDownload(75	kepr: Database,76	repo: string,77	version: string,78	platform: string,79): void {80	kepr81		.query(82			`INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)83			ON CONFLICT DO UPDATE SET count = count + 1`,84		)85		.run(repo, version, platform);86}8788/** A Nix tarball fetch (§22.2). */89export function countInstall(kepr: Database, repo: string): void {90	kepr91		.query(92			"INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",93		)94		.run(repo);95}9697/** Download counts of a repo's files, keyed `<version>/<platform>`. */98export function downloadCounts(kepr: Database, repo: string): Map<string, number> {99	const rows = kepr100		.query<{ version: string; platform: string; count: number }, [string]>(101			"SELECT version, platform, count FROM downloads WHERE repo = ?",102		)103		.all(repo);104	return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));105}106107/** Downloads of every visible release of every public repo: the profile's count (§21.2). */108export function publicDownloadCount(kepr: Database): number {109	return (110		kepr111			.query<{ n: number }, []>(112				`SELECT COALESCE(SUM(d.count), 0) AS n FROM downloads d113				JOIN repos r ON r.name = d.repo AND r.public = 1114				LEFT JOIN release_settings s ON s.repo = d.repo AND s.version = d.version115				WHERE COALESCE(s.hidden, 0) = 0`,116			)117			.get()?.n ?? 0118	);119}120121/** A release as the operator sees it (§24): hidden ones too, and the record's own notes. */122export type OperatorRelease = {123	version: string;124	recordNotes: string;125	notes: string;126	hidden: boolean;127	downloads: [platform: string, count: number][];128};129130export function operatorReleases(131	kepr: Database,132	mirror: Database,133	repo: string,134): OperatorRelease[] {135	const counts = downloadCounts(kepr, repo);136	const settings = kepr137		.query<ReleaseSetting, [string]>(138			"SELECT version, hidden, notes FROM release_settings WHERE repo = ?",139		)140		.all(repo);141	return releaseRecords(mirror)142		.map((record) => {143			const setting = settings.find((row) => row.version === record.version);144			return {145				version: record.version,146				recordNotes: record.notes,147				notes: setting?.notes ?? record.notes,148				hidden: setting?.hidden === 1,149				downloads: record.files.map((file): [string, number] => [150					file.platform,151					counts.get(`${record.version}/${file.platform}`) ?? 0,152				]),153			};154		})155		.sort((a, b) => compareVersions(b.version, a.version));156}157158/** Hides or shows a version and sets its displayed notes; null notes fall back to the record's. */159export function setRelease(160	kepr: Database,161	repo: string,162	version: string,163	hidden: boolean,164	notes: string | null,165): void {166	kepr167		.query(168			`INSERT INTO release_settings (repo, version, hidden, notes) VALUES (?, ?, ?, ?)169			ON CONFLICT DO UPDATE SET hidden = excluded.hidden, notes = excluded.notes`,170		)171		.run(repo, version, hidden ? 1 : 0, notes);172}173174// Deletes run one at a time, so two can't each move `latest` from a reading the other outdated.175let deleting: Promise<unknown> = Promise.resolve();176177/**178 * Deletes a build from the bucket (June, 2026-09-30), since Koh has no way to. `releases/latest`179 * moves first, to the newest other version with a record (or goes with the last), then the files,180 * then the record: until the record goes the version still exists (§11.2), so a failure part-way181 * leaves it whole and deleting it again finishes the job. Kepr's settings and counts for it go too.182 */183export function deleteRelease(184	bucket: Bucket,185	kepr: Database,186	mirror: Database,187	repo: string,188	version: string,189): Promise<void> {190	const run = deleting.then(() => removeRelease(bucket, kepr, mirror, repo, version));191	deleting = run.catch(() => {});192	return run;193}194195async function removeRelease(196	bucket: Bucket,197	kepr: Database,198	mirror: Database,199	repo: string,200	version: string,201): Promise<void> {202	if ((await readReleasesLatest(bucket, repo)) === version) {203		const newest = (await listReleaseVersions(bucket, repo))204			.filter((other) => other !== version)205			.sort((a, b) => compareVersions(b, a))[0];206		const latest = `${repo}/releases/latest`;207		// ponytail: an unconditional write, as the reader has no put_if_match; a Koh publish landing208		// between the read above and this write loses `latest` until it offers again.209		if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");210		else await bucket.remove(latest);211	}212	const prefix = `${repo}/releases/${version}/`;213	const record = `${prefix}release.json`;214	for (const key of await bucket.list(prefix)) {215		if (key !== record) await bucket.remove(key);216	}217	await bucket.remove(record);218	forgetRelease(mirror, version);219	kepr.transaction(() => {220		for (const table of ["release_settings", "downloads"]) {221			// `table` is one of the two names above, never input.222			kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);223		}224	})();225}226227/** Why a single file can't be deleted: the last one goes with its version, never alone. */228export class LastReleaseFile extends Error {}229230/**231 * Deletes one file of a build (2026-09-30). The record is rewritten without it first, so a failure232 * part-way leaves at most a file nothing lists; its count goes too. Runs in the same queue as whole233 * deletes, so neither reads a record the other is rewriting.234 */235export function deleteReleaseFile(236	bucket: Bucket,237	kepr: Database,238	mirror: Database,239	repo: string,240	version: string,241	platform: string,242): Promise<void> {243	const run = deleting.then(() => removeReleaseFile(bucket, kepr, mirror, repo, version, platform));244	deleting = run.catch(() => {});245	return run;246}247248async function removeReleaseFile(249	bucket: Bucket,250	kepr: Database,251	mirror: Database,252	repo: string,253	version: string,254	platform: string,255): Promise<void> {256	const key = `${repo}/releases/${version}/release.json`;257	const bytes = await bucket.get(key);258	if (bytes === null) throw new Error(`${key} is missing`);259	// Rewritten from the raw JSON, so fields Kepr doesn't read survive the edit.260	const raw = JSON.parse(new TextDecoder().decode(bytes)) as { files: ReleaseFile[] };261	const file = raw.files.find((f) => f.platform === platform);262	if (file) {263		if (raw.files.length === 1) throw new LastReleaseFile("delete the version instead");264		raw.files = raw.files.filter((f) => f !== file);265		const json = `${JSON.stringify(raw, null, 2)}\n`;266		await bucket.put(key, new TextEncoder().encode(json), "application/json");267		replaceRelease(mirror, checkRelease(repo, version, raw));268		await bucket.remove(`${repo}/releases/${version}/${platform}/${file.name}`);269	}270	kepr271		.query("DELETE FROM downloads WHERE repo = ? AND version = ? AND platform = ?")272		.run(repo, version, platform);273}