a16dea674709

read release records into the mirror when head or releases/latest moves, and keep hidden releases, notes overrides and download and install counts in kepr.db (v3.0.20)

time
2026-09-26 18:47 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
06b0512cf8ae
10 files package.json+1 −1src/bucket/repo.ts+124 −3src/bucket/sync.ts+27 −3src/mirror/queries.ts+9 −0src/mirror/store.ts+24 −6src/releases/releases.ts+91 −0src/server/db.ts+20 −0tests/fixture.ts+77 −0tests/releases.test.ts+102 −0tests/sync.test.ts+8 −6
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.19",3+	"version": "3.0.20",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/repo.ts+124 −3
@@ -32,15 +32,50 @@3232 	machine: string;3333 };3434 35+/** One built file of a release (§11.2). */36+export type ReleaseFile = {37+	platform: string;38+	name: string;39+	size: number;40+	blake3: string;41+	sha256: string;42+};43+44+/** The release record Koh 3 writes to `<repo>/releases/<version>/release.json` (§11.2). */45+export type ReleaseRecord = {46+	format: 1;47+	repo: string;48+	version: string;49+	save: string;50+	published_at: number;51+	machine: string;52+	notes: string;53+	files: ReleaseFile[];54+};55+56+/** §11.1's platform names, Kepr 2's exact list: Peach Android and existing build files depend on them. */57+export const PLATFORMS = [58+	"linux-x86_64",59+	"linux-aarch64",60+	"macos-aarch64",61+	"macos-x86_64",62+	"windows-x86_64",63+	"android-arm64",64+	"android-x86",65+] as const;66+3567 /** An object's stored zlib bytes and the inflated bytes they were verified from. */3668 export type VerifiedObject = { compressed: Uint8Array; bytes: Uint8Array };3769 3870 export class BucketError extends Error {}3971 4072 const SAVE_ID = /^[0-9a-f]{12}$/;41-const OBJECT_HASH = /^[0-9a-f]{64}$/;4273 const REPO_NAME = /^[A-Za-z0-9._-]+$/;4374 const CONTROL_CHAR = /\p{Cc}/u;75+const HEX64 = /^[0-9a-f]{64}$/;76+const VERSION = /^[A-Za-z0-9.-]{1,32}$/;77+// A file name that is safe as one key segment and inside a quoted Content-Disposition filename.78+const FILE_NAME = /^[^/\\"\p{Cc}]{1,255}$/u;4479 const decoder = new TextDecoder();4580 4681 // ponytail: one full listing per call; switch to a delimiter listing if the bucket grows large.@@ -106,7 +141,7 @@106141 	hash: string,107142 ): Promise<VerifiedObject> {108143 	requireRepo(repo);109-	if (!OBJECT_HASH.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);144+	if (!HEX64.test(hash)) throw new BucketError(`not an object hash: "${hash}"`);110145 	const compressed = await bucket.get(`${repo}/objects/${hash}`);111146 	if (compressed === null) throw new BucketError(`${repo}/objects/${hash} is missing`);112147 	const bytes = new Uint8Array(inflateSync(compressed));@@ -139,6 +174,92 @@139174 	return record;140175 }141176 177+/** §11.1's version rule, minus `.` and `..`, which would name a folder rather than a version. */178+export function isVersion(value: string): boolean {179+	return VERSION.test(value) && value !== "." && value !== "..";180+}181+182+export function isPlatform(value: string): boolean {183+	return (PLATFORMS as readonly string[]).includes(value);184+}185+186+/** The body of `<repo>/releases/latest`, trimmed, or null when the repo has never released. */187+export async function readReleasesLatest(188+	bucket: BucketReader,189+	repo: string,190+): Promise<string | null> {191+	requireRepo(repo);192+	const bytes = await bucket.get(`${repo}/releases/latest`);193+	return bytes === null ? null : decoder.decode(bytes).trim();194+}195+196+/** Every version with a `release.json`: a version without one doesn't exist (§11.2). */197+export async function listReleaseVersions(bucket: BucketReader, repo: string): Promise<string[]> {198+	requireRepo(repo);199+	return (await bucket.list(`${repo}/releases/`))200+		.map((key) => key.split("/"))201+		.filter((parts) => parts.length === 4 && parts[3] === "release.json")202+		.map((parts) => parts[2] as string)203+		.filter(isVersion);204+}205+206+export async function readRelease(207+	bucket: BucketReader,208+	repo: string,209+	version: string,210+): Promise<ReleaseRecord> {211+	requireRepo(repo);212+	if (!isVersion(version)) throw new BucketError(`not a release version: "${version}"`);213+	const key = `${repo}/releases/${version}/release.json`;214+	const bytes = await bucket.get(key);215+	if (bytes === null) throw new BucketError(`${key} is missing`);216+	const record = parseReleaseRecord(JSON.parse(decoder.decode(bytes)));217+	if (record.repo !== repo || record.version !== version) {218+		throw new BucketError(`${key} holds ${record.repo} ${record.version}`);219+	}220+	return record;221+}222+223+function parseReleaseRecord(value: unknown): ReleaseRecord {224+	const r = value as Record<string, unknown>;225+	const valid =226+		typeof value === "object" &&227+		value !== null &&228+		r.format === 1 &&229+		typeof r.repo === "string" &&230+		typeof r.version === "string" &&231+		typeof r.save === "string" &&232+		SAVE_ID.test(r.save) &&233+		isCount(r.published_at) &&234+		typeof r.machine === "string" &&235+		typeof r.notes === "string" &&236+		Array.isArray(r.files) &&237+		r.files.every(isReleaseFile) &&238+		// One file per platform per version (§11.2).239+		new Set(r.files.map((file) => file.platform)).size === r.files.length;240+	if (!valid) throw new BucketError("release record does not match §11.2");241+	return value as ReleaseRecord;242+}243+244+function isReleaseFile(value: unknown): value is ReleaseFile {245+	const f = value as Record<string, unknown>;246+	return (247+		typeof value === "object" &&248+		value !== null &&249+		typeof f.platform === "string" &&250+		isPlatform(f.platform) &&251+		typeof f.name === "string" &&252+		FILE_NAME.test(f.name) &&253+		f.name !== "." &&254+		f.name !== ".." &&255+		isCount(f.size) &&256+		typeof f.blake3 === "string" &&257+		HEX64.test(f.blake3) &&258+		typeof f.sha256 === "string" &&259+		HEX64.test(f.sha256)260+	);261+}262+142263 function parseSaveRecord(value: unknown): SaveRecord {143264 	const r = value as Record<string, unknown>;144265 	const valid =@@ -203,7 +324,7 @@203324 		typeof f.path === "string" &&204325 		isSafePath(f.path) &&205326 		typeof f.hash === "string" &&206-		OBJECT_HASH.test(f.hash) &&327+		HEX64.test(f.hash) &&207328 		typeof f.executable === "boolean"208329 	);209330 }

