Minor: move code around.

There's many things that could be broken up. Yet, let's at least move things into places where they're a bit more self-documenting.
This commit is contained in:
Sebastian Jeltsch
2025-11-05 20:43:24 +01:00
parent c6a639d383
commit a6ba62bcfe
38 changed files with 205 additions and 198 deletions

View File

@@ -12,7 +12,7 @@ import { IndexPage } from "@/components/IndexPage";
import { VerticalNavBar, HorizontalNavBar } from "@/components/NavBar";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { $user } from "@/lib/fetch";
import { $user } from "@/lib/client";
import { createWindowWidth } from "@/lib/signals";
const queryClient = new QueryClient();

View File

@@ -5,7 +5,7 @@ import {
} from "solid-js";
import type { JSX } from "solid-js";
import { client } from "@/lib/fetch";
import { client } from "@/lib/client";
import { Toaster, showToast } from "@/components/ui/toast";
import { Button } from "@/components/ui/button";

View File

@@ -10,8 +10,8 @@ import {
TbSettings,
} from "solid-icons/tb";
import { executeSql } from "@/lib/fetch";
import { castToInteger } from "@/lib/convert";
import { executeSql } from "@/lib/api/execute";
import type { SqlValue } from "@/lib/value";
import { Header } from "@/components/Header";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
@@ -240,3 +240,10 @@ export function IndexPage() {
</div>
);
}
function castToInteger(value: SqlValue): bigint {
if (typeof value === "object" && "Integer" in value) {
return value.Integer;
}
throw Error(`Expected integer, got: ${value}`);
}

View File

@@ -17,7 +17,7 @@ import {
} from "@/components/ui/tooltip";
import { Version } from "@/components/Version";
import { createVersionInfoQuery } from "@/lib/info";
import { createVersionInfoQuery } from "@/lib/api/info";
import logo from "@/assets/logo_104.webp";

View File

@@ -38,8 +38,9 @@ import {
} from "@/components/FormFields";
import { SafeSheet, SheetContainer } from "@/components/SafeSheet";
import { deleteUser, updateUser, fetchUsers } from "@/lib/user";
import { deleteUser, updateUser, fetchUsers } from "@/lib/api/user";
import { copyToClipboard } from "@/lib/utils";
import type { UpdateUserRequest } from "@bindings/UpdateUserRequest";
import type { UserJson } from "@bindings/UserJson";

View File

@@ -3,14 +3,14 @@ import { createForm } from "@tanstack/solid-form";
import { Button } from "@/components/ui/button";
import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet";
import {
buildBoolFormField,
buildTextFormField,
buildSecretFormField,
notEmptyValidator,
} from "@/components/FormFields";
import { createUser } from "@/lib/user";
import { createUser } from "@/lib/api/user";
import type { CreateUserRequest } from "@bindings/CreateUserRequest";

View File

