diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 8f45e5577..5642bcceb 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -10,6 +10,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import type { ExportProgress } from "@/lib/api"; import { isSchemaAware } from "@/lib/databaseCapabilities"; +import { generateDatabaseExportId } from "@/lib/databaseExport"; import { buildSelectedTablesPayload } from "@/lib/databaseExportSelection"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { useToast } from "@/composables/useToast"; @@ -161,7 +162,7 @@ async function startExport() { exportCancelled.value = false; exportProgress.value = null; - exportId.value = crypto.randomUUID(); + exportId.value = generateDatabaseExportId(); let filePath = ""; diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 33b7ba061..b54589a5f 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -77,6 +77,7 @@ import { normalizeWhereInput, quoteTableIdentifier, } from "@/lib/tableSelectSql"; +import { uuid } from "@/lib/utils"; import { canEditExistingTableRows, hiveTablePropertiesIndicateTransactional, @@ -580,8 +581,7 @@ function selectCustomFormatter(value: string) { } function createCustomFormatterId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) return `fmt_${crypto.randomUUID()}`; - return `fmt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; + return `fmt_${uuid()}`; } function formatterPreviewRows(columnIndex: number) { diff --git a/apps/desktop/src/lib/databaseExport.ts b/apps/desktop/src/lib/databaseExport.ts index 681bf478d..a0169542a 100644 --- a/apps/desktop/src/lib/databaseExport.ts +++ b/apps/desktop/src/lib/databaseExport.ts @@ -1,5 +1,6 @@ import type { DatabaseType, QueryResult } from "../types/database.ts"; import { buildTableSelectSql } from "./tableSelectSql.ts"; +import { uuid } from "./utils.ts"; type SqlValue = QueryResult["rows"][number][number]; @@ -72,6 +73,10 @@ export function buildExportPageSql(options: BuildExportPageSqlOptions): string { }); } +export function generateDatabaseExportId(): string { + return uuid(); +} + export function buildDatabaseSqlExport(options: BuildDatabaseSqlExportOptions): string { const exportedAt = options.exportedAt ?? new Date(); const rowLimit = options.rowLimitPerTable ?? DATABASE_EXPORT_ROW_LIMIT; diff --git a/packages/app-tests/databaseExport.test.ts b/packages/app-tests/databaseExport.test.ts index 3920692bb..76d9299e2 100644 --- a/packages/app-tests/databaseExport.test.ts +++ b/packages/app-tests/databaseExport.test.ts @@ -7,6 +7,7 @@ import { buildDatabaseSqlExport, buildInsertStatements, formatSqlLiteral, + generateDatabaseExportId, } from "../../apps/desktop/src/lib/databaseExport.ts"; test("formats SQL literals for exported INSERT statements", () => { @@ -16,6 +17,24 @@ test("formats SQL literals for exported INSERT statements", () => { assert.equal(formatSqlLiteral("O'Hara"), "'O''Hara'"); }); +test("generates export ids when crypto.randomUUID is unavailable", () => { + const originalCrypto = globalThis.crypto; + + try { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: {}, + }); + + assert.match(generateDatabaseExportId(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + } finally { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }); + } +}); + test("builds batched INSERT statements for one exported table", () => { const statements = buildInsertStatements({ qualifiedTableName: "`users`", diff --git a/packages/app-tests/webRuntimeCompatibility.test.ts b/packages/app-tests/webRuntimeCompatibility.test.ts index 28ada9db1..a9acacd29 100644 --- a/packages/app-tests/webRuntimeCompatibility.test.ts +++ b/packages/app-tests/webRuntimeCompatibility.test.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; import { strict as assert } from "node:assert"; import test from "node:test"; @@ -6,6 +6,14 @@ const appSource = readFileSync("apps/desktop/src/App.vue", "utf8"); const connectionDialogSource = readFileSync("apps/desktop/src/components/connection/ConnectionDialog.vue", "utf8"); const driverStoreSource = readFileSync("apps/desktop/src/components/config/DriverStoreDialog.vue", "utf8"); +function appSourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = `${dir}/${entry}`; + if (statSync(path).isDirectory()) return appSourceFiles(path); + return /\.(ts|vue)$/.test(entry) ? [path] : []; + }); +} + test("web runtime handles driver store open events", () => { assert.match(appSource, /showDriverStore\.value = true;/); assert.doesNotMatch(appSource, /if \(!isDesktop\) return;\s+showDriverStore\.value = true;/); @@ -22,3 +30,11 @@ test("driver store uses the shared API instead of direct Tauri calls", () => { assert.match(driverStoreSource, /api\.listInstalledAgents/); assert.match(driverStoreSource, /api\.listenAgentInstallProgress/); }); + +test("web runtime uses the shared uuid helper instead of direct randomUUID calls", () => { + const directRandomUuidCalls = appSourceFiles("apps/desktop/src") + .filter((path) => path !== "apps/desktop/src/lib/utils.ts") + .filter((path) => readFileSync(path, "utf8").includes("crypto.randomUUID(")); + + assert.deepEqual(directRandomUuidCalls, []); +});