view file

src/bucket/sync.ts+27 −3
@@ -2,6 +2,7 @@22 import {33 	knownEventIds,44 	knownObject,5+	knownReleases,56 	knownSave,67 	land,78 	mirrorState,@@ -16,10 +17,14 @@1617 	checkFormat,1718 	type EventRecord,1819 	listEventIds,20+	listReleaseVersions,1921 	listRepos,22+	type ReleaseRecord,2023 	readEvent,2124 	readHead,2225 	readObject,26+	readRelease,27+	readReleasesLatest,2328 	readSave,2429 	type SaveRecord,2530 } from "./repo.ts";@@ -66,7 +71,8 @@6671 /**6772  * Koh's steal flow (§9.4) against a mirror: walk back from the remote head to a save the mirror has,6873  * fetch what is missing, and land it in one transaction. Events are listed every time, since a69- * flag or a strip arrives without moving `head`. Returns the events it landed.74+ * flag or a strip arrives without moving `head`; releases only when `head` or `releases/latest`75+ * moved (§18), since publishing one moves both. Returns the events it landed.7076  */7177 export async function syncRepo(7278 	bucket: BucketReader,@@ -96,7 +102,25 @@96102 	for (const id of await listEventIds(bucket, repo)) {97103 		if (!known.has(id)) events.push(await readEvent(bucket, repo, id));98104 	}99-	if (saves.length === 0 && events.length === 0 && mirrorState(mirror, "head") === head) return [];100-	land(mirror, { head, saves: saves.reverse(), objects, events });105+	const releasesLatest = (await readReleasesLatest(bucket, repo)) ?? "";106+	const moved =107+		mirrorState(mirror, "head") !== head ||108+		mirrorState(mirror, "releases_latest") !== releasesLatest;109+	const releases = moved ? await newReleases(bucket, mirror, repo) : [];110+	if (!moved && events.length === 0) return [];111+	land(mirror, { head, releasesLatest, saves: saves.reverse(), objects, events, releases });101112 	return events;113+}114+115+async function newReleases(116+	bucket: BucketReader,117+	mirror: Database,118+	repo: string,119+): Promise<ReleaseRecord[]> {120+	const known = knownReleases(mirror);121+	const releases: ReleaseRecord[] = [];122+	for (const version of await listReleaseVersions(bucket, repo)) {123+		if (!known.has(version)) releases.push(await readRelease(bucket, repo, version));124+	}125+	return releases;102126 }

