71ae032e7c17

make the Kepr 2 import fail loudly before any bucket write on a bad imported row, credit replies to the operator only from Kepr 2's operator marks and refuse third-party ones, and accept a 412 only when the object holds the same bytes (v3.0.44)

time
2026-09-26 21:12 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
946c9882a405
3 files package.json+1 −1scripts/import-kepr2.ts+38 −36tests/import.test.ts+116 −51
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.43",3+	"version": "3.0.44",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

scripts/import-kepr2.ts+38 −36
@@ -1,6 +1,5 @@1-// One-time Kepr 2 import (§28), deleted after cutover; the only bucket writer. Env: KEPR2_DATABASE_URL,2-// KEPR2_DATA, KEPR_DATA, KOH_FOLDER or KOH_S3_* (write key at run time only). --dry-run writes nothing.3-import { join } from "node:path";1+// One-time Kepr 2 import (§28), deleted after cutover; the only bucket writer. --dry-run writes nothing.2+import { dirname, join } from "node:path";43 import { blake3 } from "@noble/hashes/blake3.js";54 import { bytesToHex } from "@noble/hashes/utils.js";65 import { SQL } from "bun";@@ -19,19 +18,20 @@1918 const [endpoint, name, region, accessKeyId, secretAccessKey] = VARS.map((v) => env(`KOH_S3_${v}`));2019 const s3 = { endpoint, region, accessKeyId, secretAccessKey };2120 const bucket = openBucket(folder ? { kind: "folder", path: folder } : { kind: "s3", name, ...s3 });22-/** put_if_absent (§9.1): true when `key` holds `bytes` after; a key holding other bytes is kept. */23-async function put(key: string, body: string | Uint8Array): Promise<boolean> {21+/** put_if_absent (§9.1); throws when `key` holds other bytes. */22+async function put(key: string, body: string | Uint8Array): Promise<void> {2423 	const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;25-	const had = await bucket.get(key);26-	const same = had === null || Bun.deepEquals(had, bytes);27-	console.log(had === null ? (dry ? "would put" : "put") : same ? "have" : "differs, kept:", key);28-	if (had !== null || dry) return same;29-	if (folder) return Bun.write(join(folder, key), bytes).then(() => true);30-	// Bun's S3 client has no conditional write, so a presigned PUT carries it.24+	const had = (await bucket.get(key)) ?? (dry ? null : await write(key, bytes));25+	console.log(had ? "have" : dry ? "would put" : "put", key);26+	if (had && !Bun.deepEquals(had, bytes)) throw new Error(`${key} holds other bytes`);27+}28+// Bun's S3 client can't write conditionally, so a presigned PUT does; a 412 returns the racer's bytes.29+async function write(key: string, bytes: Uint8Array): Promise<Uint8Array | null> {30+	if (folder) return Bun.write(join(folder, key), bytes).then(() => null);3131 	const url = new Bun.S3Client({ ...s3, bucket: name }).presign(key, { method: "PUT" });3232 	const res = await fetch(url, { method: "PUT", body: bytes, headers: { "If-None-Match": "*" } });33-	if (!res.ok) throw new Error(`put ${key}: ${res.status}`);34-	return true;33+	if (res.ok || res.status === 412) return res.ok ? null : bucket.get(key);34+	throw new Error(`put ${key}: ${res.status}`);3535 }3636 function insert(table: string, row: Row, tail: string): number | null {3737 	const cols = Object.keys(row);@@ -40,37 +40,38 @@4040 	console.log(table, JSON.stringify(row).slice(0, 80), id ? "set" : "have");4141 	return id;4242 }43+const errors: string[] = [],44+	writes: [string, string | Uint8Array][] = [];4345 kepr.run("BEGIN");44-// Kepr 3 keeps one category and has no description field; Kepr 2 had no homepage.4546 const upsert = "ON CONFLICT DO UPDATE SET public = excluded.public, category = excluded.category";4647 for (const repo of await pg<Row[]>`4748 	SELECT name, (private = 0)::int AS public, trim(split_part(COALESCE(categories, ''), ',', 1)) AS category FROM repos`)4849 	insert("repos", repo, upsert);49-// Kepr 2's four statuses become two (Phase 5); "enhancement" is an idea. Any account's reply but the50-// reporter's own is the operator's, since §23's thread has no other author.51-for (const { replies, ...issue } of await pg<(Row & { replies: Row[] })[]>`50+// Kepr 2's four statuses become two (Phase 5); "enhancement" is an idea. Kepr 2 wrote the operator's51+// replies as "operator" (NULL before accounts); a third account's reply has no author kind here.52+for (const { replies, ...i } of await pg<(Row & { replies: Row[] })[]>`5253 	SELECT i.repo, i.number, i.title, i.description AS body, CASE WHEN i.type IN ('bug', 'question') THEN i.type ELSE 'idea' END AS type,5354 		COALESCE(i.filed_ver, '') AS version, COALESCE(NULLIF(i.reporter_name, ''), '@' || a.username, '') AS reporter_name,5455 		COALESCE(NULLIF(i.contact_email, ''), i.contact, '') AS contact_email, i.filed_at,55-		CASE WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN 'closed' ELSE 'open' END AS status,56+		CASE WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN 'closed' ELSE 'open' END AS status, CASE WHEN i.closed_save ~ '^[0-9a-f]{12}$' THEN i.closed_save ELSE '' END AS fixed_in,5657 		CASE WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN COALESCE(i.closed_at, i.filed_at) END AS closed_at,57-		CASE WHEN i.closed_save ~ '^[0-9a-f]{12}$' THEN i.closed_save ELSE '' END AS fixed_in,58-		(SELECT COALESCE(json_agg(json_build_object('author', CASE WHEN e.account_id = i.account_id THEN 'reporter' ELSE 'operator' END,59-			'body', e.body, 'created_at', e.created_at) ORDER BY e.created_at, e.id), '[]') FROM issue_entries e WHERE e.issue_id = i.id AND e.entry_type = 'reply' AND e.body IS NOT NULL) AS replies58+		(SELECT COALESCE(json_agg(json_build_object('author', CASE WHEN e.account_id IS NULL OR e.account_id = 'operator' OR o.tier = 'operator' THEN 'operator' WHEN e.account_id = i.account_id THEN 'reporter' END,59+			'body', e.body, 'created_at', e.created_at) ORDER BY e.created_at, e.id), '[]') FROM issue_entries e LEFT JOIN accounts o ON o.id = e.account_id WHERE e.issue_id = i.id AND e.entry_type = 'reply' AND e.body IS NOT NULL) AS replies6060 	FROM issues i LEFT JOIN accounts a ON a.id = i.account_id ORDER BY i.repo, i.number`) {61-	const id = insert("issues", issue, "ON CONFLICT DO NOTHING");62-	for (const reply of id ? replies : []) insert("issue_entries", { issue_id: id, ...reply }, "");61+	const id = insert("issues", i, "ON CONFLICT DO NOTHING");62+	if (replies.some((r) => !r.author)) errors.push(`${i.repo}#${i.number}: third-party reply`);63+	else for (const r of id ? replies : []) insert("issue_entries", { issue_id: id, ...r }, "");6364 }64-// Kepr 2 counted Nix fetches as installs (kept 90 days) and never counted release downloads.65+// Kepr 2 counted Nix fetches as installs (kept 90 days). Its archive downloads have no home in Kepr 3.6566 for (const count of await pg<Row[]>`6667 	SELECT repo_id AS repo, COUNT(*) AS count FROM engagement_events WHERE event_type = 'nix_install' AND repo_id IS NOT NULL GROUP BY repo_id`)6768 	insert("installs", count, "ON CONFLICT DO NOTHING");68-// As Kepr 2 served: successful and partial builds, a file stored under the repo's name as <repo>-<platform>,69-// older uploads' files named by platform; builds off hid a repo's. Save, notes, time: the newest build's.69+// Only what Kepr 2 served: successful and partial builds, a file stored as the repo's name as70+// <repo>-<platform>, older uploads named by platform; builds off hid a repo's. Save, notes: newest build's.7071 type Release = { repo: string; version: string; hidden: boolean; record: Row; stored: string[] };7172 const newest = new Map<string, string>();7273 for (const { repo, version, hidden, record, stored } of await pg<Release[]>`73-	SELECT a.repo_id AS repo, a.version, bool_or(r.builds_enabled = 0) AS hidden, json_agg(a.filename ORDER BY a.platform) AS stored,74+	SELECT a.repo_id AS repo, a.version, bool_or(r.builds_enabled = 0) AS hidden, json_agg(concat_ws('/', 'releases', a.repo_id, a.version, a.platform, a.filename) ORDER BY a.platform) AS stored,7475 		json_build_object('format', 1, 'repo', a.repo_id, 'version', a.version, 'save', (array_agg(b.save_id ORDER BY b.started_at DESC))[1],7576 			'published_at', max(COALESCE(b.finished_at, a.built_at)), 'machine', 'kepr2', 'notes', COALESCE((array_agg(b.release_notes ORDER BY b.started_at DESC))[1], ''),7677 			'files', json_agg(json_build_object('platform', a.platform, 'name', CASE WHEN a.filename = a.repo_id THEN a.repo_id || '-' || a.platform ELSE a.filename END,@@ -79,20 +80,21 @@7980 	const prefix = `${repo}/releases/${version}`;8081 	try {8182 		for (const [n, f] of checkRelease(repo, version, record).files.entries()) {82-			const dir = join(env("KEPR2_DATA"), "releases", repo, version, f.platform);83-			const path = join(dir, stored[n] ?? "");84-			const bytes = await Bun.file((await Bun.file(path).exists()) ? path : dir).bytes();85-			const kepr2s = bytesToHex(blake3(bytes)) === f.blake3;86-			if (!kepr2s || !(await put(`${prefix}/${f.platform}/${f.name}`, bytes)))87-				throw new Error(`${f.platform}: not Kepr 2's file, or another is there`);83+			const path = join(env("KEPR2_DATA"), stored[n] ?? "");84+			const bytes = await Bun.file((await Bun.file(path).exists()) ? path : dirname(path)).bytes();85+			if (bytesToHex(blake3(bytes)) !== f.blake3) throw new Error(`${f.platform}: hash mismatch`);86+			writes.push([`${prefix}/${f.platform}/${f.name}`, bytes]);8887 		}89-		if (!(await put(`${prefix}/release.json`, JSON.stringify(record)))) continue;88+		writes.push([`${prefix}/release.json`, JSON.stringify(record)]);9089 		if (hidden) insert("release_settings", { repo, version, hidden: 1 }, "ON CONFLICT DO NOTHING");9190 		if (compareVersions(version, newest.get(repo) ?? "") > 0) newest.set(repo, version);9291 	} catch (error) {93-		console.log(`skip ${prefix}:`, error instanceof Error ? error.message : error);92+		errors.push(`${prefix}: ${error instanceof Error ? error.message : error}`);9493 	}9594 }96-for (const [repo, version] of newest) await put(`${repo}/releases/latest`, version);95+// Fail before any write (kepr.db is uncommitted); writes never overwrite, so a re-run finishes one.96+if (errors.length > 0) throw new Error(`not imported, nothing written:\n${errors.join("\n")}`);97+for (const [repo, version] of newest) writes.push([`${repo}/releases/latest`, version]);98+for (const [key, body] of writes) await put(key, body);9799 kepr.run(dry ? "ROLLBACK" : "COMMIT");98100 await pg.close();

