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
↓ kepr-71ae032e.tar.gzatlas at this save
files changed3 files
package.json+1 −1scripts/import-kepr2.ts+38 −36tests/import.test.ts+116 −51package.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": {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();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 });