@@ -4,7 +4,7 @@ import { TbUser } from "solid-icons/tb";
import { type User } from "trailbase";
import { urlSafeBase64ToUuid } from "@/lib/utils";
import { client, $user } from "@/lib/fetch";
import { client, $user } from "@/lib/client";
import { Button } from "@/components/ui/button";
import {
Dialog,

View File

@@ -1,4 +1,4 @@
import { client } from "@/lib/fetch";
import { client } from "@/lib/client";
import { showToast } from "@/components/ui/toast";
import { Badge } from "@/components/ui/badge";

View File

@@ -60,11 +60,11 @@ import type { QueryResponse } from "@bindings/QueryResponse";
import type { ListSchemasResponse } from "@bindings/ListSchemasResponse";
import type { SqlValue } from "@bindings/SqlValue";
import { createTableSchemaQuery } from "@/lib/table";
import { executeSql, type ExecutionResult } from "@/lib/fetch";
import { createTableSchemaQuery } from "@/lib/api/table";
import { executeSql, type ExecutionResult } from "@/lib/api/execute";
import { isNotNull } from "@/lib/schema";
import { sqlValueToString } from "@/lib/value";
import type { RowData } from "@/lib/convert";
import type { ArrayRecord } from "@/lib/record";
function buildSchema(schemas: ListSchemasResponse): SQLNamespace {
const schema: {
@@ -136,13 +136,13 @@ function ResultView(props: {
const isCached = () => props.response === undefined;
const response = () => props.response ?? props.script.result;
function columnDefs(data: QueryResponse): ColumnDef<RowData, SqlValue>[] {
function columnDefs(data: QueryResponse): ColumnDef<ArrayRecord, SqlValue>[] {
return (data.columns ?? []).map((col, idx) => {
const notNull = isNotNull(col.options);
const header = `${col.name} [${col.data_type}${notNull ? "" : "?"}]`;
return {
accessorFn: (row: RowData) => {
accessorFn: (row: ArrayRecord) => {
console.log(row);
return sqlValueToString(row[idx]);
},
@@ -184,7 +184,7 @@ function ResultView(props: {
{/* TODO: Enable pagination */}
<DataTable
columns={() => columnDefs(response()!.data!)}
data={() => response()!.data!.rows as RowData[]}
data={() => response()!.data!.rows as ArrayRecord[]}
pagination={{
pageIndex: 0,
pageSize: 50,

View File

@@ -1,5 +1,5 @@
import { Switch, Match, createMemo } from "solid-js";
import { createTableSchemaQuery } from "@/lib/table";
import { createTableSchemaQuery } from "@/lib/api/table";
import { prettyFormatQualifiedName } from "@/lib/schema";
import { Header } from "@/components/Header";
@@ -12,7 +12,7 @@ import {
NODE_WIDTH,
LINE_HEIGHT,
EDGE_COLOR,
} from "@/components/ErdGraph";
} from "@/components/erd/ErdGraph";
import {
getForeignKey,

View File

@@ -42,7 +42,7 @@ import {
import { DataTable, safeParseInt } from "@/components/Table";
import { FilterBar } from "@/components/FilterBar";
import { getLogs } from "@/lib/logs";
import { getLogs } from "@/lib/api/logs";
import { copyToClipboard } from "@/lib/utils";
import countriesGeoJSON from "@/assets/countries-110m.json";

View File

@@ -39,7 +39,7 @@ import {
OAuthProviderConfig,
OAuthProviderId,
} from "@proto/config";
import { createConfigQuery, setConfig } from "@/lib/config";
import { createConfigQuery, setConfig } from "@/lib/api/config";
import { adminFetch } from "@/lib/fetch";
import { createSetOnce } from "@/lib/signals";
import { showSaveFileDialog, pathJoin, copyToClipboard } from "@/lib/utils";

View File

@@ -44,8 +44,9 @@ import type { FormApiT } from "@/components/FormFields";
import type { TestEmailRequest } from "@bindings/TestEmailRequest";
import { Config, EmailConfig, SmtpEncryption } from "@proto/config";
import { createConfigQuery, setConfig } from "@/lib/config";
import { $user, adminFetch } from "@/lib/fetch";
import { createConfigQuery, setConfig } from "@/lib/api/config";
import { $user } from "@/lib/client";
import { adminFetch } from "@/lib/fetch";
import DEFAULT_EMAIL_VERIFICATION_SUBJECT from "@templates/default_email_verification_subject.txt?raw";
import DEFAULT_EMAIL_VERIFICATION_BODY from "@templates/default_email_verification_body.html?raw";

View File

@@ -24,8 +24,8 @@ import {
import { type FieldApiT, FieldInfo } from "@/components/FormFields";
import { Config, JobsConfig, SystemJob } from "@proto/config";
import { createConfigQuery, setConfig } from "@/lib/config";
import { listJobs, runJob } from "@/lib/jobs";
import { createConfigQuery, setConfig } from "@/lib/api/config";
import { listJobs, runJob } from "@/lib/api/jobs";
import type { Job } from "@bindings/Job";
const cronRegex =

View File

@@ -34,8 +34,8 @@ import {
createConfigQuery,
setConfig,
invalidateAllAdminQueries,
} from "@/lib/config";
import { createVersionInfoQuery } from "@/lib/info";
} from "@/lib/api/config";
import { createVersionInfoQuery } from "@/lib/api/info";
function ServerSettings(props: CommonProps) {
const queryClient = useQueryClient();

View File

@@ -15,7 +15,7 @@ import {
import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet";
import { showToast } from "@/components/ui/toast";
import { alterIndex, createIndex } from "@/lib/table";
import { alterIndex, createIndex } from "@/lib/api/table";
import {
buildTextFormField,
buildBoolFormField,

View File

@@ -15,7 +15,7 @@ import {
} from "@/components/ui/dialog";
import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet";
import { createTable, alterTable } from "@/lib/table";
import { createTable, alterTable } from "@/lib/api/table";
import { randomName } from "@/lib/name";
import {
buildBoolFormField,
@@ -28,7 +28,7 @@ import {
newDefaultColumn,
primaryKeyPresets,
} from "@/components/tables/CreateAlterColumnForm";
import { invalidateConfig } from "@/lib/config";
import { invalidateConfig } from "@/lib/api/config";
import type { Column } from "@bindings/Column";
import type { Table } from "@bindings/Table";

View File

@@ -25,9 +25,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { literalDefault, shallowCopySqlValue } from "@/lib/convert";
import type { Record } from "@/lib/convert";
import { updateRow, insertRow } from "@/lib/row";
import type { Record } from "@/lib/record";
import { updateRow, insertRow } from "@/lib/api/row";
import {
sqlValueToString,
getInteger,
@@ -51,6 +50,7 @@ import {
isPrimaryKeyColumn,
isNullableColumn,
getForeignKey,
literalDefault,
} from "@/lib/schema";
function buildDefaultRecord(schema: Table): Record {
@@ -887,3 +887,15 @@ function validateInsertSqlValueFormField({
// Pass validation.
return undefined;
}
function shallowCopySqlValue(
value: SqlValue | undefined,
): SqlValue | undefined {
if (value === undefined) {
return undefined;
}
if (value === "Null") {
return "Null";
}
return { ...value };
}

View File

@@ -62,9 +62,8 @@ import {
RecordApiConfig,
} from "@proto/config";
import { createConfigQuery, setConfig } from "@/lib/config";
import { literalDefault } from "@/lib/convert";
import { parseSqlExpression } from "@/lib/parse";
import { createConfigQuery, setConfig } from "@/lib/api/config";
import { parseSqlExpression } from "@/lib/api/parse";
import {
getColumns,
getDefaultValue,
@@ -72,9 +71,10 @@ import {
isNotNull,
isNullableColumn,
isPrimaryKeyColumn,
literalDefault,
tableType,
} from "@/lib/schema";
import { client } from "@/lib/fetch";
import { client } from "@/lib/client";
import { fromHex } from "@/lib/utils";
import type { ForeignKey } from "@bindings/ForeignKey";

View File

@@ -55,12 +55,12 @@ import {
UploadedFiles,
} from "@/components/tables/Files";
import { createConfigQuery, invalidateConfig } from "@/lib/config";
import type { Record, RowData } from "@/lib/convert";
import { hashSqlValue } from "@/lib/convert";
import { createConfigQuery, invalidateConfig } from "@/lib/api/config";
import type { Record, ArrayRecord } from "@/lib/record";
import { hashSqlValue } from "@/lib/value";
import { urlSafeBase64ToUuid, toHex } from "@/lib/utils";
import { dropTable, dropIndex } from "@/lib/table";
import { deleteRows, fetchRows } from "@/lib/row";
import { dropTable, dropIndex } from "@/lib/api/table";
import { deleteRows, fetchRows } from "@/lib/api/row";
import {
findPrimaryKeyColumnIndex,
getForeignKey,
@@ -92,7 +92,7 @@ export type SimpleSignal<T> = [get: () => T, set: (state: T) => void];
const blobEncodings = ["base64", "hex", "mixed"] as const;
type BlobEncoding = (typeof blobEncodings)[number];
function rowDataToRow(columns: Column[], row: RowData): Record {
function rowDataToRow(columns: Column[], row: ArrayRecord): Record {
const result: Record = {};
for (let i = 0; i < row.length; ++i) {
result[columns[i].name] = row[i];
@@ -101,7 +101,7 @@ function rowDataToRow(columns: Column[], row: RowData): Record {
}
function renderCell(
context: CellContext<RowData, SqlValue>,
context: CellContext<ArrayRecord, SqlValue>,
tableName: QualifiedName,
columns: Column[],
pkIndex: number,
@@ -486,7 +486,7 @@ function buildColumnDefs(
pkColumnIndex: number,
columns: Column[],
blobEncoding: BlobEncoding,
): ColumnDef<RowData, SqlValue>[] {
): ColumnDef<ArrayRecord, SqlValue>[] {
return columns.map((col, idx) => {
const fk = getForeignKey(col.options);
const notNull = isNotNull(col.options);
@@ -510,12 +510,12 @@ function buildColumnDefs(
},
blobEncoding,
),
accessorFn: (row: RowData) => row[idx],
} as ColumnDef<RowData, SqlValue>;
accessorFn: (row: ArrayRecord) => row[idx],
} as ColumnDef<ArrayRecord, SqlValue>;
});
}
function RowDataTable(props: {
function ArrayRecordTable(props: {
state: TableState;
pagination: SimpleSignal<PaginationState>;
filter: SimpleSignal<string | undefined>;
@@ -600,14 +600,14 @@ function RowDataTable(props: {
}}
onRowClick={
mutable()
? (_idx: number, row: RowData) => {
? (_idx: number, row: ArrayRecord) => {
setEditRow(rowDataToRow(columns(), row));
}
: undefined
}
onRowSelection={
mutable()
? (rows: Row<RowData>[], value: boolean) => {
? (rows: Row<ArrayRecord>[], value: boolean) => {
const newSelection = new Map<string, SqlValue>(
selectedRows(),
);
@@ -874,7 +874,7 @@ export function TablePane(props: {
</Match>
<Match when={state.data}>
<RowDataTable
<ArrayRecordTable
state={state.data!}
pagination={[pagination, setPagination]}
filter={[filter, setFilter]}

View File

@@ -20,7 +20,7 @@ import { SplitView } from "@/components/SplitView";
import { SafeSheet } from "@/components/SafeSheet";
import { Separator } from "@/components/ui/separator";
import { createTableSchemaQuery } from "@/lib/table";
import { createTableSchemaQuery } from "@/lib/api/table";
import {
hiddenTable,
tableType,

View File

@@ -0,0 +1,44 @@
import { adminFetch } from "@/lib/fetch";
import type { QueryResponse } from "@bindings/QueryResponse";
import type { QueryRequest } from "@bindings/QueryRequest";
export type ExecutionError = {
code: number;
message: string;
};
export type ExecutionResult = {
query: string;
timestamp: number;
data?: QueryResponse;
error?: ExecutionError;
};
export async function executeSql(sql: string): Promise<ExecutionResult> {
const response = await adminFetch("/query", {
method: "POST",
body: JSON.stringify({
query: sql,
} as QueryRequest),
throwOnError: false,
});
if (response.ok) {
return {
query: sql,
timestamp: Date.now(),
data: await response.json(),
} as ExecutionResult;
}
return {
query: sql,
timestamp: Date.now(),
error: {
code: response.status,
message: await response.text(),
} as ExecutionError,
} as ExecutionResult;
}

View File

@@ -1,5 +1,5 @@
import { adminFetch } from "@/lib/fetch";
import { buildListSearchParams2 } from "@/lib/list";
import { buildListSearchParams } from "@/lib/list";
import type { ListLogsResponse } from "@bindings/ListLogsResponse";
@@ -8,7 +8,7 @@ export async function getLogs(
filter?: string,
cursor?: string | null,
): Promise<ListLogsResponse> {
const params = buildListSearchParams2({
const params = buildListSearchParams({
filter,
pageSize,
cursor,

View File

@@ -1,10 +1,10 @@
import { adminFetch } from "@/lib/fetch";
import { buildListSearchParams2 } from "@/lib/list";
import { buildListSearchParams } from "@/lib/list";
import {
findPrimaryKeyColumnIndex,
prettyFormatQualifiedName,
} from "@/lib/schema";
import type { Record } from "@/lib/convert";
import type { Record } from "@/lib/record";
import type { Table } from "@bindings/Table";
import type { InsertRowRequest } from "@bindings/InsertRowRequest";
@@ -94,7 +94,7 @@ export async function fetchRows(
pageIndex: number,
cursor: string | null,
): Promise<ListRowsResponse> {
const params = buildListSearchParams2({
const params = buildListSearchParams({
filter,
pageSize,
pageIndex,

View File

@@ -1,5 +1,5 @@
import { adminFetch } from "@/lib/fetch";
import { buildListSearchParams2 } from "@/lib/list";
import { buildListSearchParams } from "@/lib/list";
import type { UpdateUserRequest } from "@bindings/UpdateUserRequest";
import type { CreateUserRequest } from "@bindings/CreateUserRequest";
@@ -32,7 +32,7 @@ export async function fetchUsers(
pageSize: number,
cursor: string | null,
): Promise<ListUsersResponse> {
const params = buildListSearchParams2({
const params = buildListSearchParams({
filter,
pageSize,
cursor,

View File

@@ -0,0 +1,30 @@
import { computed } from "nanostores";
import { persistentAtom } from "@nanostores/persistent";
import type { Client, Tokens, User } from "trailbase";
import { initClient } from "trailbase";
const $tokens = persistentAtom<Tokens | null>("auth_tokens", null, {
encode: JSON.stringify,
decode: JSON.parse,
});
export const $user = computed($tokens, (_tokens) => client.user());
function buildClient(): Client {
// For our dev server setup we assume that a TrailBase instance is running at ":4000", otherwise
// we query APIs relative to the origin's root path.
const HOST = import.meta.env.DEV
? new URL("http://localhost:4000")
: undefined;
const client = initClient(HOST, {
tokens: $tokens.get() ?? undefined,
onAuthChange: (c: Client, _user: User | undefined) => {
$tokens.set(c.tokens() ?? null);
},
});
// This will also trigger a logout in case of 401.
client.refreshAuthToken();
return client;
}
export const client = buildClient();

View File

@@ -1,64 +0,0 @@
import { tryParseFloat, tryParseBigInt } from "@/lib/utils";
import { unescapeLiteral, unescapeLiteralBlob } from "@/lib/schema";
import type { SqlValue } from "@/lib/value";
import type { ColumnDataType } from "@bindings/ColumnDataType";
/// A record, i.e. row of SQL values (including "Null") or undefined (i.e.
/// don't submit), keyed by column name. We use a map-like structure to allow
/// for absence and avoid schema complexities and skew.
export type Record = { [key: string]: SqlValue | undefined };
// An array representation of a single row.
export type RowData = SqlValue[];
export function castToInteger(value: SqlValue): bigint {
if (typeof value === "object" && "Integer" in value) {
return value.Integer;
}
throw Error(`Expected integer, got: ${value}`);
}
export function hashSqlValue(value: SqlValue): string {
return `__${JSON.stringify(value)}`;
}
export function shallowCopySqlValue(
value: SqlValue | undefined,
): SqlValue | undefined {
if (value === undefined) {
return undefined;
}
if (value === "Null") {
return "Null";
}
return { ...value };
}
export function literalDefault(
type: ColumnDataType,
value: string,
): string | bigint | number | undefined {
// Non literal if missing or function call, e.g. '(fun([col]))'.
if (value === undefined || value.startsWith("(")) {
return undefined;
}
if (type === "Blob") {
// e.g. for X'abba' return "abba".
const blob = unescapeLiteralBlob(value);
if (blob !== undefined) {
return blob;
}
return undefined;
} else if (type === "Text") {
// e.g. 'bar'.
return unescapeLiteral(value);
} else if (type === "Integer") {
return tryParseBigInt(value);
} else if (type === "Real") {
return tryParseFloat(value);
}
return undefined;
}

View File

@@ -1,36 +1,4 @@
import { computed } from "nanostores";
import { persistentAtom } from "@nanostores/persistent";
import type { Client, Tokens, User } from "trailbase";
import { initClient } from "trailbase";
import type { QueryResponse } from "@bindings/QueryResponse";
import type { QueryRequest } from "@bindings/QueryRequest";
const $tokens = persistentAtom<Tokens | null>("auth_tokens", null, {
encode: JSON.stringify,
decode: JSON.parse,
});
export const $user = computed($tokens, (_tokens) => client.user());
function buildClient(): Client {
// For our dev server setup we assume that a TrailBase instance is running at ":4000", otherwise
// we query APIs relative to the origin's root path.
const HOST = import.meta.env.DEV
? new URL("http://localhost:4000")
: undefined;
const client = initClient(HOST, {
tokens: $tokens.get() ?? undefined,
onAuthChange: (c: Client, _user: User | undefined) => {
$tokens.set(c.tokens() ?? null);
},
});
// This will also trigger a logout in case of 401.
client.refreshAuthToken();
return client;
}
export const client = buildClient();
import { client } from "@/lib/client";
type FetchOptions = RequestInit & {
throwOnError?: boolean;
@@ -51,43 +19,3 @@ export async function adminFetch(
...init,
});
}
export type ExecutionError = {
code: number;
message: string;
};
export type ExecutionResult = {
query: string;
timestamp: number;
data?: QueryResponse;
error?: ExecutionError;
};
export async function executeSql(sql: string): Promise<ExecutionResult> {
const response = await adminFetch("/query", {
method: "POST",
body: JSON.stringify({
query: sql,
} as QueryRequest),
throwOnError: false,
});
if (response.ok) {
return {
query: sql,
timestamp: Date.now(),
data: await response.json(),
} as ExecutionResult;
}
return {
query: sql,
timestamp: Date.now(),
error: {
code: response.status,
message: await response.text(),
} as ExecutionError,
} as ExecutionResult;
}

View File

@@ -1,19 +1,19 @@
import { parse, ExprGroup, Expr, JoinOp, SignOp } from "@/lib/fexpr";
import { showToast } from "@/components/ui/toast";
export type ListArgs2 = {
export type ListArgs = {
filter: string | undefined | null;
pageSize: number;
pageIndex?: number;
cursor: string | undefined | null;
};
export function buildListSearchParams2({
export function buildListSearchParams({
filter,
pageSize,
pageIndex,
cursor,
}: ListArgs2): URLSearchParams {
}: ListArgs): URLSearchParams {
const params = new URLSearchParams();
if (filter) {

View File

@@ -0,0 +1,15 @@
import type { SqlValue } from "@/lib/value";
/// A record, i.e. row of SQL values (including "Null") or undefined (i.e.
/// don't submit), keyed by column name.
///
/// We use this for insert/update. The map-like structure to allow / for absence
/// and avoid schema complexities and skew. Values of `undefined` won't be
/// serialized sent across the wire.
export type Record = { [key: string]: SqlValue | undefined };
/// A(nother) record, , i.e. row of SQL values (including "Null").
///
/// We use this for reading/listing records. Every column is represented and is
/// accessed by index.
export type ArrayRecord = SqlValue[];

View File

@@ -1,4 +1,5 @@
import { assert, TypeEqualityGuard } from "@/lib/value";
import { tryParseBigInt, tryParseFloat } from "@/lib/utils";
import type { Column } from "@bindings/Column";
import type { ColumnDataType } from "@bindings/ColumnDataType";
@@ -50,6 +51,34 @@ export function setDefaultValue(
return newOpts;
}
export function literalDefault(
type: ColumnDataType,
value: string,
): string | bigint | number | undefined {
// Non literal if missing or function call, e.g. '(fun([col]))'.
if (value === undefined || value.startsWith("(")) {
return undefined;
}
if (type === "Blob") {
// e.g. for X'abba' return "abba".
const blob = unescapeLiteralBlob(value);
if (blob !== undefined) {
return blob;
}
return undefined;
} else if (type === "Text") {
// e.g. 'bar'.
return unescapeLiteral(value);
} else if (type === "Integer") {
return tryParseBigInt(value);
} else if (type === "Real") {
return tryParseFloat(value);
}
return undefined;
}
function unpackCheckValue(col: ColumnOption): string | undefined {
if (typeof col === "object" && "Check" in col) {
return col.Check as string;

View File

@@ -91,3 +91,7 @@ export function urlSafeBase64EncodedBlob(blob: Blob): string {
return urlSafeBase64Encode(Uint8Array.from(blob.Array));
}
export function hashSqlValue(value: SqlValue): string {
return `__${JSON.stringify(value)}`;
}