view file

tests/import.test.ts+116 −51
@@ -25,6 +25,18 @@2525 	if (out.exitCode !== 0) throw new Error(`${cmd.join(" ")}: ${out.stderr.toString()}`);2626 };2727 28+const artifact = (29+	id: string,30+	run: string,31+	repo: string,32+	version: string,33+	platform: string,34+	name: string,35+	bytes: Uint8Array,36+	b3 = hex(bytes),37+) =>38+	`('${id}', '${run}', '${repo}', '${version}', '${platform}', '${name}', ${bytes.length}, '${sha(bytes)}', '${b3}', 5)`;39+2840 async function kepr2File(path: string, text: string) {2941 	await mkdir(join(kepr2Data, "releases", path, ".."), { recursive: true });3042 	await writeFile(join(kepr2Data, "releases", path), text);@@ -56,28 +68,13 @@5668 	const byPlatform = await kepr2File("demo/1.1.0/macos-aarch64", "demo 1.1.0 mac");5769 	const apk = await kepr2File("demo/1.1.0/android-arm64/demo-1.1.0.apk", "demo 1.1.0 apk");5870 	const failed = await kepr2File("demo/1.2.0/linux-x86_64/demo", "demo 1.2.0 failed");59-	await kepr2File("demo/1.3.0/linux-x86_64/demo", "demo 1.3.0 tampered");6071 	const secret = await kepr2File("secret/0.1.0/linux-x86_64/secret", "secret 0.1.0");61-	const oldPlatform = await kepr2File(62-		"demo/1.4.0/x86_64-linux/demo",63-		"demo 1.4.0 old platform name",64-	);65-	const artifact = (66-		id: string,67-		run: string,68-		repo: string,69-		version: string,70-		platform: string,71-		name: string,72-		bytes: Uint8Array,73-		b3 = hex(bytes),74-	) =>75-		`('${id}', '${run}', '${repo}', '${version}', '${platform}', '${name}', ${bytes.length}, '${sha(bytes)}', '${b3}', 5)`;7672 	const pg = new SQL(url);7773 	await pg.unsafe(`7874 		INSERT INTO repos (name, created_at, description, categories, private, builds_enabled) VALUES7975 			('demo', 1, 'A demo.', 'tools, cli', 0, 1), ('secret', 1, NULL, '', 1, 0);80-		INSERT INTO accounts (id, username, recovery_hash, created_at) VALUES ('acc-r', 'rita', 'x', 1), ('acc-o', 'olga', 'x', 1);76+		INSERT INTO accounts (id, username, recovery_hash, created_at, tier) VALUES77+			('acc-r', 'rita', 'x', 1, 'contributor'), ('acc-o', 'olga', 'x', 1, 'operator'), ('acc-t', 'tom', 'x', 1, 'team');8178 		INSERT INTO issues (id, repo, number, title, description, contact, contact_email, filed_at, filed_save, filed_ver, status, closed_at, closed_save, type, account_id, reporter_name) VALUES8279 			(1, 'demo', 7, 'Crash on start', 'It <b>crashes</b>.', NULL, '[email protected]', 1000, 'aaaaaaaaaaaa', '1.0.0', 'inprogress', NULL, NULL, 'bug', 'acc-r', NULL),8380 			(2, 'demo', 9, 'Dark mode', 'Please.', '[email protected]', NULL, 1500, 'aaaaaaaaaaaa', NULL, 'wontfix', 2000, '${save}', 'enhancement', NULL, 'Sam');@@ -92,24 +89,20 @@9289 			('b1', 'demo', '${save}', '1.0.0', 'manual', 'success', 10, 20, NULL),9390 			('b2', 'demo', '${save}', '1.1.0', 'manual', 'partial', 30, 40, 'Starts twice as fast and no longer crashes on start.'),9491 			('b3', 'demo', '${save}', '1.2.0', 'manual', 'failed', 50, 60, NULL),95-			('b4', 'demo', '${save}', '1.3.0', 'manual', 'success', 70, 80, NULL),96-			('b5', 'secret', '${save}', '0.1.0', 'manual', 'success', 90, 100, NULL),97-			('b6', 'demo', '${save}', '1.4.0', 'manual', 'success', 110, 120, NULL);92+			('b5', 'secret', '${save}', '0.1.0', 'manual', 'success', 90, 100, NULL);9893 		INSERT INTO release_artifacts (id, build_run, repo_id, version, platform, filename, size_bytes, sha256, blake3, built_at) VALUES9994 			${artifact("a1", "b1", "demo", "1.0.0", "linux-x86_64", "demo", legacy)},10095 			${artifact("a2", "b2", "demo", "1.1.0", "macos-aarch64", "demo", byPlatform)},10196 			${artifact("a3", "b2", "demo", "1.1.0", "android-arm64", "demo-1.1.0.apk", apk)},10297 			${artifact("a4", "b3", "demo", "1.2.0", "linux-x86_64", "demo", failed)},103-			${artifact("a5", "b4", "demo", "1.3.0", "linux-x86_64", "demo", legacy, hex(legacy))},104-			${artifact("a6", "b5", "secret", "0.1.0", "linux-x86_64", "secret", secret)},105-			${artifact("a7", "b6", "demo", "1.4.0", "x86_64-linux", "demo", oldPlatform)};98+			${artifact("a6", "b5", "secret", "0.1.0", "linux-x86_64", "secret", secret)};10699 	`);107100 	await pg.close();108101 });109102 110103 afterAll(() => run(["pg_ctl", "-D", pgDir, "-m", "fast", "-w", "stop"]));111104 112-async function importKepr2(target: Record<string, string>, keprData: string, ...args: string[]) {105+async function runImport(target: Record<string, string>, keprData: string, ...args: string[]) {113106 	const env = {114107 		PATH: process.env.PATH,115108 		KEPR2_DATABASE_URL: url,@@ -117,17 +110,22 @@117110 		KEPR_DATA: keprData,118111 		...target,119112 	};120-	// Not spawnSync: the S3 test's stand-in server runs on this process's event loop.113+	// Not spawnSync: the S3 tests' stand-in server runs on this process's event loop.121114 	const child = Bun.spawn(["bun", SCRIPT, ...args], { cwd: root, env, stderr: "pipe" });122-	const [stdout, stderr, code] = await Promise.all([115+	const [out, err, code] = await Promise.all([123116 		new Response(child.stdout).text(),124117 		new Response(child.stderr).text(),125118 		child.exited,126119 	]);127-	if (code !== 0) throw new Error(stderr);128-	return stdout;120+	return { out, err, code };129121 }130122 123+async function importKepr2(target: Record<string, string>, keprData: string, ...args: string[]) {124+	const { out, err, code } = await runImport(target, keprData, ...args);125+	if (code !== 0) throw new Error(err);126+	return out;127+}128+131129 async function files(dir: string): Promise<string[]> {132130 	return (await readdir(dir, { recursive: true, withFileTypes: true }))133131 		.filter((entry) => entry.isFile())@@ -149,8 +147,6 @@149147 test("the import lands Kepr 2's issues, counts, settings and releases, and a re-run changes nothing", async () => {150148 	const keprData = await mkdtemp(join(tmpdir(), "kepr-import-data-"));151149 	const first = await importKepr2({ KOH_FOLDER: bucketDir }, keprData);152-	expect(first).toContain("skip demo/releases/1.3.0: linux-x86_64: not Kepr 2's file");153-	expect(first).toContain("skip demo/releases/1.4.0: release record does not match §11.2");154150 	expect(first).not.toContain("1.2.0");155151 	const landed = await files(bucketDir);156152 	expect(landed.filter((key) => key.includes("/releases/"))).toEqual([@@ -165,7 +161,7 @@165161 		"secret/releases/latest",166162 	]);167163 	const again = await importKepr2({ KOH_FOLDER: bucketDir }, keprData);168-	expect(again).not.toMatch(/^(put|would put|differs)/m);164+	expect(again).not.toMatch(/^(put|would put)/m);169165 	// Only the repo settings are set again (to the same values); nothing else is added.170166 	expect(171167 		again.split("\n").filter((line) => line.endsWith(" set") && !line.startsWith("repos ")),@@ -258,15 +254,18 @@258254 	});259255 });260256 261-test("into an S3 bucket, files go up as presigned PUTs with If-None-Match, never over an existing key", async () => {262-	const s3Dir = await mkdtemp(join(tmpdir(), "kepr-import-s3-"));257+type S3 = { dir: string; puts: string[]; target: Record<string, string>; stop(): void };258+259+/** A stand-in S3 over a folder. A key in `race` appears, holding those bytes, between our GET and PUT. */260+async function standInS3(race = new Map<string, string>()): Promise<S3> {261+	const dir = await mkdtemp(join(tmpdir(), "kepr-import-s3-"));263262 	const puts: string[] = [];264263 	const server = Bun.serve({265264 		port: 0,266265 		async fetch(req) {267266 			const url = new URL(req.url);268-			const path = join(s3Dir, decodeURIComponent(url.pathname.replace("/koh/", "")));269-			const file = Bun.file(path);267+			const key = decodeURIComponent(url.pathname.replace("/koh/", ""));268+			const file = Bun.file(join(dir, key));270269 			if (req.method === "GET")271270 				return (await file.exists())272271 					? new Response(file)@@ -275,30 +274,96 @@275274 				"*",276275 				true,277276 			]);277+			const raced = race.get(key);278+			if (raced !== undefined) await Bun.write(join(dir, key), raced);278279 			if (await file.exists()) return new Response("", { status: 412 });279-			puts.push(url.pathname);280-			await Bun.write(path, await req.arrayBuffer());280+			puts.push(key);281+			await Bun.write(join(dir, key), await req.arrayBuffer());281282 			return new Response("");282283 		},283284 	});285+	const target = {286+		KOH_S3_ENDPOINT: `http://localhost:${server.port}`,287+		KOH_S3_BUCKET: "koh",288+		KOH_S3_REGION: "fr-par",289+		KOH_S3_ACCESS_KEY_ID: "write",290+		KOH_S3_SECRET_ACCESS_KEY: "key",291+	};292+	return { dir, puts, target, stop: () => server.stop(true) };293+}294+295+const newKeprData = () => mkdtemp(join(tmpdir(), "kepr-import-s3data-"));296+297+test("into an S3 bucket, files go up as presigned PUTs with If-None-Match, never over an existing key", async () => {298+	const s3 = await standInS3();284299 	try {285-		const target = {286-			KOH_S3_ENDPOINT: `http://localhost:${server.port}`,287-			KOH_S3_BUCKET: "koh",288-			KOH_S3_REGION: "fr-par",289-			KOH_S3_ACCESS_KEY_ID: "write",290-			KOH_S3_SECRET_ACCESS_KEY: "key",291-		};292-		await importKepr2(target, await mkdtemp(join(tmpdir(), "kepr-import-s3data-")));293-		expect(puts.length).toBe(9);294-		for (const key of await files(s3Dir)) {295-			expect(await Bun.file(join(s3Dir, key)).bytes()).toEqual(300+		await importKepr2(s3.target, await newKeprData());301+		expect(s3.puts.length).toBe(9);302+		for (const key of await files(s3.dir)) {303+			expect(await Bun.file(join(s3.dir, key)).bytes()).toEqual(296304 				await Bun.file(join(bucketDir, key)).bytes(),297305 			);298306 		}299-		await importKepr2(target, await mkdtemp(join(tmpdir(), "kepr-import-s3data-")));300-		expect(puts.length).toBe(9);307+		await importKepr2(s3.target, await newKeprData());308+		expect(s3.puts.length).toBe(9);301309 	} finally {302-		server.stop(true);310+		s3.stop();303311 	}312+});313+314+test("a 412 from a key written since the read is success only when it holds the same bytes", async () => {315+	const record = await Bun.file(join(bucketDir, "demo/releases/1.0.0/release.json")).text();316+	const same = await standInS3(new Map([["demo/releases/1.0.0/release.json", record]]));317+	try {318+		await importKepr2(same.target, await newKeprData());319+		expect(same.puts).not.toContain("demo/releases/1.0.0/release.json");320+		expect(same.puts.length).toBe(8);321+	} finally {322+		same.stop();323+	}324+	const other = await standInS3(new Map([["demo/releases/latest", "9.9.9"]]));325+	try {326+		const run = await runImport(other.target, await newKeprData());327+		expect(run.code).toBe(1);328+		expect(run.err).toContain("demo/releases/latest holds other bytes");329+		expect(await Bun.file(join(other.dir, "demo/releases/latest")).text()).toBe("9.9.9");330+	} finally {331+		other.stop();332+	}333+});334+335+test("any imported row that fails a check fails the run: all are listed and nothing is written", async () => {336+	await kepr2File("demo/1.3.0/linux-x86_64/demo", "demo 1.3.0 tampered");337+	const oldPlatform = await kepr2File("demo/1.4.0/x86_64-linux/demo", "demo 1.4.0 old name");338+	const legacy = bytesOf("demo 1.0.0 linux");339+	const pg = new SQL(url);340+	await pg.unsafe(`341+		INSERT INTO build_runs (id, repo_id, save_id, version, trigger, status, started_at, finished_at) VALUES342+			('b4', 'demo', '${save}', '1.3.0', 'manual', 'success', 70, 80), ('b6', 'demo', '${save}', '1.4.0', 'manual', 'success', 110, 120);343+		INSERT INTO release_artifacts (id, build_run, repo_id, version, platform, filename, size_bytes, sha256, blake3, built_at) VALUES344+			${artifact("a5", "b4", "demo", "1.3.0", "linux-x86_64", "demo", legacy)},345+			${artifact("a7", "b6", "demo", "1.4.0", "x86_64-linux", "demo", oldPlatform)};346+		INSERT INTO issue_entries (id, issue_id, account_id, entry_type, body, created_at) VALUES ('e5', 2, 'acc-t', 'reply', 'Team here.', 1600);347+	`);348+	await pg.close();349+	const before = await files(bucketDir);350+	const keprData = await mkdtemp(join(tmpdir(), "kepr-import-bad-"));351+	const run = await runImport({ KOH_FOLDER: bucketDir }, keprData);352+	expect(run.code).toBe(1);353+	expect(run.err).toContain("not imported, nothing written:");354+	expect(run.err).toContain("demo#9: third-party reply");355+	expect(run.err).toContain("demo/releases/1.3.0: linux-x86_64: hash mismatch");356+	expect(run.err).toContain("demo/releases/1.4.0: release record does not match §11.2");357+	expect(run.out).not.toMatch(/^put /m);358+	expect(await files(bucketDir)).toEqual(before);359+	const inst = await instance(bucketDir, keprData);360+	for (const table of ["issues", "issue_entries", "installs", "release_settings"]) {361+		expect([table, inst.kepr.query(`SELECT COUNT(*) AS n FROM ${table}`).get()]).toEqual([362+			table,363+			{ n: 0 },364+		]);365+	}366+	expect(inst.kepr.query("SELECT COUNT(*) AS n FROM repos WHERE name = 'secret'").get()).toEqual({367+		n: 0,368+	});304369 });

view file