diff --git a/apps/electron/main/server.ts b/apps/electron/main/server.ts index 4c6f2ead..f3f28080 100644 --- a/apps/electron/main/server.ts +++ b/apps/electron/main/server.ts @@ -179,7 +179,7 @@ export function createStandaloneServerManager({ isDev, userDataPath, databasePat if (pendingServerUrlPromise) return pendingServerUrlPromise; if (isDev) { - cachedServerUrl = process.env.ELECTRON_START_URL ?? 'http://127.0.0.1:3000'; + cachedServerUrl = process.env.ELECTRON_START_URL ?? 'http://localhost:3000'; return cachedServerUrl; } @@ -235,16 +235,9 @@ function readDesktopSecrets(filePath: string, logWarn: LogFn): Partial { + const levelName = level >= 3 ? 'error' : level === 2 ? 'warn' : 'info'; + log(`[renderer:${levelName}]`, message, sourceId ? `${sourceId}:${line}` : ''); + }); + + mainWindow.webContents.on('render-process-gone', (_event, details) => { + log('[electron] render process gone:', details.reason, details.exitCode); + }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; diff --git a/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/result-table/vtable/index.tsx b/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/result-table/vtable/index.tsx index 5342f516..9eb4cb0c 100644 --- a/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/result-table/vtable/index.tsx +++ b/apps/web/app/(app)/[organization]/[connectionId]/sql-console/components/result-table/vtable/index.tsx @@ -886,7 +886,7 @@ export default function VTable({ data-cell={`${r}-${colKeyName}`} style={{ ...style, display: 'flex', alignItems: 'center', boxShadow: selectionEdgeShadow }} className={cn( - 'px-2 text-sm border-b border-r last:border-r-0 cursor-pointer outline-none select-none', + 'px-2 text-sm border-b border-r cursor-pointer outline-none select-none', 'min-w-0 overflow-hidden', isRowSelected && PRIMARY_SELECTION_SUBTLE_CLASS, isCellSelected && PRIMARY_SELECTION_CLASS, diff --git a/apps/web/app/(app)/[organization]/connections/api.ts b/apps/web/app/(app)/[organization]/connections/api.ts index 701b63b1..76a11e75 100644 --- a/apps/web/app/(app)/[organization]/connections/api.ts +++ b/apps/web/app/(app)/[organization]/connections/api.ts @@ -126,8 +126,18 @@ export async function testConnection(params: CreateConnectionPayload & { timeout return res; } -export async function connectConnection(params: ConnectionListItem): Promise> { - const res = await fetchJsonResponse( +type ConnectConnectionResult = { + connectionId?: string; + identityId?: string | null; + status?: string; + lastCheckStatus?: 'ok' | 'error' | 'unknown'; + lastCheckAt?: string | null; + lastCheckLatencyMs?: number | null; + lastCheckError?: string | null; +}; + +export async function connectConnection(params: ConnectionListItem): Promise> { + const res = await fetchJsonResponse( '/api/connection/connect', { method: 'POST', diff --git a/apps/web/app/(app)/[organization]/connections/components/connection-dialog.tsx b/apps/web/app/(app)/[organization]/connections/components/connection-dialog.tsx index 60104a1a..444efe10 100644 --- a/apps/web/app/(app)/[organization]/connections/components/connection-dialog.tsx +++ b/apps/web/app/(app)/[organization]/connections/components/connection-dialog.tsx @@ -249,7 +249,11 @@ export function ConnectionDialog({ return ( - + event.preventDefault()} + > {isEditMode ? tc('Edit.title') : tc('Create.title')} diff --git a/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/duckdb.tsx b/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/duckdb.tsx index c61f7886..362db00a 100644 --- a/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/duckdb.tsx +++ b/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/duckdb.tsx @@ -5,7 +5,7 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/regi import { Input } from '@/registry/new-york-v4/ui/input'; import { Button } from '@/registry/new-york-v4/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/new-york-v4/ui/select'; -import { FieldHelp } from './shared'; +import { applySelectedLocalDatabasePath, FieldHelp } from './shared'; import { isDesktopRuntime } from '@dory/shared/runtime'; type DuckDbMode = 'local' | 'motherduck'; @@ -155,10 +155,7 @@ export function DuckDbConnectionFields({ form }: { form: UseFormReturn }) { if (!selectedPath) { return; } - form.setValue('connection.path', selectedPath, { - shouldDirty: true, - shouldValidate: true, - }); + applySelectedLocalDatabasePath(form, selectedPath); }} > Choose diff --git a/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/shared.tsx b/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/shared.tsx index 03648fdc..3526ac76 100644 --- a/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/shared.tsx +++ b/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/shared.tsx @@ -4,6 +4,33 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/new-york-v4/ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/registry/new-york-v4/ui/form'; import { Input } from '@/registry/new-york-v4/ui/input'; +export function inferLocalDatabaseTypeFromPath(filePath: string | null | undefined): 'duckdb' | 'sqlite' | null { + const extension = filePath + ?.trim() + .match(/\.([^.\\/]+)$/)?.[1] + ?.toLowerCase(); + if (extension === 'duckdb') return 'duckdb'; + if (extension === 'sqlite' || extension === 'sqlite3') return 'sqlite'; + return null; +} + +export function applySelectedLocalDatabasePath(form: UseFormReturn, selectedPath: string) { + const inferredType = inferLocalDatabaseTypeFromPath(selectedPath); + if (inferredType === 'duckdb') { + form.setValue('connection.type', 'duckdb', { shouldDirty: true, shouldValidate: false }); + form.setValue('connection.duckdbMode', 'local', { shouldDirty: true, shouldValidate: false }); + form.setValue('connection.database', '', { shouldDirty: true, shouldValidate: false }); + } else if (inferredType === 'sqlite') { + form.setValue('connection.type', 'sqlite', { shouldDirty: true, shouldValidate: false }); + form.setValue('connection.database', 'main', { shouldDirty: true, shouldValidate: false }); + } + + form.setValue('connection.path', selectedPath, { + shouldDirty: true, + shouldValidate: true, + }); +} + export function FieldHelp({ text }: { text: string }) { return ( diff --git a/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/sqlite.tsx b/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/sqlite.tsx index 37051523..52eeed95 100644 --- a/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/sqlite.tsx +++ b/apps/web/app/(app)/[organization]/connections/components/forms/connection/drivers/sqlite.tsx @@ -3,7 +3,7 @@ import { UseFormReturn } from 'react-hook-form'; import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/registry/new-york-v4/ui/form'; import { Input } from '@/registry/new-york-v4/ui/input'; import { Button } from '@/registry/new-york-v4/ui/button'; -import { FieldHelp } from './shared'; +import { applySelectedLocalDatabasePath, FieldHelp } from './shared'; import { useTranslations } from 'next-intl'; import { isDesktopRuntime } from '@dory/shared/runtime'; @@ -109,10 +109,7 @@ export function SqliteConnectionFields({ form }: { form: UseFormReturn }) { if (!selectedPath) { return; } - form.setValue('connection.path', selectedPath, { - shouldDirty: true, - shouldValidate: true, - }); + applySelectedLocalDatabasePath(form, selectedPath); }} > {t('Choose')} diff --git a/apps/web/app/(app)/[organization]/connections/components/local-files-dialog.tsx b/apps/web/app/(app)/[organization]/connections/components/local-files-dialog.tsx index 18bae05b..b9242cc1 100644 --- a/apps/web/app/(app)/[organization]/connections/components/local-files-dialog.tsx +++ b/apps/web/app/(app)/[organization]/connections/components/local-files-dialog.tsx @@ -81,10 +81,11 @@ export function LocalFilesDialog({ open, onOpenChange, onSuccess, mode = 'create const [advancedOpen, setAdvancedOpen] = useState(false); const canPickFile = isDesktopRuntime() && typeof window !== 'undefined' && typeof window.electron?.selectLocalFile === 'function'; + const isDesktop = isDesktopRuntime(); const relations = inspectResult?.relations ?? []; const isEditMode = mode === 'edit'; const busy = inspecting || creating || loadingDataset; - const canSave = Boolean(inspectResult && datasetName.trim() && selectedRelations.size > 0 && (!isEditMode || datasetId)); + const canSave = Boolean(datasetName.trim() && filePath.trim() && (!inspectResult || selectedRelations.size > 0) && (!isEditMode || datasetId)); const selectedRelationList = useMemo(() => { return relations.filter(relation => selectedRelations.has(relation.relationName)); @@ -199,6 +200,11 @@ export function LocalFilesDialog({ open, onOpenChange, onSuccess, mode = 'create if (!selectedPath) { return; } + setFilePath(selectedPath); + setDatasetName(current => current || defaultDatasetName(selectedPath)); + setInspectResult(null); + setSelectedRelations(new Set()); + setAdvancedOpen(false); await handleInspect(selectedPath); } catch (error: any) { toast.error(error?.message ?? 'Failed to choose local file'); @@ -235,16 +241,36 @@ export function LocalFilesDialog({ open, onOpenChange, onSuccess, mode = 'create }; const handleSave = async () => { - if (!inspectResult || !canSave) return; + if (!canSave) return; setCreating(true); try { + const trimmedPath = filePath.trim(); + const activeInspectResult = + inspectResult?.source.path === trimmedPath + ? inspectResult + : await inspectLocalFiles({ + source: { + backend: 'serverPath', + filePath: trimmedPath, + }, + }).then(result => { + if (!isSuccess(result) || !result.data) { + throw new Error(result.message || 'Failed to inspect local file'); + } + return result.data; + }); + const activeRelations = activeInspectResult.relations; + const activeSelectedRelations = inspectResult?.source.path === trimmedPath ? selectedRelationList : activeRelations; + if (activeSelectedRelations.length === 0) { + throw new Error('No supported sheets or tables were found in this file.'); + } const payload = { name: datasetName.trim(), source: { backend: 'serverPath' as const, - filePath: filePath.trim(), + filePath: trimmedPath, }, - relations: selectedRelationList, + relations: activeSelectedRelations, }; const result = isEditMode && datasetId ? await updateLocalFiles(datasetId, payload) : await createLocalFiles(payload); if (!isSuccess(result)) { @@ -263,7 +289,7 @@ export function LocalFilesDialog({ open, onOpenChange, onSuccess, mode = 'create return ( - + event.preventDefault()}> @@ -287,10 +313,12 @@ export function LocalFilesDialog({ open, onOpenChange, onSuccess, mode = 'create Choose ) : null} - + {!isDesktop ? ( + + ) : null} diff --git a/apps/web/app/(app)/[organization]/connections/form-schema.ts b/apps/web/app/(app)/[organization]/connections/form-schema.ts index 46ec3d4f..1b148384 100644 --- a/apps/web/app/(app)/[organization]/connections/form-schema.ts +++ b/apps/web/app/(app)/[organization]/connections/form-schema.ts @@ -14,6 +14,11 @@ function isAbsolutePath(value: string) { return /^(\/|[a-zA-Z]:[\\/])/.test(value); } +function getLowerPathExtension(value: string) { + const match = value.trim().match(/\.([^.\\/]+)$/); + return match?.[1]?.toLowerCase() ?? ''; +} + export const ConnectionDialogFormSchema = z .object({ connection: z.object({ @@ -55,6 +60,7 @@ export const ConnectionDialogFormSchema = z if (value.connection.type === 'sqlite') { const normalizedPath = value.connection.path?.trim() ?? ''; + const extension = getLowerPathExtension(normalizedPath); if (!normalizedPath) { ctx.addIssue({ code: 'custom', @@ -67,6 +73,12 @@ export const ConnectionDialogFormSchema = z path: ['connection', 'path'], message: 'SQLite path must be absolute', }); + } else if (extension === 'duckdb') { + ctx.addIssue({ + code: 'custom', + path: ['connection', 'path'], + message: 'This is a DuckDB file. Change the connection type to DuckDB.', + }); } return; } @@ -75,6 +87,7 @@ export const ConnectionDialogFormSchema = z const mode = value.connection.duckdbMode === 'motherduck' ? 'motherduck' : 'local'; if (mode === 'local') { const normalizedPath = value.connection.path?.trim() ?? ''; + const extension = getLowerPathExtension(normalizedPath); if (!normalizedPath) { ctx.addIssue({ code: 'custom', @@ -87,6 +100,12 @@ export const ConnectionDialogFormSchema = z path: ['connection', 'path'], message: 'DuckDB path must be absolute', }); + } else if (extension === 'sqlite' || extension === 'sqlite3') { + ctx.addIssue({ + code: 'custom', + path: ['connection', 'path'], + message: 'This is a SQLite file. Change the connection type to SQLite.', + }); } } else if (!value.identity.id && !value.identity.password?.trim()) { ctx.addIssue({ diff --git a/apps/web/app/(app)/[organization]/connections/hooks/use-connect-connection.ts b/apps/web/app/(app)/[organization]/connections/hooks/use-connect-connection.ts index 6bf778f7..2e024db6 100644 --- a/apps/web/app/(app)/[organization]/connections/hooks/use-connect-connection.ts +++ b/apps/web/app/(app)/[organization]/connections/hooks/use-connect-connection.ts @@ -1,6 +1,6 @@ 'use client'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, usePathname, useRouter } from 'next/navigation'; import { useSetAtom } from 'jotai'; import { toast } from 'sonner'; @@ -8,11 +8,13 @@ import { useTranslations } from 'next-intl'; import posthog from 'posthog-js'; import { connectConnection } from '../api'; -import { connectionLoadingAtom } from '../states'; +import { connectionLoadingAtom, connectionsAtom, searchResultAtom } from '../states'; import { currentConnectionAtom } from '@/shared/stores/app.store'; import type { ResponseObject } from '@dory/shared'; import type { ConnectionListItem } from '@dory/shared/types/connections'; +const CONNECTIONS_QUERY_KEY = ['connections'] as const; + type ConnectParams = { payload: ConnectionListItem; navigateToConsole?: boolean; @@ -36,8 +38,34 @@ function resolveTeamId(paramsTeam: unknown, pathname: string | null): string | n return segments[0] ?? null; } +type ConnectResponseData = { + lastCheckStatus?: 'ok' | 'error' | 'unknown'; + lastCheckAt?: string | Date | null; + lastCheckLatencyMs?: number | null; + lastCheckError?: string | null; +}; + +function withConnectedStatus(payload: ConnectionListItem, data: ConnectResponseData | null | undefined): ConnectionListItem { + const checkedAt = data?.lastCheckAt ?? new Date().toISOString(); + return { + ...payload, + connection: { + ...payload.connection, + lastCheckStatus: data?.lastCheckStatus ?? 'ok', + lastCheckAt: checkedAt instanceof Date ? checkedAt : new Date(checkedAt), + lastCheckLatencyMs: typeof data?.lastCheckLatencyMs === 'number' ? data.lastCheckLatencyMs : payload.connection.lastCheckLatencyMs, + lastCheckError: data?.lastCheckError ?? null, + }, + }; +} + +function updateConnectionListItem(list: ConnectionListItem[] | undefined, updated: ConnectionListItem) { + return (list ?? []).map(item => (item.connection.id === updated.connection.id ? { ...item, ...updated } : item)); +} + export function useConnectConnection() { const router = useRouter(); + const queryClient = useQueryClient(); const pathname = usePathname(); const params = useParams(); const t = useTranslations('Connections'); @@ -45,12 +73,13 @@ export function useConnectConnection() { const setConnectLoadings = useSetAtom(connectionLoadingAtom); const setCurrentConnection = useSetAtom(currentConnectionAtom); + const setConnections = useSetAtom(connectionsAtom); + const setSearchResult = useSetAtom(searchResultAtom); - return useMutation, Error, ConnectParams>({ + return useMutation, Error, ConnectParams>({ mutationFn: async ({ payload, identityId }) => { if (!payload?.connection?.id) throw new Error(t('Missing connection id')); - const requestPayload = identityId ? { ...payload, identityId } : payload; return connectConnection(requestPayload as ConnectionListItem & { identityId?: string | null }); }, @@ -64,10 +93,12 @@ export function useConnectConnection() { [makeLoadingKey(payload.connection.id, identityId)]: true, })); }, - onSuccess: (_res, { payload, navigateToConsole, setCurrentImmediately }) => { - if (setCurrentImmediately === false) { - setCurrentConnection(payload); - } + onSuccess: (res, { payload, navigateToConsole, setCurrentImmediately }) => { + const connectedPayload = withConnectedStatus(payload, res.data); + setCurrentConnection(connectedPayload); + queryClient.setQueryData(CONNECTIONS_QUERY_KEY, list => updateConnectionListItem(list, connectedPayload)); + setConnections(list => updateConnectionListItem(list, connectedPayload)); + setSearchResult(list => (list ? updateConnectionListItem(list, connectedPayload) : list)); posthog.capture('connection_opened', { connection_type: payload.connection.type, connection_id: payload.connection.id, diff --git a/apps/web/app/(auth)/components/SignInForm.desktop.tsx b/apps/web/app/(auth)/components/SignInForm.desktop.tsx index bebdaf22..3e175937 100644 --- a/apps/web/app/(auth)/components/SignInForm.desktop.tsx +++ b/apps/web/app/(auth)/components/SignInForm.desktop.tsx @@ -125,16 +125,12 @@ export function SignInForm({ } } - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); + async function submitEmailPassword() { + if (loading) return; + setErr(null); setMsg(null); - if (isDesktopOffline) { - setErr(t('SignIn.CloudFeaturesUnavailableOffline')); - return; - } - setLoading(true); try { const res = await fetch('/api/electron/auth/sign-in/email', { @@ -153,6 +149,13 @@ export function SignInForm({ posthog.identify(email, { email }); posthog.capture('user_signed_in', { method: 'email' }); await refetchSession(); + const sessionRes = await fetch('/api/electron/auth/session', { cache: 'no-store' }); + const sessionPayload = await sessionRes.json().catch(() => null); + if (!sessionRes.ok || !sessionPayload?.session) { + setErr(t('SignIn.LoginFailedRetry')); + posthog.capture('user_sign_in_failed', { method: 'email', error: 'desktop_session_missing_after_sign_in' }); + return; + } router.refresh(); router.push(callbackURL); } catch (e: any) { @@ -164,6 +167,11 @@ export function SignInForm({ } } + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + void submitEmailPassword(); + } + async function onForgotPassword() { if (isDesktopOffline) { setErr(t('SignIn.CloudFeaturesUnavailableOffline')); @@ -228,20 +236,33 @@ export function SignInForm({
- setEmail(e.target.value.trim())} autoComplete="email" /> + setEmail(e.target.value.trim())} + autoComplete="email" + />
-
setPwd(e.target.value)} autoComplete="current-password" />
- diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx index 78856c9e..a65f33d4 100644 --- a/apps/web/app/(auth)/reset-password/page.tsx +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -8,8 +8,8 @@ export default function ResetPasswordPage() { return (
- - + +
); } diff --git a/apps/web/app/(auth)/sign-in/page.tsx b/apps/web/app/(auth)/sign-in/page.tsx index 8622eeb0..06c09f1a 100644 --- a/apps/web/app/(auth)/sign-in/page.tsx +++ b/apps/web/app/(auth)/sign-in/page.tsx @@ -66,7 +66,7 @@ export default async function SignInPage({ -
+
*/} - + {/* */}
); diff --git a/apps/web/app/(auth)/sign-up/page.tsx b/apps/web/app/(auth)/sign-up/page.tsx index 4e698280..94f6d402 100644 --- a/apps/web/app/(auth)/sign-up/page.tsx +++ b/apps/web/app/(auth)/sign-up/page.tsx @@ -43,10 +43,10 @@ export default async function SignUpPage() { )} > -
+
- +
); } diff --git a/apps/web/app/api/connection/connect/route.ts b/apps/web/app/api/connection/connect/route.ts index 50f41e9c..180468c5 100644 --- a/apps/web/app/api/connection/connect/route.ts +++ b/apps/web/app/api/connection/connect/route.ts @@ -8,11 +8,11 @@ import { UnsupportedTypeError } from '@dory/drivers/core'; import { BaseConfig } from '@dory/drivers/types'; import { CONNECTION_REQUEST_TIMEOUT_MS, withConnectionTimeout } from '@dory/drivers/core'; import { destroyDriverPool, ensureDriverPool, getDriverPool } from '@dory/drivers/core'; -import { buildStoredConnectionConfig, pickConnectionIdentity } from '@dory/drivers/config'; +import { pickConnectionIdentity } from '@dory/drivers/config'; import { withUserAndOrganizationHandler } from '@/app/api/utils/with-organization-handler'; import { getApiLocale, translateApi } from '@/app/api/utils/i18n'; import { CONNECTION_ERROR_CODES, type ConnectionErrorCode, createConnectionError, getConnectionErrorCode } from '@/app/api/connection/utils'; -import { resolveStoredSqlitePath } from '@/lib/demo/paths'; +import { buildStoredConnectionConfig } from '@/lib/connection/config'; export const runtime = 'nodejs'; @@ -21,9 +21,7 @@ type SshWithSecrets = ConnectionSsh & { password?: string | null; privateKey?: s async function ensurePoolWithLatest(config: BaseConfig) { const existing = await getDriverPool(config.id); const needRefresh = - existing && - ((config.configVersion && existing.config.configVersion !== config.configVersion) || - (config.updatedAt && existing.config.updatedAt !== config.updatedAt)); + existing && ((config.configVersion && existing.config.configVersion !== config.configVersion) || (config.updatedAt && existing.config.updatedAt !== config.updatedAt)); if (needRefresh) { await destroyDriverPool(config.id); @@ -44,53 +42,33 @@ export const POST = withUserAndOrganizationHandler(async ({ req, db, organizatio // ignore, fall through with null payload } - const connectionId: string | undefined = - payload?.connectionId ?? payload?.id ?? payload?.connection?.id ?? undefined; - const identityId: string | null = - payload?.identityId ?? payload?.identity?.id ?? payload?.defaultIdentityId ?? null; + const connectionId: string | undefined = payload?.connectionId ?? payload?.id ?? payload?.connection?.id ?? undefined; + const identityId: string | null = payload?.identityId ?? payload?.identity?.id ?? payload?.defaultIdentityId ?? null; if (!connectionId) { - return NextResponse.json( - ResponseUtil.error({ code: ErrorCodes.INVALID_PARAMS, message: t('Api.Connection.Errors.MissingConnectionId') }), - { status: 400 }, - ); + return NextResponse.json(ResponseUtil.error({ code: ErrorCodes.INVALID_PARAMS, message: t('Api.Connection.Errors.MissingConnectionId') }), { status: 400 }); } try { const record = await db.connections.getById(organizationId, connectionId); if (!record) { - return NextResponse.json( - ResponseUtil.error({ code: ErrorCodes.NOT_FOUND, message: t('Api.Connection.Errors.NotFound') }), - { status: 404 }, - ); + return NextResponse.json(ResponseUtil.error({ code: ErrorCodes.NOT_FOUND, message: t('Api.Connection.Errors.NotFound') }), { status: 404 }); } const identity = pickConnectionIdentity(record.identities, identityId); if (!identity) { - return NextResponse.json( - ResponseUtil.error({ code: ErrorCodes.INVALID_PARAMS, message: t('Api.Connection.Errors.MissingIdentity') }), - { status: 400 }, - ); + return NextResponse.json(ResponseUtil.error({ code: ErrorCodes.INVALID_PARAMS, message: t('Api.Connection.Errors.MissingIdentity') }), { status: 400 }); } const passwordFromPayload = payload?.identity?.password ?? payload?.password ?? null; - const plainPassword = - passwordFromPayload ?? (identity.id ? await db.connections.getIdentityPlainPassword(organizationId, identity.id) : null); + const plainPassword = passwordFromPayload ?? (identity.id ? await db.connections.getIdentityPlainPassword(organizationId, identity.id) : null); const sshSecrets = await db.connections.getSshPlainSecrets(organizationId, record.connection.id); - const sshConfig: SshWithSecrets | null = record.ssh - ? { ...record.ssh, ...(sshSecrets ?? {}) } - : sshSecrets - ? ({ enabled: true, ...sshSecrets } as SshWithSecrets) - : null; - - const config = buildStoredConnectionConfig( - record.connection, - { ...identity, password: plainPassword }, - sshConfig, - code => createConnectionError(code as ConnectionErrorCode), - resolveStoredSqlitePath, + const sshConfig: SshWithSecrets | null = record.ssh ? { ...record.ssh, ...(sshSecrets ?? {}) } : sshSecrets ? ({ enabled: true, ...sshSecrets } as SshWithSecrets) : null; + + const config = buildStoredConnectionConfig(record.connection, { ...identity, password: plainPassword }, sshConfig, code => + createConnectionError(code as ConnectionErrorCode), ); const { health } = await withConnectionTimeout( @@ -116,6 +94,10 @@ export const POST = withUserAndOrganizationHandler(async ({ req, db, organizatio connectionId: config.id, identityId: identity.id ?? null, status: 'Connected', + lastCheckStatus: 'ok', + lastCheckAt: new Date().toISOString(), + lastCheckLatencyMs: tookMs, + lastCheckError: null, }), ); } catch (error) { diff --git a/apps/web/app/api/connection/test/service.ts b/apps/web/app/api/connection/test/service.ts index d9c4ee0f..3e75c019 100644 --- a/apps/web/app/api/connection/test/service.ts +++ b/apps/web/app/api/connection/test/service.ts @@ -6,8 +6,7 @@ import { createDriver } from '@dory/drivers/core'; import { getDBService } from '@dory/database'; import { TestConnectionPayload } from '@dory/shared/types/connections'; import { CONNECTION_ERROR_CODES, type ConnectionErrorCode, createConnectionError } from '@/app/api/connection/utils'; -import { buildTestConnectionConfig } from '@dory/drivers/config'; -import { resolveStoredSqlitePath } from '@/lib/demo/paths'; +import { buildTestConnectionConfig } from '@/lib/connection/config'; type SSHConfigWithSecrets = NonNullable & { password?: string | null; @@ -46,10 +45,8 @@ export async function testConnectService(organizationId: string, payload: TestCo const testPassword = payload?.identity?.password ?? plainPassword; const resolvedSsh = await resolveSshSecrets(organizationId, payload, db); - const config = buildTestConnectionConfig( - { ...payload, identity: { ...payload.identity, password: testPassword }, ssh: resolvedSsh }, - code => createConnectionError(code as ConnectionErrorCode), - resolveStoredSqlitePath, + const config = buildTestConnectionConfig({ ...payload, identity: { ...payload.identity, password: testPassword }, ssh: resolvedSsh }, code => + createConnectionError(code as ConnectionErrorCode), ); let provider = null as Awaited> | null; diff --git a/apps/web/app/api/connection/utils.ts b/apps/web/app/api/connection/utils.ts index 04caba67..38ac71e0 100644 --- a/apps/web/app/api/connection/utils.ts +++ b/apps/web/app/api/connection/utils.ts @@ -7,8 +7,8 @@ import { ErrorCodes } from '@dory/shared/errors'; import type { ConnectionListItem, ConnectionSsh } from '@dory/shared/types/connections'; import { BaseConfig } from '@dory/drivers/types'; import { destroyDriverPool, ensureDriverPool, getDriverPool } from '@dory/drivers/core'; -import { buildStoredConnectionConfig, parseConnectionOptions, pickConnectionIdentity } from '@dory/drivers/config'; -import { resolveStoredSqlitePath } from '@/lib/demo/paths'; +import { parseConnectionOptions, pickConnectionIdentity } from '@dory/drivers/config'; +import { buildStoredConnectionConfig } from '@/lib/connection/config'; type SshWithSecrets = ConnectionSsh & { password?: string | null; privateKey?: string | null; passphrase?: string | null }; @@ -74,10 +74,10 @@ export function normalizeOptions(raw: unknown): string | Record function isLocalFilesDatasetOptions(options: unknown) { return Boolean( options && - typeof options === 'object' && - !Array.isArray(options) && - (options as Record).managedBy === 'local-files' && - (options as Record).mode === 'localFilesDataset', + typeof options === 'object' && + !Array.isArray(options) && + (options as Record).managedBy === 'local-files' && + (options as Record).mode === 'localFilesDataset', ); } @@ -137,28 +137,15 @@ export async function ensureConnectionPoolForUser(userId: string, organizationId const plainPassword = identity.id ? await db.connections.getIdentityPlainPassword(organizationId, identity.id) : null; const sshSecrets = await db.connections.getSshPlainSecrets(organizationId, record.connection.id); - const sshConfig: SshWithSecrets | null = record.ssh - ? { ...record.ssh, ...(sshSecrets ?? {}) } - : sshSecrets - ? ({ enabled: true, ...sshSecrets } as SshWithSecrets) - : null; - - const config = buildStoredConnectionConfig( - record.connection, - { ...identity, password: plainPassword }, - sshConfig, - code => createConnectionError(code as ConnectionErrorCode), - resolveStoredSqlitePath, - ); + const sshConfig: SshWithSecrets | null = record.ssh ? { ...record.ssh, ...(sshSecrets ?? {}) } : sshSecrets ? ({ enabled: true, ...sshSecrets } as SshWithSecrets) : null; + + const config = buildStoredConnectionConfig(record.connection, { ...identity, password: plainPassword }, sshConfig, code => createConnectionError(code as ConnectionErrorCode)); const entry = await ensurePoolWithLatest(config); return { entry, config, identity }; } -export function mapConnectionErrorToResponse( - error: unknown, - messages: { notFound: string; missingHost: string; missingPath?: string; fallback: string }, -) { +export function mapConnectionErrorToResponse(error: unknown, messages: { notFound: string; missingHost: string; missingPath?: string; fallback: string }) { const code = getConnectionErrorCode(error); if (code === CONNECTION_ERROR_CODES.notFound) { @@ -170,10 +157,7 @@ export function mapConnectionErrorToResponse( } if (code === CONNECTION_ERROR_CODES.missingPath) { - return NextResponse.json( - ResponseUtil.error({ code: ErrorCodes.INVALID_PARAMS, message: messages.missingPath ?? messages.fallback }), - { status: 400 }, - ); + return NextResponse.json(ResponseUtil.error({ code: ErrorCodes.INVALID_PARAMS, message: messages.missingPath ?? messages.fallback }), { status: 400 }); } return NextResponse.json(ResponseUtil.error({ code: ErrorCodes.ERROR, message: messages.fallback }), { status: 500 }); diff --git a/apps/web/app/api/electron/auth/session/route.ts b/apps/web/app/api/electron/auth/session/route.ts new file mode 100644 index 00000000..768e0b75 --- /dev/null +++ b/apps/web/app/api/electron/auth/session/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +import { getSessionFromRequest } from '@/lib/auth/session'; +import { isDesktopRuntime } from '@dory/shared/runtime'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + if (!isDesktopRuntime()) { + return NextResponse.json({ ok: false, error: 'desktop_runtime_required' }, { status: 404 }); + } + + const session = await getSessionFromRequest(req); + return NextResponse.json({ + ok: Boolean(session?.session && session?.user), + session: session?.session ?? null, + user: session?.user ?? null, + }); +} diff --git a/apps/web/app/api/electron/auth/sign-in/email/route.ts b/apps/web/app/api/electron/auth/sign-in/email/route.ts index fd75ddd7..c919e189 100644 --- a/apps/web/app/api/electron/auth/sign-in/email/route.ts +++ b/apps/web/app/api/electron/auth/sign-in/email/route.ts @@ -21,10 +21,7 @@ function rewriteSetCookie(value: string, isSecureRequest: boolean): string { const parts = value.split(';'); const [nameValue, ...attrs] = parts; const normalizedAttrs = attrs.map(attr => attr.trim()); - const isClearingCookie = - /=\s*$/.test(nameValue) || - normalizedAttrs.some(attr => /^max-age=0$/i.test(attr)) || - normalizedAttrs.some(attr => /^expires=/i.test(attr)); + const isClearingCookie = /=\s*$/.test(nameValue) || normalizedAttrs.some(attr => /^max-age=0$/i.test(attr)) || normalizedAttrs.some(attr => /^expires=/i.test(attr)); let rewrittenNameValue = nameValue; if (!isSecureRequest && /^__Secure-/i.test(nameValue)) { @@ -51,6 +48,9 @@ export async function POST(req: Request) { const response = await proxyAuthRequest(req); const mirror = response.ok ? await mirrorCloudSessionToDesktop(req, response.headers) : null; if (!mirror) { + if (response.ok) { + return NextResponse.json({ error: 'Failed to create a local desktop session. Please try again.' }, { status: 502 }); + } return response; } @@ -75,7 +75,10 @@ export async function POST(req: Request) { asResponse: true, }); - const payload = await response.clone().json().catch(() => null); + const payload = await response + .clone() + .json() + .catch(() => null); const res = NextResponse.json(payload ?? { ok: response.ok }, { status: response.status }); const isSecureRequest = new URL(req.url).protocol === 'https:'; diff --git a/apps/web/components/originui/input-password.tsx b/apps/web/components/originui/input-password.tsx index 061b1c35..d4a9bc3f 100644 --- a/apps/web/components/originui/input-password.tsx +++ b/apps/web/components/originui/input-password.tsx @@ -1,27 +1,26 @@ 'use client'; import { Input } from '@/registry/new-york-v4/ui/input'; +import { cn } from '@dory/web-utils'; import { Eye, EyeOff } from 'lucide-react'; -import React, { useId, useState } from 'react'; +import React, { useState } from 'react'; - -const InputPassword = React.forwardRef>(({ className: _className, type: _type, ...props }, ref) => { - const id = useId(); +const InputPassword = React.forwardRef>(({ className, id, type: _type, ...props }, ref) => { const [isVisible, setIsVisible] = useState(false); const toggleVisibility = () => setIsVisible(prevState => !prevState); return ( -
+
- + diff --git a/apps/web/lib/connection/config.ts b/apps/web/lib/connection/config.ts new file mode 100644 index 00000000..45148306 --- /dev/null +++ b/apps/web/lib/connection/config.ts @@ -0,0 +1,68 @@ +import { + buildStoredConnectionConfig as buildStoredDriverConnectionConfig, + buildTestConnectionConfig as buildTestDriverConnectionConfig, + parseConnectionOptions, + type StoredDriverConnection, + type StoredDriverIdentity, + type StoredDriverSsh, + type TestDriverConnectionPayload, +} from '@dory/drivers/config'; + +import { isDemoDuckDbResourcePath, resolveStoredDatabasePath } from '@/lib/demo/paths'; + +type ErrorFactory = (code: string) => Error; + +function withDemoDuckDbReadOnlyOptions(connection: Connection): Connection { + const type = (connection.type ?? connection.engine)?.toLowerCase(); + if (type !== 'duckdb' || !isDemoDuckDbResourcePath(connection.path)) { + return connection; + } + + const options = parseConnectionOptions(connection.options) ?? {}; + const instanceOptions = + options.instanceOptions && typeof options.instanceOptions === 'object' && !Array.isArray(options.instanceOptions) + ? { ...(options.instanceOptions as Record) } + : {}; + + return { + ...connection, + options: { + ...options, + instanceOptions: { + ...instanceOptions, + access_mode: typeof instanceOptions.access_mode === 'string' ? instanceOptions.access_mode : 'READ_ONLY', + }, + }, + }; +} + +export function buildStoredConnectionConfig( + connection: StoredDriverConnection, + identity: StoredDriverIdentity & { password?: string | null }, + ssh?: (StoredDriverSsh & { password?: string | null; privateKey?: string | null; passphrase?: string | null }) | null, + createError?: ErrorFactory, +) { + return buildStoredDriverConnectionConfig(withDemoDuckDbReadOnlyOptions(connection), identity, ssh, createError, resolveStoredDatabasePath); +} + +export function buildTestConnectionConfig( + payload: TestDriverConnectionPayload & { + ssh?: + | (NonNullable & { + password?: string | null; + privateKey?: string | null; + passphrase?: string | null; + }) + | null; + }, + createError?: ErrorFactory, +) { + return buildTestDriverConnectionConfig( + { + ...payload, + connection: withDemoDuckDbReadOnlyOptions(payload.connection), + }, + createError, + resolveStoredDatabasePath, + ); +} diff --git a/apps/web/lib/connection/connection-service.ts b/apps/web/lib/connection/connection-service.ts index f5936fb7..212b0159 100644 --- a/apps/web/lib/connection/connection-service.ts +++ b/apps/web/lib/connection/connection-service.ts @@ -2,19 +2,19 @@ import { getDBService } from '@dory/database'; import '@/lib/drivers/register-database-drivers'; import { destroyDriverPool, ensureDriverPool, getDriverPool, type DriverPoolEntry } from '@dory/drivers/core'; -import { buildStoredConnectionConfig, parseConnectionOptions, pickConnectionIdentity } from '@dory/drivers/config'; +import { parseConnectionOptions, pickConnectionIdentity } from '@dory/drivers/config'; import type { ConnectionListItem, ConnectionSsh } from '@dory/shared/types/connections'; -import { resolveStoredSqlitePath } from '@/lib/demo/paths'; +import { buildStoredConnectionConfig } from '@/lib/connection/config'; type SshWithSecrets = ConnectionSsh & { password?: string | null; privateKey?: string | null; passphrase?: string | null }; function isLocalFilesDatasetOptions(options: unknown) { return Boolean( options && - typeof options === 'object' && - !Array.isArray(options) && - (options as Record).managedBy === 'local-files' && - (options as Record).mode === 'localFilesDataset', + typeof options === 'object' && + !Array.isArray(options) && + (options as Record).managedBy === 'local-files' && + (options as Record).mode === 'localFilesDataset', ); } @@ -43,10 +43,7 @@ async function withLocalFilesSchema(organizationId: string, record: ConnectionLi }; } -export async function getOrCreateConnectionPool( - organizationId: string, - connectionId: string, -): Promise { +export async function getOrCreateConnectionPool(organizationId: string, connectionId: string): Promise { const existing = await getDriverPool(connectionId); if (existing && !isLocalFilesDatasetOptions(existing.config.options)) return existing; @@ -60,19 +57,9 @@ export async function getOrCreateConnectionPool( const plainPassword = identity.id ? await db.connections.getIdentityPlainPassword(organizationId, identity.id) : null; const sshSecrets = await db.connections.getSshPlainSecrets(organizationId, record.connection.id); - const sshConfig: SshWithSecrets | null = record.ssh - ? { ...record.ssh, ...(sshSecrets ?? {}) } - : sshSecrets - ? ({ enabled: true, ...sshSecrets } as SshWithSecrets) - : null; + const sshConfig: SshWithSecrets | null = record.ssh ? { ...record.ssh, ...(sshSecrets ?? {}) } : sshSecrets ? ({ enabled: true, ...sshSecrets } as SshWithSecrets) : null; - const config = buildStoredConnectionConfig( - record.connection, - { ...identity, password: plainPassword }, - sshConfig, - undefined, - resolveStoredSqlitePath, - ); + const config = buildStoredConnectionConfig(record.connection, { ...identity, password: plainPassword }, sshConfig); if (existing) { const currentOptions = JSON.stringify(existing.config.options ?? {}); const nextOptions = JSON.stringify(config.options ?? {}); diff --git a/apps/web/lib/demo/connection-path.ts b/apps/web/lib/demo/connection-path.ts index 411e8aaa..dd49f3ec 100644 --- a/apps/web/lib/demo/connection-path.ts +++ b/apps/web/lib/demo/connection-path.ts @@ -1,5 +1,10 @@ export const DEMO_SQLITE_CONNECTION_PATH = 'dory://demo-sqlite'; +export const DEMO_DUCKDB_CONNECTION_PATH = 'dory://demo-duckdb'; export function isDemoSqliteConnectionPath(value: string | null | undefined): boolean { return value?.trim() === DEMO_SQLITE_CONNECTION_PATH; } + +export function isDemoDuckDbConnectionPath(value: string | null | undefined): boolean { + return value?.trim() === DEMO_DUCKDB_CONNECTION_PATH; +} diff --git a/apps/web/lib/demo/paths.ts b/apps/web/lib/demo/paths.ts index fee6418b..2bcd4180 100644 --- a/apps/web/lib/demo/paths.ts +++ b/apps/web/lib/demo/paths.ts @@ -1,16 +1,61 @@ import path from 'node:path'; import fs from 'node:fs'; -import { DEMO_SQLITE_CONNECTION_PATH, isDemoSqliteConnectionPath } from './connection-path'; +import { fileURLToPath } from 'node:url'; +import { isDemoDuckDbConnectionPath, isDemoSqliteConnectionPath } from './connection-path'; const DEMO_SQLITE_FILENAME = 'demo.sqlite'; +const DEMO_DUCKDB_FILENAME = 'demo.duckdb'; const DEMO_SQLITE_DIR = path.join('public', 'resources'); const DEMO_SQLITE_RELATIVE_PATH = path.join(DEMO_SQLITE_DIR, DEMO_SQLITE_FILENAME); +const DEMO_DUCKDB_RELATIVE_PATH = path.join(DEMO_SQLITE_DIR, DEMO_DUCKDB_FILENAME); function getDemoSqlitePathCandidates(): string[] { - return [ - path.resolve(process.cwd(), DEMO_SQLITE_RELATIVE_PATH), - path.resolve(process.cwd(), 'apps', 'web', DEMO_SQLITE_RELATIVE_PATH), - ]; + return [path.resolve(process.cwd(), DEMO_SQLITE_RELATIVE_PATH), path.resolve(process.cwd(), 'apps', 'web', DEMO_SQLITE_RELATIVE_PATH)]; +} + +function getDemoDuckDbPathCandidates(): string[] { + return [path.resolve(process.cwd(), DEMO_DUCKDB_RELATIVE_PATH), path.resolve(process.cwd(), 'apps', 'web', DEMO_DUCKDB_RELATIVE_PATH)]; +} + +function resolveFileUrlPath(value: string | undefined): string | null { + if (!value?.startsWith('file:')) return null; + try { + return fileURLToPath(value); + } catch { + return null; + } +} + +function getDemoResourceCacheRoot(): string { + const configured = process.env.DORY_DEMO_RESOURCE_CACHE_DIR?.trim(); + if (configured) return configured; + + const pglitePath = resolveFileUrlPath(process.env.PGLITE_DB_PATH); + if (pglitePath) { + return path.join(path.dirname(pglitePath), 'demo-resources'); + } + + return path.join(process.cwd(), 'localdata', 'demo-resources'); +} + +function ensureRuntimeResourceCopy(sourcePath: string, filename: string): string { + const targetPath = path.join(getDemoResourceCacheRoot(), filename); + try { + const sourceStat = fs.statSync(sourcePath); + const targetStat = fs.existsSync(targetPath) ? fs.statSync(targetPath) : null; + const needsCopy = !targetStat || targetStat.size !== sourceStat.size || Math.trunc(targetStat.mtimeMs) < Math.trunc(sourceStat.mtimeMs); + + if (needsCopy) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o644); + } + + return targetPath; + } catch (error) { + console.warn('[demo] failed to prepare runtime demo resource copy, falling back to bundled resource', error); + return sourcePath; + } } /** @@ -29,6 +74,29 @@ export function resolveDemoSqlitePath(): string { return path.resolve(process.cwd(), 'apps', 'web', DEMO_SQLITE_RELATIVE_PATH); } +export function resolveDemoDuckDbPath(): string { + const existingPath = getDemoDuckDbPathCandidates().find(candidate => fs.existsSync(candidate)); + + if (existingPath) { + return ensureRuntimeResourceCopy(existingPath, DEMO_DUCKDB_FILENAME); + } + + return path.resolve(process.cwd(), 'apps', 'web', DEMO_DUCKDB_RELATIVE_PATH); +} + +export function isDemoDuckDbResourcePath(value: string | null | undefined): boolean { + const normalized = value?.trim(); + if (!normalized) return false; + if (isDemoDuckDbConnectionPath(normalized)) return true; + + const parsed = path.parse(normalized); + if (parsed.base !== DEMO_DUCKDB_FILENAME) return false; + + const parent = path.basename(parsed.dir); + const grandparent = path.basename(path.dirname(parsed.dir)); + return parent === 'resources' && grandparent === 'public'; +} + export function resolveStoredSqlitePath(value: string | null | undefined): string | undefined { const normalized = value?.trim(); if (!normalized) return undefined; @@ -38,6 +106,18 @@ export function resolveStoredSqlitePath(value: string | null | undefined): strin return normalized; } +export function resolveStoredDatabasePath(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + if (isDemoSqliteConnectionPath(normalized)) { + return resolveDemoSqlitePath(); + } + if (isDemoDuckDbConnectionPath(normalized) || isDemoDuckDbResourcePath(normalized)) { + return resolveDemoDuckDbPath(); + } + return normalized; +} + /** * Get the fixed absolute path for demo.sqlite. */ diff --git a/apps/web/lib/local-files/service.ts b/apps/web/lib/local-files/service.ts index c63c20cd..d835c1dc 100644 --- a/apps/web/lib/local-files/service.ts +++ b/apps/web/lib/local-files/service.ts @@ -43,13 +43,12 @@ type RefreshRelationInput = { const DATASET_CONNECTION_OPTION_MODE = 'localFilesDataset'; -function assertSelfHostedNodeRuntime() { - if (isDesktopRuntime()) { - throw new Error('Local Files are only available in self-hosted web runtime'); - } +function assertLocalFilesRuntime() { + if (isDesktopRuntime()) return; + const runtime = getRuntimeForServer(); if (runtime && runtime !== 'web' && runtime !== 'docker') { - throw new Error('Local Files are only available in self-hosted web runtime'); + throw new Error('Open Files are only available in desktop or self-hosted web runtime'); } } @@ -102,7 +101,10 @@ function getWorkspacePath(organizationId: string, connectionId?: string) { function schemaNameForDataset(name: string, connectionId: string, explicitSchemaName?: string) { const base = normalizeDatasetSchemaName(explicitSchemaName || name); - const suffix = connectionId.replace(/[^a-zA-Z0-9]/g, '').slice(-8).toLowerCase(); + const suffix = connectionId + .replace(/[^a-zA-Z0-9]/g, '') + .slice(-8) + .toLowerCase(); return normalizeDatasetSchemaName(`${base}_${suffix || 'dataset'}`); } @@ -348,7 +350,7 @@ async function runRefreshPipeline(ctx: LocalFilesContext, connection: BaseConnec } export async function inspectLocalFiles(ctx: LocalFilesContext, request: LocalFilesInspectRequest) { - assertSelfHostedNodeRuntime(); + assertLocalFilesRuntime(); const source = await statSource(request.source); const relations = await inspectSource(request.source); return { @@ -358,7 +360,7 @@ export async function inspectLocalFiles(ctx: LocalFilesContext, request: LocalFi } export async function createLocalFilesDataset(ctx: LocalFilesContext, request: LocalFilesCreateRequest) { - assertSelfHostedNodeRuntime(); + assertLocalFilesRuntime(); const source = await statSource(request.source); const connectionId = newEntityId(); const workspacePath = getWorkspacePath(ctx.organizationId, connectionId); @@ -558,7 +560,7 @@ function toDatasetDetail(record: { } export async function getLocalFilesDataset(ctx: LocalFilesContext, request: { datasetId?: string | null; connectionId?: string | null }) { - assertSelfHostedNodeRuntime(); + assertLocalFilesRuntime(); const record = (request.connectionId ? await ctx.db.localFiles.getDatasetByConnectionId(ctx.organizationId, request.connectionId) : null) ?? (request.datasetId ? await ctx.db.localFiles.getDataset(ctx.organizationId, request.datasetId) : null); @@ -569,7 +571,7 @@ export async function getLocalFilesDataset(ctx: LocalFilesContext, request: { da } export async function updateLocalFilesDataset(ctx: LocalFilesContext, datasetId: string, request: LocalFilesUpdateRequest) { - assertSelfHostedNodeRuntime(); + assertLocalFilesRuntime(); const existing = await ctx.db.localFiles.getDataset(ctx.organizationId, datasetId); if (!existing) { throw new Error('Local Files dataset not found'); @@ -666,7 +668,7 @@ export async function updateLocalFilesDataset(ctx: LocalFilesContext, datasetId: } export async function refreshLocalFilesDataset(ctx: LocalFilesContext, request: LocalFilesRefreshRequest) { - assertSelfHostedNodeRuntime(); + assertLocalFilesRuntime(); const record = await ctx.db.localFiles.getDataset(ctx.organizationId, request.datasetId); if (!record) { throw new Error('Local Files dataset not found'); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 42177c42..72970e06 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -44,6 +44,7 @@ const nextConfig = { '/*': [ './registry/**/*', './public/resources/demo.sqlite', + './public/resources/demo.duckdb', '../../packages/database/src/pglite/migrations.json', '../../packages/database/src/postgres/migrations/**/*', '../../packages/database/src/pglite/migrations/**/*', diff --git a/packages/drivers/src/database/duckdb/duckdb-driver.ts b/packages/drivers/src/database/duckdb/duckdb-driver.ts index 8aebf0bf..9e363dcc 100644 --- a/packages/drivers/src/database/duckdb/duckdb-driver.ts +++ b/packages/drivers/src/database/duckdb/duckdb-driver.ts @@ -69,6 +69,9 @@ function resolveDuckDbInstanceOptions(config: BaseConfig): Record