Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { AppSettingsStore } from '../modules/app-settings.module';
import { SignupModule } from '../modules/signup.module';
import { rateLimit } from './rate-limit';
import { runStatements, clampMaxRows, MAX_STATEMENTS, MAX_STATEMENT_LENGTH } from './sql-execute';
import { clampOffset } from './sql-page-wrap';
import { getMetadataDbConfig, SUPPORTED_ENGINES, type DbEngine } from '../database/config';
import { createMetadataStore } from '../database/stores/registry';
import { keySchemeInfo } from '../cores/crypto';
Expand Down Expand Up @@ -365,7 +366,11 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
// caps only. Rate-limited: each call can hold a DB connection for a while.
const sqlExecuteLimiter = rateLimit({ windowMs: 60 * 1000, max: 60 });
router.post('/sql/execute', sqlExecuteLimiter, async (req: Request, res: Response) => {
const { statements, maxRows, ...ref } = req.body as ConnectionRef & { statements?: unknown; maxRows?: unknown };
const { statements, maxRows, offset, ...ref } = req.body as ConnectionRef & {
statements?: unknown;
maxRows?: unknown;
offset?: unknown;
};
if (!Array.isArray(statements) || statements.length === 0) {
res.status(400).json({ error: 'statements[] is required.' });
return;
Expand Down Expand Up @@ -393,7 +398,8 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
resolved.option,
statements as string[],
clampMaxRows(maxRows),
resolved.schema
resolved.schema,
clampOffset(offset)
);
res.json({ results });
} catch (error: unknown) {
Expand Down
46 changes: 41 additions & 5 deletions apps/web/src/backend/api/sql-execute.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/core';
import { trimPageProbe, wrapSqlForPage } from './sql-page-wrap';

/**
* Helpers behind POST /api/sql/execute (SQL Editor). One request = one
Expand All @@ -16,6 +17,8 @@ export interface StatementResultOk {
rowCount: number;
/** True when the driver returned more rows than maxRows and the tail was dropped. */
truncated: boolean;
/** True when another page is available (page probe). */
hasNext?: boolean;
durationMs: number;
}
export interface StatementResultErr {
Expand Down Expand Up @@ -85,7 +88,8 @@ export async function runStatements(
option: ConnectionOptions,
statements: string[],
maxRows: number,
schema?: string
schema?: string,
offset = 0
): Promise<StatementResult[]> {
const schemaName = (schema ?? option.schema)?.trim() || '';
const optionWithSchema: ConnectionOptions = schemaName
Expand All @@ -102,15 +106,47 @@ export async function runStatements(
for (const sql of statements) {
const started = Date.now();
try {
const paged = wrapSqlForPage(sql, dialect, offset, maxRows);
const raw = await ConnectionFactory.executeOnConnection<Record<string, unknown>>(
dialect,
connection,
sql
paged
);
results.push({ ok: true, ...shapeRows(raw, maxRows), durationMs: Date.now() - started });
const shaped = shapeRows(raw, maxRows + 1);
const page = trimPageProbe(shaped, maxRows);
results.push({
ok: true,
columns: page.columns,
rows: page.rows,
rowCount: page.rowCount,
truncated: page.truncated,
hasNext: page.hasNext,
durationMs: Date.now() - started,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
results.push({ ok: false, error: message, durationMs: Date.now() - started });
// Fallback: run unwrapped (e.g. non-SELECT / dialect wrap rejection).
try {
const raw = await ConnectionFactory.executeOnConnection<Record<string, unknown>>(
dialect,
connection,
sql
);
const shaped = shapeRows(raw, maxRows);
results.push({
ok: true,
...shaped,
hasNext: false,
durationMs: Date.now() - started,
});
} catch (inner: unknown) {
const message = inner instanceof Error ? inner.message : String(inner);
const wrapMsg = error instanceof Error ? error.message : String(error);
results.push({
ok: false,
error: offset > 0 ? `${message} (page wrap: ${wrapMsg})` : message,
durationMs: Date.now() - started,
});
}
}
}
return results;
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/backend/api/sql-page-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { trimPageProbe, wrapSqlForPage } from './sql-page-wrap';

describe('sql-page-wrap', () => {
it('wraps postgres-style with LIMIT/OFFSET and +1 probe', () => {
expect(wrapSqlForPage('SELECT 1;', 'postgres', 40, 20)).toBe(
'SELECT * FROM (SELECT 1) AS _fox_page LIMIT 21 OFFSET 40'
);
});

it('wraps sqlserver with ORDER BY OFFSET FETCH', () => {
const sql = wrapSqlForPage('SELECT id FROM t', 'sqlserver', 0, 10);
expect(sql).toContain('OFFSET 0 ROWS FETCH NEXT 11 ROWS ONLY');
expect(sql).toContain('ORDER BY (SELECT NULL)');
});

it('trimPageProbe drops probe row and sets hasNext', () => {
const shaped = {
columns: ['a'],
rows: [[1], [2], [3]],
rowCount: 3,
truncated: false,
};
const t = trimPageProbe(shaped, 2);
expect(t.rows).toEqual([[1], [2]]);
expect(t.hasNext).toBe(true);
expect(t.truncated).toBe(true);
});
});
55 changes: 55 additions & 0 deletions apps/web/src/backend/api/sql-page-wrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Wrap a statement so the engine returns a page (LIMIT/OFFSET).
* Fetches `limit + 1` rows so the caller can detect `hasNext` without a COUNT.
*/

export function clampOffset(v: unknown): number {
const n = typeof v === 'number' ? Math.floor(v) : Number.NaN;
if (!Number.isFinite(n) || n < 0) return 0;
return Math.min(n, 1_000_000);
}

/**
* Best-effort page wrap. Dialects without OFFSET still get a subquery + LIMIT
* when offset is 0; non-zero offset uses the closest dialect syntax.
*/
export function wrapSqlForPage(
sql: string,
dialect: string,
offset: number,
limit: number
): string {
const trimmed = sql.trim().replace(/;+\s*$/, '');
const d = dialect.toLowerCase();
const inner = trimmed;
const fetchLimit = limit + 1; // +1 probe row

if (d === 'sqlserver' || d === 'mssql') {
// SQL Server requires ORDER BY for OFFSET/FETCH.
return `SELECT * FROM (${inner}) AS _fox_page ORDER BY (SELECT NULL) OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`;
}
if (d === 'oracle') {
return `SELECT * FROM (${inner}) _fox_page OFFSET ${offset} ROWS FETCH NEXT ${fetchLimit} ROWS ONLY`;
}
if (d === 'db2') {
return `SELECT * FROM (${inner}) AS _fox_page OFFSET ${offset} ROWS FETCH FIRST ${fetchLimit} ROWS ONLY`;
}
// Postgres, MySQL, MariaDB, SQLite, Cockroach, Yugabyte, TiDB, DuckDB, ClickHouse-ish
return `SELECT * FROM (${inner}) AS _fox_page LIMIT ${fetchLimit} OFFSET ${offset}`;
}

/** After shaping, drop the probe row and set truncated/hasNext. */
export function trimPageProbe<T extends { rows: unknown[][]; rowCount: number; truncated: boolean }>(
shaped: T,
pageSize: number
): T & { hasNext: boolean } {
const hasNext = shaped.rows.length > pageSize;
const rows = hasNext ? shaped.rows.slice(0, pageSize) : shaped.rows;
return {
...shaped,
rows,
rowCount: rows.length,
truncated: hasNext || shaped.truncated,
hasNext,
};
}
15 changes: 12 additions & 3 deletions apps/web/src/frontend/api/sqlApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import type { ConnectionRef } from './schemaApi';

/** One statement's outcome from POST /sql/execute (mirrors backend sql-execute.ts). */
export type SqlStatementResult =
| { ok: true; columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; durationMs: number }
| {
ok: true;
columns: string[];
rows: unknown[][];
rowCount: number;
truncated: boolean;
hasNext?: boolean;
durationMs: number;
}
| { ok: false; error: string; durationMs: number };

/**
Expand All @@ -14,13 +22,14 @@ export type SqlStatementResult =
export async function executeSql(
ref: ConnectionRef,
statements: string[],
maxRows?: number
maxRows?: number,
offset?: number
): Promise<{ results: SqlStatementResult[] }> {
const res = await fetch(`${getApiBase()}/sql/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ...ref, statements, maxRows }),
body: JSON.stringify({ ...ref, statements, maxRows, offset }),
});
const text = await res.text();
let data: { results?: SqlStatementResult[]; error?: string };
Expand Down
90 changes: 67 additions & 23 deletions apps/web/src/frontend/components/ProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { LogOut, Palette, ChevronDown, ArrowUpCircle, Globe } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { SettingsPanel } from './SettingsPanel';
Expand All @@ -9,11 +10,16 @@ export const ProfileMenu: React.FC = () => {
const [open, setOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [update, setUpdate] = useState<UpdateInfo | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
const t = e.target as Node;
if (ref.current?.contains(t) || menuRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
Expand All @@ -27,31 +33,45 @@ export const ProfileMenu: React.FC = () => {
};
}, []);

const placeMenu = () => {
const btn = buttonRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
setMenuPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
};

useLayoutEffect(() => {
if (!open) {
setMenuPos(null);
return;
}
placeMenu();
const onReposition = () => placeMenu();
window.addEventListener('resize', onReposition);
window.addEventListener('scroll', onReposition, true);
return () => {
window.removeEventListener('resize', onReposition);
window.removeEventListener('scroll', onReposition, true);
};
}, [open]);

if (!user) return null;

const updateAvailable = !!update?.updateAvailable;

return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 pl-2 pr-2 py-1.5 rounded-md border border-slate-700 hover:border-slate-600 hover:bg-slate-800/60 transition cursor-pointer"
>
<span className="relative w-6 h-6 rounded-full accent-grad on-accent-fg text-xs font-bold flex items-center justify-center uppercase">
{user.email.charAt(0)}
{updateAvailable && (
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-amber-400 ring-2 ring-slate-900" />
)}
</span>
<span className="text-sm text-slate-300 max-w-[160px] truncate hidden sm:block">{user.email}</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
</button>

{open && (
<div className="absolute right-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden">
const menu = open && menuPos
? createPortal(
<div
ref={menuRef}
data-testid="profile-menu-dropdown"
className="fixed w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[300] overflow-hidden"
style={{ top: menuPos.top, right: menuPos.right }}
>
<div className="px-4 py-3 border-b border-slate-800">
<p className="text-xs text-slate-500 uppercase tracking-wider font-bold">Signed in as</p>
<p className="text-sm text-slate-200 truncate" title={user.email}>{user.email}</p>
<p className="text-sm text-slate-200 truncate" title={user.email}>
{user.email}
</p>
</div>

{updateAvailable && (
Expand All @@ -67,6 +87,7 @@ export const ProfileMenu: React.FC = () => {
)}

<button
type="button"
onClick={() => {
setShowSettings(true);
setOpen(false);
Expand All @@ -87,13 +108,36 @@ export const ProfileMenu: React.FC = () => {
</a>

<button
type="button"
onClick={logout}
className="w-full flex items-center gap-2.5 px-4 py-3 text-sm font-bold text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 transition cursor-pointer border-t border-slate-800 bg-slate-950/40"
>
<LogOut className="w-4 h-4" /> Sign out
</button>
</div>
)}
</div>,
document.body
)
: null;

return (
<div ref={ref} className="relative z-[200]">
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 pl-2 pr-2 py-1.5 rounded-md border border-slate-700 hover:border-slate-600 hover:bg-slate-800/60 transition cursor-pointer"
>
<span className="relative w-6 h-6 rounded-full accent-grad on-accent-fg text-xs font-bold flex items-center justify-center uppercase">
{user.email.charAt(0)}
{updateAvailable && (
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-amber-400 ring-2 ring-slate-900" />
)}
</span>
<span className="text-sm text-slate-300 max-w-[160px] truncate hidden sm:block">{user.email}</span>
<ChevronDown className="w-3.5 h-3.5 text-slate-500" />
</button>

{menu}

<SettingsPanel open={showSettings} onClose={() => setShowSettings(false)} />
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/frontend/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const SettingsPanel: React.FC<Props> = ({ open, onClose }) => {

return createPortal(
<div
className="fixed inset-0 z-[95] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={onClose}
>
<div
Expand Down
Loading
Loading