view file

src/mirror/queries.ts+9 −0
@@ -1,5 +1,6 @@11 import type { Database } from "bun:sqlite";22 import { inflateSync } from "node:zlib";3+import type { ReleaseRecord } from "../bucket/repo.ts";34 import { mirrorState } from "./store.ts";45 56 export type Save = {@@ -159,6 +160,14 @@159160 		else byPath.set(path, [save]);160161 	}161162 	return { line: [...saves.values()], byPath };163+}164+165+/** Every release record the bucket holds, in no particular order. */166+export function releaseRecords(db: Database): ReleaseRecord[] {167+	return db168+		.query<{ record: string }, []>("SELECT record FROM releases")169+		.all()170+		.map((row) => JSON.parse(row.record) as ReleaseRecord);162171 }163172 164173 function asSave(row: Save): Save {

view file

src/mirror/store.ts+24 −6
@@ -2,9 +2,12 @@22 import { mkdirSync } from "node:fs";33 import { join } from "node:path";44 import { inflateSync } from "node:zlib";5-import type { EventRecord, SaveRecord } from "../bucket/repo.ts";5+import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts";66 7-// Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs.7+// Koh 3's schema (§5.1, `src/storage/storage.v`) minus `file_cache`, which only a working tree needs,8+// plus `releases`: each version's release.json (§11.2) as read from the bucket, since that is bucket9+// data too and pages and feeds need it on every request.10+const SCHEMA_VERSION = 2;811 const SCHEMA = `912 CREATE TABLE objects (1013 	hash TEXT PRIMARY KEY,@@ -44,6 +47,10 @@4447 	key   TEXT PRIMARY KEY,4548 	value TEXT NOT NULL4649 );50+CREATE TABLE releases (51+	version TEXT PRIMARY KEY,52+	record  TEXT NOT NULL53+);4754 CREATE INDEX saves_parent ON saves(parent);4855 CREATE INDEX save_files_hash ON save_files(hash);4956 `;@@ -54,9 +61,11 @@5461 /** What one sync brings in, landed in one transaction. Saves are oldest first. */5562 export type Landing = {5663 	head: string;64+	releasesLatest: string;5765 	saves: SaveRecord[];5866 	objects: Map<string, StoredObject>;5967 	events: EventRecord[];68+	releases: ReleaseRecord[];6069 };6170 6271 // Display names for the header's language, by extension; data and docs formats never count.@@ -104,16 +113,16 @@104113 	const { user_version } = db.query<{ user_version: number }, []>("PRAGMA user_version").get() ?? {105114 		user_version: 0,106115 	};107-	if (user_version !== 0 && user_version !== 1) {116+	if (user_version !== 0 && user_version !== SCHEMA_VERSION) {108117 		db.close();109118 		throw new Error(110-			`${path} has mirror schema version ${user_version}; this Kepr reads version 1. The mirror is disposable: delete it and the next sync rebuilds it from the bucket.`,119+			`${path} has mirror schema version ${user_version}; this Kepr reads version ${SCHEMA_VERSION}. The mirror is disposable: delete it and the next sync rebuilds it from the bucket.`,111120 		);112121 	}113122 	if (user_version === 0) {114123 		db.transaction(() => {115124 			db.run(SCHEMA);116-			db.run("PRAGMA user_version = 1");125+			db.run(`PRAGMA user_version = ${SCHEMA_VERSION}`);117126 		})();118127 	}119128 	open.set(path, db);@@ -140,6 +149,11 @@140149 	return new Set(rows.map((row) => row.id));141150 }142151 152+export function knownReleases(db: Database): Set<string> {153+	const rows = db.query<{ version: string }, []>("SELECT version FROM releases").all();154+	return new Set(rows.map((row) => row.version));155+}156+143157 /** Lands a sync like Koh's steal: objects, then events, then saves, then strips, all or nothing. */144158 export function land(db: Database, landing: Landing): void {145159 	const putObject = db.query("INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)");@@ -153,6 +167,8 @@153167 	const putFile = db.query(154168 		"INSERT OR IGNORE INTO save_files (save_id, path, hash, executable) VALUES (?, ?, ?, ?)",155169 	);170+	const putRelease = db.query("INSERT OR IGNORE INTO releases (version, record) VALUES (?, ?)");171+	const setState = db.query("INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)");156172 	db.transaction(() => {157173 		for (const [hash, object] of landing.objects)158174 			putObject.run(hash, object.size, object.compressed);@@ -175,8 +191,10 @@175191 			);176192 			for (const f of s.files) putFile.run(s.id, f.path, f.hash, f.executable ? 1 : 0);177193 		}194+		for (const r of landing.releases) putRelease.run(r.version, JSON.stringify(r));178195 		applyStrips(db);179-		db.query("INSERT OR REPLACE INTO state (key, value) VALUES ('head', ?)").run(landing.head);196+		setState.run("head", landing.head);197+		setState.run("releases_latest", landing.releasesLatest);180198 		refreshStats(db, landing.head);181199 	})();182200 }

