fix(ui): polish issue detail timelines and attachments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 11:51:40 -05:00
parent 36376968af
commit bd6d07d0b4
25 changed files with 2020 additions and 82 deletions

View file

@ -0,0 +1,179 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
import { ensurePostgresDatabase } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const cleanups: Array<() => Promise<void> | void> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanups.push(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
async function createTempDatabase(): Promise<string> {
const db = await startEmbeddedPostgresTestDatabase("paperclip-db-backup-");
cleanups.push(db.cleanup);
return db.connectionString;
}
async function createSiblingDatabase(connectionString: string, databaseName: string): Promise<string> {
const adminUrl = new URL(connectionString);
adminUrl.pathname = "/postgres";
await ensurePostgresDatabase(adminUrl.toString(), databaseName);
const targetUrl = new URL(connectionString);
targetUrl.pathname = `/${databaseName}`;
return targetUrl.toString();
}
afterEach(async () => {
while (cleanups.length > 0) {
const cleanup = cleanups.pop();
await cleanup?.();
}
});
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres backup tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("createBufferedTextFileWriter", () => {
it("preserves line boundaries across buffered flushes", async () => {
const tempDir = createTempDir("paperclip-buffered-writer-");
const outputPath = path.join(tempDir, "backup.sql");
const writer = createBufferedTextFileWriter(outputPath, 16);
const lines = [
"-- header",
"BEGIN;",
"",
"INSERT INTO test VALUES (1);",
"-- footer",
];
for (const line of lines) {
writer.emit(line);
}
await writer.close();
expect(fs.readFileSync(outputPath, "utf8")).toBe(lines.join("\n"));
});
});
describeEmbeddedPostgres("runDatabaseBackup", () => {
it(
"backs up and restores large table payloads without materializing one giant string",
async () => {
const sourceConnectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(
sourceConnectionString,
"paperclip_restore_target",
);
const backupDir = createTempDir("paperclip-db-backup-output-");
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
try {
await sourceSql.unsafe(`
CREATE TYPE "public"."backup_test_state" AS ENUM ('pending', 'done');
`);
await sourceSql.unsafe(`
CREATE TABLE "public"."backup_test_records" (
"id" serial PRIMARY KEY,
"title" text NOT NULL,
"payload" text NOT NULL,
"state" "public"."backup_test_state" NOT NULL,
"metadata" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now()
);
`);
const payload = "x".repeat(8192);
for (let index = 0; index < 160; index += 1) {
const createdAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index));
await sourceSql`
INSERT INTO "public"."backup_test_records" (
"title",
"payload",
"state",
"metadata",
"created_at"
)
VALUES (
${`row-${index}`},
${payload},
${index % 2 === 0 ? "pending" : "done"}::"public"."backup_test_state",
${JSON.stringify({ index, even: index % 2 === 0 })}::jsonb,
${createdAt}
)
`;
}
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retentionDays: 7,
filenamePrefix: "paperclip-test",
});
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/);
expect(result.sizeBytes).toBeGreaterThan(1024 * 1024);
expect(fs.existsSync(result.backupFile)).toBe(true);
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile: result.backupFile,
});
const counts = await restoreSql.unsafe<{ count: number }[]>(`
SELECT count(*)::int AS count
FROM "public"."backup_test_records"
`);
expect(counts[0]?.count).toBe(160);
const sampleRows = await restoreSql.unsafe<{
title: string;
payload: string;
state: string;
metadata: { index: number; even: boolean };
}[]>(`
SELECT "title", "payload", "state"::text AS "state", "metadata"
FROM "public"."backup_test_records"
WHERE "title" IN ('row-0', 'row-159')
ORDER BY "title"
`);
expect(sampleRows).toEqual([
{
title: "row-0",
payload,
state: "pending",
metadata: { index: 0, even: true },
},
{
title: "row-159",
payload,
state: "done",
metadata: { index: 159, even: false },
},
]);
} finally {
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
});

