Files
cobble/src/functions/backend/database/grab-table-records.ts
T
tben 41bb4aaac2 Let a super admin read the database from the app
A new super admin settings page, /admin/settings/database, lists every
table with its record count and size, and one page of the open table's
records newest key first. Values are cut inside SQL (400 characters, with
a flag saying so) and a record opens whole in a modal, so a table of long
texts reads without shipping it all. A search matches every column's
text, word by word or as a phrase, which is the one thing that also works
on a virtual table. Nothing in it writes: the catalog, the records and
the single record are all reads, GET only.
2026-10-23 06:03:42 +01:00

90 lines
2.8 KiB
TypeScript

import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import buildRecordRow from "./build-record-row";
import buildRecordsSql, { buildCountSql } from "./build-records-sql";
import clampPage from "./clamp-page";
import grabTableCatalogEntry from "./grab-table-catalog-entry";
import type { DatabaseRecord, DatabaseRecordsPayload, DatabaseSearchMode } from "@/src/types";
import { AppData } from "@/src/data/app-data";
type Params = {
table?: string | null;
/**
* Which page of records, from 1.
*/
page?: number;
/**
* What to look for in the table's records, across every column's text.
*/
search?: string;
mode?: DatabaseSearchMode;
};
type TotalRow = { total: number };
/**
* One page of one table's records, read-only: the table asked for with all
* of its columns, its records newest key first, and how many matched so the
* pager knows where the end is.
*
* Every value crosses out of SQLite already cut to
* `AppData.DatabaseBrowserCellMaxChars`, so a table of long texts can't
* turn one page into a megabyte of JSON.
*/
export default async function grabTableRecords({
table,
page = 1,
search = "",
mode = "every_word",
}: Params): Promise<DatabaseRecordsPayload> {
const entry = await grabTableCatalogEntry({ table });
const page_size = AppData.DatabaseBrowserPageSize;
const current_page = Math.max(1, Math.trunc(page || 1));
const columns = entry.columns.map((column) => column.name);
const trimmed = search.trim();
const search_params = trimmed
? { text: trimmed, mode, max_words: AppData.DatabaseBrowserMaxSearchWords }
: null;
const count_res = await BunSQLite.sql<TotalRow>(
buildCountSql({
table: entry.name,
columns,
search: search_params,
}),
);
const total = Number(count_res.payload?.[0]?.total) || 0;
const final_page = clampPage({ page: current_page, total, page_size });
const page_query = buildRecordsSql({
table: entry.name,
columns,
key: entry.key,
max_chars: AppData.DatabaseBrowserCellMaxChars,
limit: page_size,
offset: (final_page - 1) * page_size,
search: search_params,
});
const res = await BunSQLite.sql<Record<string, unknown>>(page_query);
if (!res.success || !res.payload) {
throw new AppError(`Couldn't read the records of "${entry.name}".`);
}
const records: DatabaseRecord[] = res.payload.map((row) =>
buildRecordRow({
row,
columns: entry.columns,
max_chars: AppData.DatabaseBrowserCellMaxChars,
}),
);
return {
table: entry,
records,
total,
page: final_page,
page_size,
search: trimmed,
mode,
};
}