view file

src/releases/releases.ts+91 −0
@@ -0,0 +1,91 @@1+import type { Database } from "bun:sqlite";2+import type { ReleaseFile } from "../bucket/repo.ts";3+import { releaseRecords } from "../mirror/queries.ts";4+5+/** A release as the public sees it: its notes are the operator's override when one is set (§24). */6+export type Release = {7+	version: string;8+	save: string;9+	publishedAt: number;10+	notes: string;11+	files: ReleaseFile[];12+};13+14+type ReleaseSetting = { version: string; hidden: number; notes: string | null };15+16+/**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+ */20+export 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+}40+41+export function compareVersions(a: string, b: string): number {42+	const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);43+	const right = b.split(".").map((part) => Number.parseInt(part, 10) || 0);44+	for (let i = 0; i < Math.max(left.length, right.length); i++) {45+		const diff = (left[i] ?? 0) - (right[i] ?? 0);46+		if (diff !== 0) return diff;47+	}48+	// Numerically equal (`1.0` and `1.0.0`): compare the text, so the order is still total.49+	return a < b ? -1 : a > b ? 1 : 0;50+}51+52+/** §22.2's content types for a release file. */53+export function releaseContentType(name: string): string {54+	const lower = name.toLowerCase();55+	if (lower.endsWith(".apk")) return "application/vnd.android.package-archive";56+	if (lower.endsWith(".zip")) return "application/zip";57+	return "application/octet-stream";58+}59+60+export function countDownload(61+	kepr: Database,62+	repo: string,63+	version: string,64+	platform: string,65+): void {66+	kepr67+		.query(68+			`INSERT INTO downloads (repo, version, platform, count) VALUES (?, ?, ?, 1)69+			ON CONFLICT DO UPDATE SET count = count + 1`,70+		)71+		.run(repo, version, platform);72+}73+74+/** A Nix tarball fetch (§22.2). */75+export function countInstall(kepr: Database, repo: string): void {76+	kepr77+		.query(78+			"INSERT INTO installs (repo, count) VALUES (?, 1) ON CONFLICT DO UPDATE SET count = count + 1",79+		)80+		.run(repo);81+}82+83+/** Download counts of a repo's files, keyed `<version>/<platform>`. */84+export function downloadCounts(kepr: Database, repo: string): Map<string, number> {85+	const rows = kepr86+		.query<{ version: string; platform: string; count: number }, [string]>(87+			"SELECT version, platform, count FROM downloads WHERE repo = ?",88+		)89+		.all(repo);90+	return new Map(rows.map((row) => [`${row.version}/${row.platform}`, row.count]));91+}