View file

@ -1,5 +1,5 @@
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import postgres from "postgres";
@ -47,6 +47,7 @@ type TableDefinition = {
const DRIZZLE_SCHEMA = "drizzle";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
@ -141,6 +142,98 @@ function tableKey(schemaName: string, tableName: string): string {
return `${schemaName}.${tableName}`;
}
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
const stream = createWriteStream(filePath, { encoding: "utf8" });
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
let bufferedLines: string[] = [];
let bufferedBytes = 0;
let firstChunk = true;
let closed = false;
let streamError: Error | null = null;
let pendingWrite = Promise.resolve();
stream.on("error", (error) => {
streamError = error;
});
const writeChunk = async (chunk: string): Promise<void> => {
if (streamError) throw streamError;
const canContinue = stream.write(chunk);
if (!canContinue) {
await new Promise<void>((resolve, reject) => {
const handleDrain = () => {
cleanup();
resolve();
};
const handleError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
stream.off("drain", handleDrain);
stream.off("error", handleError);
};
stream.once("drain", handleDrain);
stream.once("error", handleError);
});
}
if (streamError) throw streamError;
};
const flushBufferedLines = () => {
if (bufferedLines.length === 0) return;
const linesToWrite = bufferedLines;
bufferedLines = [];
bufferedBytes = 0;
const chunkBody = linesToWrite.join("\n");
const chunk = firstChunk ? chunkBody : `\n${chunkBody}`;
firstChunk = false;
pendingWrite = pendingWrite.then(() => writeChunk(chunk));
};
return {
emit(line: string) {
if (closed) {
throw new Error(`Cannot write to closed backup file: ${filePath}`);
}
if (streamError) throw streamError;
bufferedLines.push(line);
bufferedBytes += Buffer.byteLength(line, "utf8") + 1;
if (bufferedBytes >= flushThreshold) {
flushBufferedLines();
}
},
async close() {
if (closed) return;
closed = true;
flushBufferedLines();
await pendingWrite;
await new Promise<void>((resolve, reject) => {
if (streamError) {
reject(streamError);
return;
}
stream.end((error?: Error | null) => {
if (error) reject(error);
else resolve();
});
});
if (streamError) throw streamError;
},
async abort() {
if (closed) return;
closed = true;
bufferedLines = [];
bufferedBytes = 0;
stream.destroy();
await pendingWrite.catch(() => {});
if (existsSync(filePath)) {
unlinkSync(filePath);
}
},
};
}
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
@ -149,12 +242,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
mkdirSync(opts.backupDir, { recursive: true });
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
const writer = createBufferedTextFileWriter(backupFile);
try {
await sql`SELECT 1`;
const lines: string[] = [];
const emit = (line: string) => lines.push(line);
const emit = (line: string) => writer.emit(line);
const emitStatement = (statement: string) => {
emit(statement);
emit(STATEMENT_BREAKPOINT);
@ -503,10 +598,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emitStatement("COMMIT;");
emit("");
// Write the backup file
mkdirSync(opts.backupDir, { recursive: true });
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
await writeFile(backupFile, lines.join("\n"), "utf8");
await writer.close();
const sizeBytes = statSync(backupFile).size;
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
@ -516,6 +608,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
sizeBytes,
prunedCount,
};
} catch (error) {
await writer.abort();
throw error;
} finally {
await sql.end();
}

View file

@ -2,6 +2,7 @@ import type { FeedbackDataSharingPreference } from "./feedback.js";
export interface InstanceGeneralSettings {
censorUsernameInLogs: boolean;
keyboardShortcuts: boolean;
feedbackDataSharingPreference: FeedbackDataSharingPreference;
}

View file

@ -4,6 +4,7 @@ import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
export const instanceGeneralSettingsSchema = z.object({
censorUsernameInLogs: z.boolean().default(false),
keyboardShortcuts: z.boolean().default(false),
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
),