view file

src/server/db.ts+20 −0
@@ -20,6 +20,26 @@2020 	last_sync_at    INTEGER,2121 	last_sync_error TEXT2222 );23+-- §24: the admin console hides a version or replaces its notes; NULL notes keep the record's.24+CREATE TABLE IF NOT EXISTS release_settings (25+	repo    TEXT NOT NULL,26+	version TEXT NOT NULL,27+	hidden  INTEGER NOT NULL DEFAULT 0,28+	notes   TEXT,29+	PRIMARY KEY (repo, version)30+);31+-- §22.2: one count per release file; installs are Nix tarball fetches, per repo.32+CREATE TABLE IF NOT EXISTS downloads (33+	repo     TEXT NOT NULL,34+	version  TEXT NOT NULL,35+	platform TEXT NOT NULL,36+	count    INTEGER NOT NULL DEFAULT 0,37+	PRIMARY KEY (repo, version, platform)38+);39+CREATE TABLE IF NOT EXISTS installs (40+	repo  TEXT PRIMARY KEY,41+	count INTEGER NOT NULL DEFAULT 042+);2343 `;2444 2545 export type RepoSettings = {

view file

tests/fixture.ts+77 −0
@@ -1,6 +1,9 @@11 import { mkdir, mkdtemp, writeFile } from "node:fs/promises";22 import { tmpdir } from "node:os";33 import { join } from "node:path";4+import { blake3 } from "@noble/hashes/blake3.js";5+import { bytesToHex } from "@noble/hashes/utils.js";6+import type { ReleaseFile } from "../src/bucket/repo.ts";47 58 export type Fixture = {69 	bucketDir: string;@@ -112,4 +115,78 @@112115 	const code = await proc.exited;113116 	proc.terminal?.close();114117 	if (code !== 0) throw new Error(`koh3 strip ${path} failed: ${output}`);118+}119+120+/** Repos with one koh3 save each, all offered to one folder remote: enough history for releases. */121+export async function makeRepos(122+	names: string[],123+): Promise<{ bucketDir: string; saves: Record<string, string> }> {124+	const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));125+	const home = join(root, "home");126+	const bucketDir = join(root, "bucket");127+	await mkdir(home);128+	const saves: Record<string, string> = {};129+	for (const [i, name] of names.entries()) {130+		const repoDir = join(root, "work", name);131+		await mkdir(repoDir, { recursive: true });132+		run(repoDir, home, ["init"]);133+		if (i === 0) run(repoDir, home, ["remote", "folder", bucketDir]);134+		await writeFile(join(repoDir, "README.md"), `# ${name}\n\nThe ${name} project.\n`);135+		run(repoDir, home, ["save", `release ${name}`]);136+		saves[name] = JSON.parse(run(repoDir, home, ["log", "--json"])).head_id;137+	}138+	return { bucketDir, saves };139+}140+141+/** A release file with its bytes, or only as a record declares it (the bytes are then a stand-in). */142+export type FixtureFile = { platform: string; name: string; bytes: Uint8Array } | ReleaseFile;143+144+export type FixtureRelease = {145+	version: string;146+	save: string;147+	notes?: string;148+	publishedAt?: number;149+	files: FixtureFile[];150+};151+152+/**153+ * Publishes a release into a folder remote as `koh offer --builds` does (§11.2): the files, then154+ * release.json, then `releases/latest`. koh3 3.0.72 has no `--builds`, so this writes them itself.155+ */156+export async function writeRelease(157+	bucketDir: string,158+	repo: string,159+	release: FixtureRelease,160+	latest = true,161+): Promise<void> {162+	const dir = join(bucketDir, repo, "releases", release.version);163+	const files: ReleaseFile[] = [];164+	for (const file of release.files) {165+		const bytes = "bytes" in file ? file.bytes : new TextEncoder().encode(`stand-in ${file.name}`);166+		await mkdir(join(dir, file.platform), { recursive: true });167+		await writeFile(join(dir, file.platform, file.name), bytes);168+		files.push(169+			"bytes" in file170+				? {171+						platform: file.platform,172+						name: file.name,173+						size: bytes.length,174+						blake3: bytesToHex(blake3(bytes)),175+						sha256: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"),176+					}177+				: file,178+		);179+	}180+	const record = {181+		format: 1,182+		repo,183+		version: release.version,184+		save: release.save,185+		published_at: release.publishedAt ?? 1790000000,186+		machine: "fixture",187+		notes: release.notes ?? "",188+		files,189+	};190+	await writeFile(join(dir, "release.json"), JSON.stringify(record, null, 2));191+	if (latest) await writeFile(join(bucketDir, repo, "releases", "latest"), release.version);115192 }

view file

tests/releases.test.ts+102 −0
@@ -0,0 +1,102 @@1+import { beforeAll, describe, expect, test } from "bun:test";2+import { mkdtemp, writeFile } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import { openBucket } from "../src/bucket/reader.ts";6+import { type SyncTarget, syncAll } from "../src/bucket/sync.ts";7+import { openMirror } from "../src/mirror/store.ts";8+import { compareVersions, visibleReleases } from "../src/releases/releases.ts";9+import { openKeprDb } from "../src/server/db.ts";10+import { makeRepos, writeRelease } from "./fixture.ts";11+12+const bytes = (text: string) => new TextEncoder().encode(text);13+14+describe("release reading in sync", () => {15+	let bucketDir: string;16+	let save: string;17+	let target: SyncTarget;18+	const releases = () =>19+		visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app").map((r) => r.version);20+	const lastError = () =>21+		(target.kepr.query("SELECT last_sync_error FROM repos").get() as { last_sync_error: string })22+			.last_sync_error;23+24+	beforeAll(async () => {25+		const repos = await makeRepos(["app"]);26+		bucketDir = repos.bucketDir;27+		save = repos.saves.app as string;28+		const dataDir = await mkdtemp(join(tmpdir(), "kepr-releases-"));29+		target = {30+			bucket: openBucket({ kind: "folder", path: bucketDir }),31+			kepr: openKeprDb(dataDir),32+			mirrorDir: join(dataDir, "mirror"),33+			tarballDir: join(dataDir, "tarballs"),34+		};35+		for (const version of ["1.9.0", "1.10.0", "1.2.3"]) {36+			const files = [{ platform: "linux-x86_64", name: "app", bytes: bytes(version) }];37+			await writeRelease(bucketDir, "app", { version, save, files }, version === "1.10.0");38+		}39+		await syncAll(target);40+	});41+42+	test("reads every release.json, newest first by number", () => {43+		expect(lastError()).toBeNull();44+		expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);45+	});46+47+	test("a version folder without release.json does not exist, and nothing is re-read until latest moves", async () => {48+		await writeFile(join(bucketDir, "app/releases/2.0.0.tmp"), "half-written");49+		const orphan = join(bucketDir, "app/releases/3.0.0/linux-x86_64");50+		await Bun.write(join(orphan, "app"), "no record yet");51+		await writeRelease(52+			bucketDir,53+			"app",54+			{55+				version: "1.11.0",56+				save,57+				files: [{ platform: "linux-x86_64", name: "app", bytes: bytes("x") }],58+			},59+			false,60+		);61+		await syncAll(target);62+		expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);63+		await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");64+		await syncAll(target);65+		expect(releases()).toEqual(["1.11.0", "1.10.0", "1.9.0", "1.2.3"]);66+	});67+68+	test("hidden versions drop out and a notes override replaces the record's notes", () => {69+		target.kepr.run(70+			"INSERT INTO release_settings (repo, version, hidden, notes) VALUES ('app', '1.11.0', 1, NULL), ('app', '1.9.0', 0, 'Operator notes.')",71+		);72+		const shown = visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app");73+		expect(shown.map((r) => [r.version, r.notes])).toEqual([74+			["1.10.0", ""],75+			["1.9.0", "Operator notes."],76+			["1.2.3", ""],77+		]);78+		target.kepr.run("DELETE FROM release_settings");79+	});80+81+	test("a release record that breaks §11.2 is the repo's last sync error", async () => {82+		await writeRelease(bucketDir, "app", {83+			version: "1.12.0",84+			save,85+			files: [{ platform: "plan9-mips", name: "app", bytes: bytes("x") }],86+		});87+		await syncAll(target);88+		expect(lastError()).toContain("release record does not match");89+		await writeRelease(bucketDir, "app", {90+			version: "1.12.0",91+			save,92+			files: [{ platform: "linux-x86_64", name: "../app", bytes: bytes("x") }],93+		});94+		await syncAll(target);95+		expect(lastError()).toContain("release record does not match");96+	});97+});98+99+test("versions compare as numbers, segment by segment", () => {100+	const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);101+	expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);102+});

view file

tests/sync.test.ts+8 −6
@@ -146,18 +146,20 @@146146 });147147 148148 describe("the mirror's schema version", () => {149-	test("a version 1 mirror reopens and any other version is refused as disposable", async () => {149+	test("a version 2 mirror reopens and any other version is refused as disposable", async () => {150150 		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));151151 		const created = openMirror(dir, "one");152152 		created.run("PRAGMA wal_checkpoint(TRUNCATE)");153153 		await Bun.write(join(dir, "copy.db"), Bun.file(join(dir, "one.db")));154154 		expect(openMirror(dir, "copy").query("PRAGMA user_version").get()).toEqual({155-			user_version: 1,155+			user_version: 2,156156 		});157157 158-		const future = new Database(join(dir, "future.db"));159-		future.run("PRAGMA user_version = 2");160-		future.close();161-		expect(() => openMirror(dir, "future")).toThrow("disposable");158+		for (const version of [1, 3]) {159+			const other = new Database(join(dir, `v${version}.db`));160+			other.run(`PRAGMA user_version = ${version}`);161+			other.close();162+			expect(() => openMirror(dir, `v${version}`)).toThrow("disposable");163+		}162164 	});163165 });

view file