diff --git a/src/lib/components/data/data-format.ts b/src/lib/components/data/data-format.ts
new file mode 100644
index 0000000..fe00fcc
--- /dev/null
+++ b/src/lib/components/data/data-format.ts
@@ -0,0 +1,72 @@
+// Client-side value helpers for the /lists/db grid (shared by the grid and FieldInput).
+export type Col = {
+ name: string;
+ label: string;
+ type: string;
+ kind: string;
+ uiType: string | null;
+ nullable: boolean;
+ hasDefault: boolean;
+ defaultExpr: string | null;
+ readOnly: boolean;
+ enumValues: string[];
+ pk: boolean;
+ fk: { schema: string; table: string; column: string } | null;
+ help: string | null;
+};
+
+const pad = (n: number) => String(n).padStart(2, '0');
+const withTz = (c: Col) => c.type.includes('with time zone');
+
+/** Raw JSON value from the server -> editor string (null stays null). */
+export function toEditor(c: Col, v: unknown): string | null {
+ if (v === null || v === undefined) return null;
+ if (c.kind === 'json') return JSON.stringify(v, null, 2);
+ if (c.kind === 'array') return JSON.stringify(v);
+ if (c.kind === 'datetime' && typeof v === 'string') {
+ if (!withTz(c)) return v.slice(0, 19);
+ const d = new Date(v);
+ if (Number.isNaN(d.getTime())) return v;
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+ }
+ if (c.kind === 'date' && typeof v === 'string') return v.slice(0, 10);
+ return String(v);
+}
+
+/** Editor string -> value sent to the server. */
+export function fromEditor(c: Col, s: string | null): string | null {
+ if (s === null) return null;
+ if (c.kind === 'datetime' && withTz(c) && s.trim()) {
+ const d = new Date(s);
+ return Number.isNaN(d.getTime()) ? s : d.toISOString();
+ }
+ return s;
+}
+
+/** Short display text for a grid cell. */
+export function display(c: Col, v: unknown): string {
+ if (v === null || v === undefined) return '';
+ if (c.kind === 'bool') return v ? '✓' : '✗';
+ if (c.uiType === 'cents' && typeof v === 'number') return `€${(v / 100).toFixed(2)}`;
+ if (c.kind === 'datetime' && typeof v === 'string') {
+ const d = new Date(v);
+ if (Number.isNaN(d.getTime())) return v;
+ return withTz(c)
+ ? d.toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })
+ : v.replace('T', ' ').slice(0, 16);
+ }
+ if (typeof v === 'object') {
+ const s = JSON.stringify(v);
+ return s.length > 120 ? `${s.slice(0, 117)}…` : s;
+ }
+ const s = String(v);
+ return s.length > 160 ? `${s.slice(0, 157)}…` : s;
+}
+
+export function shortType(t: string): string {
+ return t
+ .replace('timestamp with time zone', 'timestamptz')
+ .replace('timestamp without time zone', 'timestamp')
+ .replace('character varying', 'varchar')
+ .replace('double precision', 'float8');
+}
diff --git a/src/lib/server/data-editor.ts b/src/lib/server/data-editor.ts
new file mode 100644
index 0000000..10a40de
--- /dev/null
+++ b/src/lib/server/data-editor.ts
@@ -0,0 +1,675 @@
+// Owner-only database browser/editor behind /lists/db. Every table and column name used
+// in SQL comes from pg_catalog (looked up with bound parameters) and is quoted with
+// sql.identifier; every value is a bound parameter. Access rules live in data-policy.ts.
+import { sql, type SQL } from 'drizzle-orm';
+import { db } from '#lib/server/db/index.js';
+import {
+ coerce,
+ hiddenColumn,
+ plausibleIdent,
+ safeTypeName,
+ tableAccess,
+ typeKind,
+ writeAllowed,
+ type Access,
+ type Filter,
+ type Kind
+} from '#lib/server/data-policy.js';
+
+export type TableSummary = {
+ schema: string;
+ name: string;
+ displayName: string;
+ description: string | null;
+ relkind: string;
+ access: Access;
+ rows: number | null;
+ approximate: boolean;
+ listSlug: string | null;
+};
+
+export type ColumnInfo = {
+ name: string;
+ label: string;
+ type: string;
+ kind: Kind;
+ uiType: string | null;
+ nullable: boolean;
+ hasDefault: boolean;
+ defaultExpr: string | null;
+ /** identity ALWAYS / generated columns cannot be written */
+ readOnly: boolean;
+ enumValues: string[];
+ pk: boolean;
+ fk: { schema: string; table: string; column: string } | null;
+ help: string | null;
+};
+
+export type TableInfo = TableSummary & {
+ oid: number;
+ columns: ColumnInfo[];
+ primaryKey: string[];
+ canInsert: boolean;
+ canUpdate: boolean;
+ canDelete: boolean;
+ isList: boolean;
+};
+
+type CatalogRow = {
+ oid: number;
+ schema: string;
+ name: string;
+ relkind: string;
+ estimate: number;
+ can_insert: boolean;
+ can_update: boolean;
+ can_delete: boolean;
+ has_pk: boolean;
+ comment: string | null;
+ display_name: string | null;
+ description: string | null;
+ meta_kind: string | null;
+ slug: string | null;
+ archived: boolean;
+};
+
+const MAX_EXACT = 200_000;
+
+function catalogQuery(where: SQL) {
+ return sql`
+ SELECT c.oid::int AS oid, n.nspname AS schema, c.relname AS name, c.relkind::text AS relkind,
+ greatest(c.reltuples, 0)::bigint::float8 AS estimate,
+ has_table_privilege(c.oid, 'INSERT') AS can_insert,
+ has_table_privilege(c.oid, 'UPDATE') AS can_update,
+ has_table_privilege(c.oid, 'DELETE') AS can_delete,
+ EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.oid AND i.indisprimary) AS has_pk,
+ obj_description(c.oid, 'pg_class') AS comment,
+ mt.display_name, mt.description, mt.kind AS meta_kind, mt.slug,
+ (mt.archived_at IS NOT NULL) AS archived
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ LEFT JOIN meta.tables mt ON mt.schema_name = n.nspname AND mt.table_name = c.relname
+ WHERE c.relkind IN ('r', 'p', 'v', 'm') AND NOT c.relispartition
+ AND n.nspname <> 'information_schema' AND n.nspname NOT LIKE 'pg\\_%'
+ AND has_schema_privilege(n.oid, 'USAGE') AND has_table_privilege(c.oid, 'SELECT')
+ AND ${where}
+ ORDER BY n.nspname, c.relname`;
+}
+
+function summarise(r: CatalogRow): Omit {
+ const access = tableAccess({
+ schema: r.schema,
+ name: r.name,
+ relkind: r.relkind,
+ hasPrimaryKey: r.has_pk,
+ canInsert: r.can_insert,
+ canUpdate: r.can_update,
+ canDelete: r.can_delete
+ });
+ return {
+ schema: r.schema,
+ name: r.name,
+ displayName: r.display_name ?? r.name.replace(/_/g, ' '),
+ description: r.description ?? r.comment,
+ relkind: r.relkind,
+ access: r.archived ? 'locked' : access,
+ listSlug: r.meta_kind === 'list' && !r.archived ? r.slug : null
+ };
+}
+
+const rel = (schema: string, name: string) => sql`${sql.identifier(schema)}.${sql.identifier(name)}`;
+
+/** Every table/view the app role can read, minus hidden ones, with row counts. */
+export async function listTables(): Promise {
+ const rows = (await db.execute(catalogQuery(sql`true`))) as unknown as CatalogRow[];
+ const visible = rows.map((r) => ({ r, s: summarise(r) })).filter(({ s }) => s.access !== 'hidden');
+ // exact counts for real tables up to MAX_EXACT rows; estimates for huge ones; views are
+ // counted only when opened (some aggregate big tables).
+ const exact = visible.filter(({ r }) => (r.relkind === 'r' || r.relkind === 'p') && r.estimate < MAX_EXACT);
+ const counts = new Map();
+ if (exact.length) {
+ const res = await db.transaction(async (tx) => {
+ await tx.execute(sql`SET LOCAL statement_timeout = '10s'`);
+ return tx.execute<{ k: string; n: number }>(
+ sql.join(
+ exact.map(
+ ({ r }) => sql`SELECT ${`${r.schema}.${r.name}`}::text AS k, count(*)::int AS n FROM ${rel(r.schema, r.name)}`
+ ),
+ sql` UNION ALL `
+ )
+ );
+ });
+ for (const c of res as unknown as { k: string; n: number }[]) counts.set(c.k, Number(c.n));
+ }
+ return visible.map(({ r, s }) => {
+ const n = counts.get(`${r.schema}.${r.name}`);
+ return n !== undefined
+ ? { ...s, rows: n, approximate: false }
+ : r.relkind === 'r' || r.relkind === 'p'
+ ? { ...s, rows: r.estimate, approximate: true }
+ : { ...s, rows: null, approximate: false };
+ });
+}
+
+type ColumnRow = {
+ name: string;
+ type: string;
+ notnull: boolean;
+ default_expr: string | null;
+ identity: string;
+ generated: string;
+ is_enum: boolean;
+ enum_values: string[] | null;
+ comment: string | null;
+ pk_pos: number | null;
+ fk_schema: string | null;
+ fk_table: string | null;
+ fk_column: string | null;
+ ui_type: string | null;
+ label: string | null;
+ help_text: string | null;
+ meta_hidden: boolean | null;
+ select_values: string[] | null;
+};
+
+/** One table by schema + name (bound parameters), or null when unknown or hidden. */
+export async function getTable(schema: string, name: string): Promise {
+ if (!plausibleIdent(schema) || !plausibleIdent(name)) return null;
+ const [r] = (await db.execute(
+ catalogQuery(sql`n.nspname = ${schema} AND c.relname = ${name}`)
+ )) as unknown as CatalogRow[];
+ if (!r) return null;
+ const s = summarise(r);
+ if (s.access === 'hidden') return null;
+ const cols = (await db.execute(sql`
+ SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS notnull,
+ pg_get_expr(d.adbin, d.adrelid) AS default_expr, a.attidentity::text AS identity,
+ a.attgenerated::text AS generated,
+ (et.typtype = 'e') AS is_enum,
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = et.oid) AS enum_values,
+ col_description(a.attrelid, a.attnum) AS comment,
+ (SELECT array_position(i.indkey::int2[], a.attnum) FROM pg_index i
+ WHERE i.indrelid = a.attrelid AND i.indisprimary) AS pk_pos,
+ fk.fk_schema, fk.fk_table, fk.fk_column,
+ mc.ui_type, mc.label, mc.help_text, mc.hidden AS meta_hidden,
+ CASE WHEN jsonb_typeof(mc.options->'values') = 'array'
+ THEN ARRAY(SELECT jsonb_array_elements_text(mc.options->'values')) END AS select_values
+ FROM pg_attribute a
+ JOIN pg_type t ON t.oid = a.atttypid
+ LEFT JOIN pg_type et ON et.oid = CASE WHEN t.typelem <> 0 AND t.typcategory = 'A' THEN t.typelem ELSE t.oid END
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
+ LEFT JOIN LATERAL (
+ SELECT fn.nspname AS fk_schema, fc.relname AS fk_table, fa.attname AS fk_column
+ FROM pg_constraint k
+ JOIN pg_class fc ON fc.oid = k.confrelid
+ JOIN pg_namespace fn ON fn.oid = fc.relnamespace
+ JOIN pg_attribute fa ON fa.attrelid = k.confrelid AND fa.attnum = k.confkey[1]
+ WHERE k.conrelid = a.attrelid AND k.contype = 'f' AND cardinality(k.conkey) = 1
+ AND k.conkey[1] = a.attnum
+ LIMIT 1) fk ON true
+ LEFT JOIN meta.tables mt ON mt.schema_name = ${schema} AND mt.table_name = ${name}
+ LEFT JOIN meta.columns mc ON mc.table_id = mt.id AND mc.column_name = a.attname
+ WHERE a.attrelid = ${r.oid} AND a.attnum > 0 AND NOT a.attisdropped
+ ORDER BY a.attnum`)) as unknown as ColumnRow[];
+ const columns: ColumnInfo[] = cols
+ .filter((c) => !hiddenColumn(c.name) && safeTypeName(c.type))
+ .map((c) => ({
+ name: c.name,
+ label: c.label ?? c.name.replace(/_/g, ' '),
+ type: c.type,
+ kind: typeKind(c.type, Boolean(c.is_enum)),
+ uiType: c.ui_type,
+ nullable: !c.notnull,
+ hasDefault: c.default_expr !== null || c.identity !== '' || c.generated !== '',
+ defaultExpr: c.default_expr,
+ readOnly: c.identity === 'a' || c.generated !== '',
+ enumValues: c.enum_values ?? (c.ui_type === 'select' ? (c.select_values ?? []) : []),
+ pk: c.pk_pos !== null,
+ fk: c.fk_schema ? { schema: c.fk_schema, table: c.fk_table!, column: c.fk_column! } : null,
+ help: c.help_text ?? c.comment
+ }));
+ const primaryKey = cols
+ .filter((c) => c.pk_pos !== null)
+ .sort((a, b) => a.pk_pos! - b.pk_pos!)
+ .map((c) => c.name);
+ // a key column that is hidden or has an odd type makes rows unaddressable: read-only
+ const keyUsable = primaryKey.length > 0 && primaryKey.every((k) => columns.some((c) => c.name === k));
+ return {
+ ...s,
+ access: s.access !== 'locked' && !keyUsable ? 'locked' : s.access,
+ rows: null,
+ approximate: false,
+ oid: r.oid,
+ columns,
+ primaryKey,
+ canInsert: r.can_insert,
+ canUpdate: r.can_update,
+ canDelete: r.can_delete,
+ isList: s.listSlug !== null
+ };
+}
+
+// ---------- reading ----------
+
+export type RowQuery = {
+ q?: string;
+ filters?: Filter[];
+ sort?: string;
+ dir?: 'asc' | 'desc';
+ limit?: number;
+ offset?: number;
+};
+
+function colRef(t: TableInfo, name: string) {
+ const c = t.columns.find((x) => x.name === name);
+ if (!c) throw new DataError(400, `Unknown column ${name}.`);
+ return { c, ref: sql`t.${sql.identifier(c.name)}` };
+}
+
+function cast(value: string | null, c: ColumnInfo) {
+ return sql`CAST(${value} AS ${sql.raw(c.type)})`;
+}
+
+function whereClause(t: TableInfo, q: RowQuery): SQL {
+ const parts: SQL[] = [];
+ const search = q.q?.trim();
+ if (search) {
+ const texts = t.columns.map((c) => sql`t.${sql.identifier(c.name)}::text`);
+ parts.push(sql`concat_ws(' ', ${sql.join(texts, sql`, `)}) ILIKE ${`%${search.replace(/[\\%_]/g, '\\$&')}%`}`);
+ }
+ for (const f of q.filters ?? []) {
+ const { c, ref } = colRef(t, f.column);
+ if (f.op === 'null') parts.push(sql`${ref} IS NULL`);
+ else if (f.op === 'notnull') parts.push(sql`${ref} IS NOT NULL`);
+ else if (f.op === 'contains')
+ parts.push(sql`${ref}::text ILIKE ${`%${f.value.replace(/[\\%_]/g, '\\$&')}%`}`);
+ else {
+ const v = coerce(c.kind, f.value, c.label);
+ if (!v.ok) throw new DataError(400, v.error);
+ const val = c.kind === 'json' ? sql`${v.value}::jsonb` : cast(v.value, c);
+ const lhs = c.kind === 'json' ? sql`${ref}::jsonb` : ref;
+ const op = { eq: '=', ne: 'IS DISTINCT FROM', gt: '>', gte: '>=', lt: '<', lte: '<=' }[f.op];
+ parts.push(sql`${lhs} ${sql.raw(op)} ${val}`);
+ }
+ }
+ return parts.length ? sql`WHERE ${sql.join(parts, sql` AND `)}` : sql``;
+}
+
+function orderClause(t: TableInfo, q: RowQuery): SQL {
+ const keys: SQL[] = [];
+ if (q.sort) {
+ const { ref } = colRef(t, q.sort);
+ keys.push(sql`${ref} ${sql.raw(q.dir === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST')}`);
+ }
+ for (const k of t.primaryKey.length ? t.primaryKey : [])
+ if (k !== q.sort) keys.push(sql`t.${sql.identifier(k)} ${sql.raw(q.dir === 'desc' && !q.sort ? 'DESC' : 'ASC')}`);
+ return keys.length ? sql`ORDER BY ${sql.join(keys, sql`, `)}` : sql``;
+}
+
+/** to_jsonb of the visible columns only (hidden credential columns are never selected). */
+function rowJson(t: TableInfo) {
+ // jsonb_build_object takes at most 100 arguments: build it in chunks of 50 columns
+ const chunks: SQL[] = [];
+ for (let i = 0; i < t.columns.length; i += 50)
+ chunks.push(
+ sql`jsonb_build_object(${sql.join(
+ t.columns.slice(i, i + 50).map((c) => sql`${c.name}::text, t.${sql.identifier(c.name)}`),
+ sql`, `
+ )})`
+ );
+ return chunks.length ? sql.join(chunks, sql` || `) : sql`'{}'::jsonb`;
+}
+
+export type RowsResult = {
+ rows: Record[];
+ total: number | null;
+ approximate: boolean;
+ fkLabels: Record>;
+};
+
+export async function readRows(t: TableInfo, q: RowQuery): Promise {
+ const limit = Math.min(Math.max(q.limit ?? 50, 1), 500);
+ const offset = Math.max(q.offset ?? 0, 0);
+ const where = whereClause(t, q);
+ const filtered = Boolean(q.q?.trim() || q.filters?.length);
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`SET TRANSACTION READ ONLY`);
+ await tx.execute(sql`SET LOCAL statement_timeout = '15s'`);
+ const select = rowJson(t);
+ const rows = (await tx.execute<{ r: Record }>(
+ sql`SELECT ${select} AS r FROM ${rel(t.schema, t.name)} t ${where} ${orderClause(t, q)} LIMIT ${limit} OFFSET ${offset}`
+ )) as unknown as { r: Record }[];
+ let total: number | null = null;
+ let approximate = false;
+ const [est] = (await tx.execute<{ e: number }>(
+ sql`SELECT greatest(reltuples, 0)::float8 AS e FROM pg_class WHERE oid = ${t.oid}`
+ )) as unknown as { e: number }[];
+ if (!filtered && (t.relkind === 'r' || t.relkind === 'p') && est && est.e > MAX_EXACT) {
+ total = Math.round(est.e);
+ approximate = true;
+ } else {
+ await tx.execute(sql`SAVEPOINT c`);
+ try {
+ await tx.execute(sql`SET LOCAL statement_timeout = '5s'`);
+ const [c] = (await tx.execute<{ n: number }>(
+ sql`SELECT count(*)::float8 AS n FROM ${rel(t.schema, t.name)} t ${where}`
+ )) as unknown as { n: number }[];
+ total = Number(c.n);
+ } catch {
+ await tx.execute(sql`ROLLBACK TO SAVEPOINT c`);
+ }
+ }
+ const visible = rows.map(({ r }) => {
+ const out: Record = {};
+ for (const c of t.columns) out[c.name] = r[c.name] ?? null;
+ return out;
+ });
+ const fkLabels: RowsResult['fkLabels'] = {};
+ for (const c of t.columns) {
+ if (!c.fk) continue;
+ const values = [...new Set(visible.map((r) => r[c.name]).filter((v) => v !== null))].map(String);
+ if (!values.length) continue;
+ const labels = await fkOptions(c, { values }, tx).catch(() => []);
+ if (labels.length) fkLabels[c.name] = Object.fromEntries(labels.map((o) => [o.value, o.label]));
+ }
+ return { rows: visible, total, approximate, fkLabels };
+ });
+}
+
+type Tx = Parameters[0]>[0];
+type Exec = Pick;
+
+/** Rows of the table a foreign key points at, as {value, label} for a dropdown. */
+export async function fkOptions(
+ c: ColumnInfo,
+ opts: { q?: string; values?: string[] },
+ runner: Exec = db
+): Promise<{ value: string; label: string }[]> {
+ if (!c.fk) return [];
+ const target = await getTable(c.fk.schema, c.fk.table);
+ if (!target) return [];
+ const key = target.columns.find((x) => x.name === c.fk!.column);
+ if (!key) return [];
+ const labelCol =
+ ['name', 'display_name', 'title', 'label', 'slug', 'email', 'code'].map((n) =>
+ target.columns.find((x) => x.name === n)
+ ).find(Boolean) ?? key;
+ const k = sql`t.${sql.identifier(key.name)}`;
+ const l = sql`t.${sql.identifier(labelCol.name)}`;
+ const conds: SQL[] = [];
+ if (opts.values?.length)
+ conds.push(sql`${k}::text IN (${sql.join(opts.values.slice(0, 500).map((v) => sql`${v}`), sql`, `)})`);
+ if (opts.q?.trim()) {
+ const like = `%${opts.q.trim().replace(/[\\%_]/g, '\\$&')}%`;
+ conds.push(sql`(${l}::text ILIKE ${like} OR ${k}::text ILIKE ${like})`);
+ }
+ const rows = (await runner.execute<{ value: string; label: string }>(sql`
+ SELECT ${k}::text AS value, ${l}::text AS label FROM ${rel(target.schema, target.name)} t
+ ${conds.length ? sql`WHERE ${sql.join(conds, sql` AND `)}` : sql``}
+ ORDER BY 2 NULLS LAST LIMIT ${opts.values?.length ? 500 : 50}`)) as unknown as {
+ value: string;
+ label: string;
+ }[];
+ return rows.map((r) => ({
+ value: r.value,
+ label: labelCol === key ? r.value : `${r.label ?? '—'} (${r.value})`
+ }));
+}
+
+/** All matching rows for CSV export (capped). */
+export async function exportRows(t: TableInfo, q: RowQuery, cap = 200_000) {
+ return db.transaction(async (tx) => {
+ await tx.execute(sql`SET TRANSACTION READ ONLY`);
+ await tx.execute(sql`SET LOCAL statement_timeout = '60s'`);
+ const cols = t.columns.map((c) =>
+ c.kind === 'json' || c.kind === 'array'
+ ? sql`to_json(t.${sql.identifier(c.name)})::text`
+ : sql`t.${sql.identifier(c.name)}::text`
+ );
+ const rows = (await tx.execute<{ v: (string | null)[] }>(
+ sql`SELECT ARRAY[${sql.join(cols, sql`, `)}]::text[] AS v FROM ${rel(t.schema, t.name)} t ${whereClause(t, q)} ${orderClause(t, q)} LIMIT ${cap}`
+ )) as unknown as { v: (string | null)[] }[];
+ return rows.map((r) => r.v);
+ });
+}
+
+// ---------- writing ----------
+
+export class DataError extends Error {
+ constructor(
+ public status: number,
+ message: string
+ ) {
+ super(message);
+ }
+}
+
+export type Actor = { id: string; email: string };
+export type Pk = Record;
+
+function assertWritable(t: TableInfo, unlocked: boolean, need: 'insert' | 'update' | 'delete') {
+ if (t.access === 'locked') throw new DataError(403, 'This table is read-only here.');
+ if (!writeAllowed(t.access, unlocked))
+ throw new DataError(423, 'This is an app table: unlock editing first.');
+ const ok = need === 'insert' ? t.canInsert : need === 'update' ? t.canUpdate : t.canDelete;
+ if (!ok) throw new DataError(403, `The app has no ${need} permission on this table.`);
+}
+
+function assignments(t: TableInfo, values: Record) {
+ const out: { c: ColumnInfo; v: string | null }[] = [];
+ for (const [name, raw] of Object.entries(values)) {
+ const c = t.columns.find((x) => x.name === name);
+ if (!c) throw new DataError(400, `Unknown column ${name}.`);
+ if (c.readOnly) throw new DataError(400, `${c.label} is generated and cannot be set.`);
+ const v = coerce(c.kind, raw, c.label);
+ if (!v.ok) throw new DataError(400, v.error);
+ if (v.value === null && !c.nullable && !(c.hasDefault && raw === undefined))
+ throw new DataError(400, `${c.label} is required.`);
+ if (c.kind === 'enum' && v.value !== null && c.enumValues.length && !c.enumValues.includes(v.value))
+ throw new DataError(400, `${c.label} must be one of ${c.enumValues.join(', ')}.`);
+ out.push({ c, v: v.value });
+ }
+ return out;
+}
+
+function pkWhere(t: TableInfo, pk: Pk) {
+ if (!t.primaryKey.length) throw new DataError(400, 'This table has no primary key.');
+ return sql.join(
+ t.primaryKey.map((k) => {
+ const c = t.columns.find((x) => x.name === k)!;
+ const raw = pk[k];
+ if (raw === undefined || raw === null) throw new DataError(400, `Missing key ${k}.`);
+ const v = coerce(c.kind, typeof raw === 'object' ? JSON.stringify(raw) : String(raw), c.label);
+ if (!v.ok || v.value === null) throw new DataError(400, `Invalid key ${k}.`);
+ return sql`t.${sql.identifier(k)} = ${cast(v.value, c)}`;
+ }),
+ sql` AND `
+ );
+}
+
+let auditAvailable: boolean | null = null;
+async function audit(
+ tx: Exec,
+ actor: Actor,
+ t: TableInfo,
+ action: 'insert' | 'update' | 'delete' | 'import' | 'add_column',
+ pk: unknown,
+ before: unknown,
+ after: unknown
+) {
+ if (auditAvailable === null) {
+ const [r] = (await tx.execute<{ ok: boolean }>(
+ sql`SELECT to_regclass('meta.edit_log') IS NOT NULL AND has_table_privilege('meta.edit_log', 'INSERT') AS ok`
+ )) as unknown as { ok: boolean }[];
+ auditAvailable = Boolean(r?.ok);
+ }
+ if (!auditAvailable) return;
+ await tx.execute(sql`
+ INSERT INTO meta.edit_log (user_id, user_email, schema_name, table_name, action, pk, before, after)
+ VALUES (${actor.id}, ${actor.email}, ${t.schema}, ${t.name}, ${action},
+ ${pk === null ? null : JSON.stringify(pk)}::jsonb,
+ ${before === null ? null : JSON.stringify(before)}::jsonb,
+ ${after === null ? null : JSON.stringify(after)}::jsonb)`);
+}
+
+function pgMessage(e: unknown): DataError {
+ if (e instanceof DataError) return e;
+ const err = (e as { cause?: unknown })?.cause ?? e;
+ const code = (err as { code?: string })?.code ?? '';
+ const msg = (err as { message?: string })?.message ?? 'Database error';
+ // constraint / data errors are the user's input; show Postgres' explanation
+ if (/^(22|23)/.test(code)) return new DataError(400, msg);
+ if (code === '42501') return new DataError(403, 'The app has no permission for this change.');
+ console.error('data editor write failed', e);
+ return new DataError(500, 'The database refused the change.');
+}
+
+async function write(fn: (tx: Tx) => Promise): Promise {
+ try {
+ return await db.transaction(async (tx) => {
+ await tx.execute(sql`SET LOCAL statement_timeout = '30s'`);
+ return fn(tx);
+ });
+ } catch (e) {
+ throw pgMessage(e);
+ }
+}
+
+const one = (rows: unknown) => (rows as T[])[0];
+
+export async function insertRow(t: TableInfo, values: Record, unlocked: boolean, actor: Actor) {
+ assertWritable(t, unlocked, 'insert');
+ const set = assignments(t, values);
+ return write(async (tx) => {
+ const row = one<{ r: Record }>(
+ await tx.execute(
+ set.length
+ ? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(set.map((s) => sql.identifier(s.c.name)), sql`, `)})
+ VALUES (${sql.join(set.map((s) => cast(s.v, s.c)), sql`, `)}) RETURNING ${rowJson(t)} AS r`
+ : sql`INSERT INTO ${rel(t.schema, t.name)} AS t DEFAULT VALUES RETURNING ${rowJson(t)} AS r`
+ )
+ ).r;
+ const pk = Object.fromEntries(t.primaryKey.map((k) => [k, row[k]]));
+ await audit(tx, actor, t, 'insert', pk, null, row);
+ return row;
+ });
+}
+
+export async function updateRow(
+ t: TableInfo,
+ pk: Pk,
+ values: Record,
+ unlocked: boolean,
+ actor: Actor
+) {
+ assertWritable(t, unlocked, 'update');
+ const set = assignments(t, values);
+ if (!set.length) throw new DataError(400, 'Nothing to change.');
+ const where = pkWhere(t, pk);
+ return write(async (tx) => {
+ const before = one<{ r: Record }>(
+ await tx.execute(sql`SELECT ${rowJson(t)} AS r FROM ${rel(t.schema, t.name)} t WHERE ${where} FOR UPDATE`)
+ )?.r;
+ if (!before) throw new DataError(404, 'That row no longer exists.');
+ const after = one<{ r: Record }>(
+ await tx.execute(sql`UPDATE ${rel(t.schema, t.name)} AS t
+ SET ${sql.join(set.map((s) => sql`${sql.identifier(s.c.name)} = ${cast(s.v, s.c)}`), sql`, `)}
+ WHERE ${where} RETURNING ${rowJson(t)} AS r`)
+ ).r;
+ const changed = Object.fromEntries(set.map((s) => [s.c.name, before[s.c.name]]));
+ await audit(tx, actor, t, 'update', pk, changed, Object.fromEntries(set.map((s) => [s.c.name, after[s.c.name]])));
+ return after;
+ });
+}
+
+export async function deleteRows(t: TableInfo, pks: Pk[], unlocked: boolean, actor: Actor) {
+ assertWritable(t, unlocked, 'delete');
+ if (!pks.length || pks.length > 500) throw new DataError(400, 'Select 1–500 rows to delete.');
+ const wheres = pks.map((pk) => pkWhere(t, pk));
+ return write(async (tx) => {
+ let n = 0;
+ for (let i = 0; i < wheres.length; i++) {
+ const before = one<{ r: Record }>(
+ await tx.execute(sql`DELETE FROM ${rel(t.schema, t.name)} AS t WHERE ${wheres[i]} RETURNING ${rowJson(t)} AS r`)
+ )?.r;
+ if (!before) continue;
+ n++;
+ await audit(tx, actor, t, 'delete', pks[i], before, null);
+ }
+ return n;
+ });
+}
+
+/** Append CSV rows (header = column names) in one transaction: all or nothing. */
+export async function importRows(
+ t: TableInfo,
+ table: string[][],
+ unlocked: boolean,
+ actor: Actor
+): Promise {
+ assertWritable(t, unlocked, 'insert');
+ const [header, ...body] = table;
+ if (!header?.length) throw new DataError(400, 'The CSV is empty.');
+ if (body.length > 20_000) throw new DataError(400, 'Import at most 20,000 rows at a time.');
+ const names = header.map((h) => h.trim());
+ const unknown = names.filter((n) => n && !t.columns.some((c) => c.name === n));
+ if (unknown.length) throw new DataError(400, `Unknown column(s): ${unknown.join(', ')}.`);
+ if (new Set(names).size !== names.length) throw new DataError(400, 'Duplicate column in the header.');
+ const prepared = body.map((cells, i) => {
+ if (cells.length !== names.length)
+ throw new DataError(400, `Row ${i + 2} has ${cells.length} cells, expected ${names.length}.`);
+ const values: Record = {};
+ names.forEach((n, j) => {
+ if (!n) return;
+ const c = t.columns.find((x) => x.name === n)!;
+ if (c.readOnly) return;
+ // empty cell: column default when it has one, NULL otherwise (text stays '')
+ if (cells[j] === '' && (c.hasDefault || c.kind !== 'text')) return;
+ values[n] = cells[j];
+ });
+ try {
+ return assignments(t, values);
+ } catch (e) {
+ throw new DataError(400, `Row ${i + 2}: ${(e as Error).message}`);
+ }
+ });
+ return write(async (tx) => {
+ for (const set of prepared)
+ await tx.execute(
+ set.length
+ ? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(set.map((s) => sql.identifier(s.c.name)), sql`, `)})
+ VALUES (${sql.join(set.map((s) => cast(s.v, s.c)), sql`, `)})`
+ : sql`INSERT INTO ${rel(t.schema, t.name)} DEFAULT VALUES`
+ );
+ await audit(tx, actor, t, 'import', null, null, { rows: prepared.length, columns: names });
+ return prepared.length;
+ });
+}
+
+export const LIST_UI_TYPES = ['text', 'longtext', 'url', 'image', 'number', 'cents', 'date', 'datetime', 'bool', 'select', 'multiselect', 'json'] as const;
+
+/** Add a typed column to a runtime list (lists.* only) through meta.add_column(). */
+export async function addListColumn(
+ t: TableInfo,
+ input: { name: string; uiType: string; label?: string; options?: string[] },
+ actor: Actor
+) {
+ if (!t.isList || !t.listSlug || t.access !== 'open')
+ throw new DataError(403, 'Columns can only be added to lists.');
+ if (!/^[a-z][a-z0-9_]{0,62}$/.test(input.name))
+ throw new DataError(400, 'Column names use lowercase letters, digits and _ and start with a letter.');
+ if (!(LIST_UI_TYPES as readonly string[]).includes(input.uiType))
+ throw new DataError(400, 'Unknown column type.');
+ if (t.columns.some((c) => c.name === input.name)) throw new DataError(409, 'That column already exists.');
+ const col: Record = { name: input.name, ui_type: input.uiType };
+ if (input.label?.trim()) col.label = input.label.trim().slice(0, 100);
+ if (input.uiType === 'select' || input.uiType === 'multiselect') {
+ const values = (input.options ?? []).map((o) => o.trim()).filter(Boolean).slice(0, 100);
+ if (input.uiType === 'select' && !values.length) throw new DataError(400, 'Give the select at least one option.');
+ col.options = { values };
+ }
+ await write(async (tx) => {
+ await tx.execute(sql`SELECT meta.add_column(${t.listSlug}, ${JSON.stringify(col)}::jsonb)`);
+ await audit(tx, actor, t, 'add_column', null, null, col);
+ });
+}
diff --git a/src/lib/server/data-policy.test.ts b/src/lib/server/data-policy.test.ts
new file mode 100644
index 0000000..3662264
--- /dev/null
+++ b/src/lib/server/data-policy.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import {
+ coerce,
+ hiddenColumn,
+ parseCsv,
+ parseFilter,
+ pgArrayLiteral,
+ plausibleIdent,
+ safeTypeName,
+ tableAccess,
+ toCsv,
+ typeKind,
+ writeAllowed,
+ type TableFacts
+} from './data-policy';
+
+const t = (schema: string, name: string, extra: Partial = {}): TableFacts => ({
+ schema,
+ name,
+ relkind: 'r',
+ hasPrimaryKey: true,
+ canInsert: true,
+ canUpdate: true,
+ canDelete: true,
+ ...extra
+});
+
+describe('tableAccess', () => {
+ it('hides auth secrets, migrations and credential tables', () => {
+ for (const name of ['session', 'account', 'verification', 'jwks'])
+ expect(tableAccess(t('auth', name))).toBe('hidden');
+ expect(tableAccess(t('drizzle', '__drizzle_migrations'))).toBe('hidden');
+ expect(tableAccess(t('money', 'bank_sessions'))).toBe('hidden');
+ expect(tableAccess(t('money', 'api_keys'))).toBe('hidden');
+ expect(tableAccess(t('public', 'anything'))).toBe('hidden');
+ });
+ it('keeps auth.user, meta and legacy read-only', () => {
+ expect(tableAccess(t('auth', 'user'))).toBe('locked');
+ expect(tableAccess(t('meta', 'tables'))).toBe('locked');
+ expect(tableAccess(t('legacy', 'lists'))).toBe('locked');
+ });
+ it('locks views, tables without a key or without write grants', () => {
+ expect(tableAccess(t('energy', 'gas_storage_latest', { relkind: 'v' }))).toBe('locked');
+ expect(tableAccess(t('money', 'x', { hasPrimaryKey: false }))).toBe('locked');
+ expect(
+ tableAccess(t('ref', 'pokemon', { canInsert: false, canUpdate: false, canDelete: false }))
+ ).toBe('locked');
+ });
+ it('lists are open, app tables are core (unlock first)', () => {
+ expect(tableAccess(t('lists', 'books'))).toBe('open');
+ expect(tableAccess(t('money', 'transactions'))).toBe('core');
+ expect(tableAccess(t('books', 'sessions'))).toBe('core');
+ expect(writeAllowed('open', false)).toBe(true);
+ expect(writeAllowed('core', false)).toBe(false);
+ expect(writeAllowed('core', true)).toBe(true);
+ expect(writeAllowed('locked', true)).toBe(false);
+ expect(writeAllowed('hidden', true)).toBe(false);
+ });
+});
+
+describe('columns and identifiers', () => {
+ it('hides credential-like columns only', () => {
+ for (const c of ['password', 'access_token', 'refresh_token_expires_at', 'api_key', 'client_secret'])
+ expect(hiddenColumn(c)).toBe(true);
+ for (const c of ['key', 'session_id', 'iban', 'author', 'market_hash_name', 'r2_key'])
+ expect(hiddenColumn(c)).toBe(false);
+ });
+ it('validates identifiers and type names', () => {
+ expect(plausibleIdent('done_at')).toBe(true);
+ expect(plausibleIdent('a"; drop table x; --')).toBe(false);
+ expect(plausibleIdent('')).toBe(false);
+ expect(safeTypeName('timestamp with time zone')).toBe(true);
+ expect(safeTypeName('numeric(12,2)')).toBe(true);
+ expect(safeTypeName('"books"."metadata_status"')).toBe(true);
+ expect(safeTypeName('text); drop table x; --')).toBe(false);
+ });
+ it('maps types to editor kinds', () => {
+ expect(typeKind('integer', false)).toBe('number');
+ expect(typeKind('numeric(12,2)', false)).toBe('number');
+ expect(typeKind('text[]', false)).toBe('array');
+ expect(typeKind('books.metadata_status', true)).toBe('enum');
+ expect(typeKind('timestamp with time zone', false)).toBe('datetime');
+ expect(typeKind('jsonb', false)).toBe('json');
+ });
+});
+
+describe('coerce', () => {
+ it('validates per kind', () => {
+ expect(coerce('number', '12.5', 'n')).toEqual({ ok: true, value: '12.5' });
+ expect(coerce('number', 'abc', 'n').ok).toBe(false);
+ expect(coerce('number', '', 'n')).toEqual({ ok: true, value: null });
+ expect(coerce('bool', 'yes', 'b')).toEqual({ ok: true, value: 'true' });
+ expect(coerce('bool', 'maybe', 'b').ok).toBe(false);
+ expect(coerce('date', '2026-10-24', 'd').ok).toBe(true);
+ expect(coerce('date', '24/10', 'd').ok).toBe(false);
+ expect(coerce('json', '{"a":1}', 'j').ok).toBe(true);
+ expect(coerce('json', '{a:1}', 'j').ok).toBe(false);
+ expect(coerce('text', '', 't')).toEqual({ ok: true, value: '' });
+ expect(coerce('text', null, 't')).toEqual({ ok: true, value: null });
+ });
+ it('builds array literals', () => {
+ expect(pgArrayLiteral(['a', 'b c', 'x"y'])).toBe('{"a","b c","x\\"y"}');
+ expect(coerce('array', '["a","b"]', 'a')).toEqual({ ok: true, value: '{"a","b"}' });
+ expect(coerce('array', 'a, b', 'a')).toEqual({ ok: true, value: '{"a","b"}' });
+ });
+});
+
+describe('filters and csv', () => {
+ it('parses filters', () => {
+ expect(parseFilter('name:contains:foo:bar')).toEqual({ column: 'name', op: 'contains', value: 'foo:bar' });
+ expect(parseFilter('done_at:null')).toEqual({ column: 'done_at', op: 'null', value: '' });
+ expect(parseFilter('x;y:eq:1')).toBeNull();
+ expect(parseFilter('name:like:1')).toBeNull();
+ });
+ it('round-trips CSV', () => {
+ const csv = toCsv(['a', 'b'], [['x, y', 'he said "hi"'], [null, { k: 1 }], ['line\nbreak', 3]]);
+ expect(parseCsv(csv)).toEqual([
+ ['a', 'b'],
+ ['x, y', 'he said "hi"'],
+ ['', '{"k":1}'],
+ ['line\nbreak', '3']
+ ]);
+ expect(parseCsv('\ufeffa,b\r\n1,2')).toEqual([['a', 'b'], ['1', '2']]);
+ expect(() => parseCsv('a\n"open')).toThrow();
+ });
+});
diff --git a/src/lib/server/data-policy.ts b/src/lib/server/data-policy.ts
new file mode 100644
index 0000000..2d25820
--- /dev/null
+++ b/src/lib/server/data-policy.ts
@@ -0,0 +1,217 @@
+// Pure rules for the owner-only database browser at /lists/db: which tables and columns
+// are visible or editable, identifier checks, value coercion and CSV. No DB access here,
+// so it is unit tested directly (data-policy.test.ts).
+
+/** hidden: never listed or served. locked: always read-only. core: read-only until the
+ * owner unlocks editing for the table. open: freely editable (runtime lists). */
+export type Access = 'hidden' | 'locked' | 'core' | 'open';
+
+export type TableFacts = {
+ schema: string;
+ name: string;
+ /** pg_class.relkind: r table, p partitioned, v view, m materialized view */
+ relkind: string;
+ hasPrimaryKey: boolean;
+ canInsert: boolean;
+ canUpdate: boolean;
+ canDelete: boolean;
+};
+
+/** Schemas that are never shown: migration bookkeeping and Postgres internals. */
+const HIDDEN_SCHEMAS = new Set(['drizzle', 'information_schema', 'public']);
+/** Better Auth: only the user table is visible (read-only); sessions, accounts (OAuth
+ * tokens, password hashes) and verification codes are hidden. */
+const AUTH_VISIBLE = new Set(['user']);
+/** Individually hidden tables holding third-party credentials. */
+const HIDDEN_TABLES = new Set(['money.bank_sessions']);
+/** Schemas whose tables are always read-only here (registry, rollback copies, auth). */
+const LOCKED_SCHEMAS = new Set(['meta', 'legacy', 'auth']);
+const CREDENTIAL_NAME =
+ /(^|_)(passwords?|passwd|secrets?|tokens?|api_?keys?|private_?keys?|credentials?|salt|jwks|otp)($|_)/i;
+
+export function tableAccess(t: TableFacts): Access {
+ if (HIDDEN_SCHEMAS.has(t.schema) || t.schema.startsWith('pg_')) return 'hidden';
+ if (t.schema === 'auth' && !AUTH_VISIBLE.has(t.name)) return 'hidden';
+ if (HIDDEN_TABLES.has(`${t.schema}.${t.name}`) || CREDENTIAL_NAME.test(t.name)) return 'hidden';
+ if (LOCKED_SCHEMAS.has(t.schema)) return 'locked';
+ if (t.relkind !== 'r' && t.relkind !== 'p') return 'locked';
+ if (!t.hasPrimaryKey) return 'locked';
+ if (!t.canInsert && !t.canUpdate && !t.canDelete) return 'locked';
+ return t.schema === 'lists' ? 'open' : 'core';
+}
+
+/** Columns that are never sent to the browser (tokens, password hashes, ...). */
+export function hiddenColumn(name: string): boolean {
+ return CREDENTIAL_NAME.test(name);
+}
+
+/** Whether a write may go ahead: open tables always, core tables only when unlocked. */
+export function writeAllowed(access: Access, unlocked: boolean): boolean {
+ return access === 'open' || (access === 'core' && unlocked);
+}
+
+/** Identifiers coming from the URL/body are looked up in the catalog; this is a cheap
+ * pre-filter so obviously bad input never reaches a query at all. */
+export function plausibleIdent(s: unknown): s is string {
+ return typeof s === 'string' && s.length > 0 && s.length <= 63 && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(s);
+}
+
+/** format_type() output that is safe to splice into a CAST (it comes from pg_catalog). */
+export function safeTypeName(type: string): boolean {
+ return /^[A-Za-z0-9_ ."(),[\]]{1,120}$/.test(type) && !type.includes('--') && !type.includes(';');
+}
+
+export type Kind = 'text' | 'number' | 'bool' | 'date' | 'datetime' | 'time' | 'json' | 'enum' | 'array' | 'uuid' | 'other';
+
+export function typeKind(type: string, isEnum: boolean): Kind {
+ if (type.endsWith('[]')) return 'array';
+ if (isEnum) return 'enum';
+ if (/^(smallint|integer|bigint|real|double precision|numeric|money)/.test(type)) return 'number';
+ if (type === 'boolean') return 'bool';
+ if (type === 'date') return 'date';
+ if (type.startsWith('timestamp')) return 'datetime';
+ if (type.startsWith('time')) return 'time';
+ if (type === 'json' || type === 'jsonb') return 'json';
+ if (type === 'uuid') return 'uuid';
+ if (/^(text|character|"char"|citext|name)/.test(type)) return 'text';
+ return 'other';
+}
+
+/** Postgres array literal for a list of strings, e.g. ['a', 'b c'] -> {"a","b c"}. */
+export function pgArrayLiteral(items: unknown[]): string {
+ return `{${items
+ .map((v) =>
+ v === null || v === undefined
+ ? 'NULL'
+ : `"${String(typeof v === 'object' ? JSON.stringify(v) : v).replace(/["\\]/g, '\\$&')}"`
+ )
+ .join(',')}}`;
+}
+
+export type Coerced = { ok: true; value: string | null } | { ok: false; error: string };
+
+/**
+ * Turn a value from the editor/CSV into the text that is bound as a parameter and
+ * CAST to the column type by Postgres (which does the final validation). null = NULL.
+ * Empty input means NULL for every non-text column.
+ */
+export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
+ if (raw === null || raw === undefined) return { ok: true, value: null };
+ if (kind === 'array') {
+ let items: unknown = raw;
+ if (typeof raw === 'string') {
+ const s = raw.trim();
+ if (!s) return { ok: true, value: null };
+ if (s.startsWith('{')) return { ok: true, value: s }; // already a Postgres literal
+ try {
+ items = JSON.parse(s);
+ } catch {
+ items = s.split(',').map((x) => x.trim()).filter(Boolean);
+ }
+ }
+ if (!Array.isArray(items)) return { ok: false, error: `${label} must be a list.` };
+ return { ok: true, value: pgArrayLiteral(items) };
+ }
+ if (kind === 'json') {
+ if (typeof raw !== 'string') return { ok: true, value: JSON.stringify(raw) };
+ if (!raw.trim()) return { ok: true, value: null };
+ try {
+ JSON.parse(raw);
+ } catch {
+ return { ok: false, error: `${label} is not valid JSON.` };
+ }
+ return { ok: true, value: raw };
+ }
+ const s = typeof raw === 'string' ? raw : String(raw);
+ if (kind === 'text' || kind === 'other') return { ok: true, value: s };
+ const t = s.trim();
+ if (!t) return { ok: true, value: null };
+ switch (kind) {
+ case 'number':
+ if (!/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(t.replace(/_/g, '')))
+ return { ok: false, error: `${label} must be a number.` };
+ return { ok: true, value: t.replace(/_/g, '') };
+ case 'bool': {
+ const v = t.toLowerCase();
+ if (['true', 't', 'yes', 'y', '1', 'on'].includes(v)) return { ok: true, value: 'true' };
+ if (['false', 'f', 'no', 'n', '0', 'off'].includes(v)) return { ok: true, value: 'false' };
+ return { ok: false, error: `${label} must be true or false.` };
+ }
+ case 'date':
+ if (!/^\d{4}-\d{2}-\d{2}/.test(t)) return { ok: false, error: `${label} must be a date (YYYY-MM-DD).` };
+ return { ok: true, value: t };
+ case 'datetime':
+ if (Number.isNaN(Date.parse(t)) && !/^\d{4}-\d{2}-\d{2}/.test(t))
+ return { ok: false, error: `${label} must be a date and time.` };
+ return { ok: true, value: t };
+ case 'uuid':
+ if (!/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(t))
+ return { ok: false, error: `${label} must be a UUID.` };
+ return { ok: true, value: t };
+ default:
+ return { ok: true, value: t };
+ }
+}
+
+/** Filter operators accepted by the grid (?f=::). */
+export const FILTER_OPS = ['contains', 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'null', 'notnull'] as const;
+export type FilterOp = (typeof FILTER_OPS)[number];
+export type Filter = { column: string; op: FilterOp; value: string };
+
+export function parseFilter(s: string): Filter | null {
+ const i = s.indexOf(':');
+ const j = i < 0 ? -1 : s.indexOf(':', i + 1);
+ if (i < 1) return null;
+ const column = s.slice(0, i);
+ const op = (j < 0 ? s.slice(i + 1) : s.slice(i + 1, j)) as FilterOp;
+ const value = j < 0 ? '' : s.slice(j + 1);
+ if (!plausibleIdent(column) || !FILTER_OPS.includes(op) || value.length > 500) return null;
+ return { column, op, value };
+}
+
+// ---- CSV (RFC 4180) ----
+
+export function csvCell(v: unknown): string {
+ if (v === null || v === undefined) return '';
+ const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+ return /[",\r\n]/.test(s) || /^\s|\s$/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}
+
+export function toCsv(header: string[], rows: unknown[][]): string {
+ return [header, ...rows].map((r) => r.map(csvCell).join(',')).join('\r\n') + '\r\n';
+}
+
+export function parseCsv(text: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let cell = '';
+ let quoted = false;
+ let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; // BOM
+ for (; i < text.length; i++) {
+ const ch = text[i];
+ if (quoted) {
+ if (ch === '"') {
+ if (text[i + 1] === '"') {
+ cell += '"';
+ i++;
+ } else quoted = false;
+ } else cell += ch;
+ } else if (ch === '"' && cell === '') quoted = true;
+ else if (ch === ',') {
+ row.push(cell);
+ cell = '';
+ } else if (ch === '\n' || ch === '\r') {
+ if (ch === '\r' && text[i + 1] === '\n') i++;
+ row.push(cell);
+ rows.push(row);
+ row = [];
+ cell = '';
+ } else cell += ch;
+ }
+ if (quoted) throw new Error('CSV has an unterminated quoted field.');
+ if (cell !== '' || row.length) {
+ row.push(cell);
+ rows.push(row);
+ }
+ return rows.filter((r) => !(r.length === 1 && r[0] === ''));
+}
diff --git a/src/lib/server/data-route.ts b/src/lib/server/data-route.ts
new file mode 100644
index 0000000..9f7ae75
--- /dev/null
+++ b/src/lib/server/data-route.ts
@@ -0,0 +1,47 @@
+// Request helpers shared by the /lists/db routes. Every route calls ownerTable() (or
+// requireOwner) itself, so access never depends on the layout load having run.
+import { error, json } from '@sveltejs/kit';
+import { requireOwner } from '#lib/server/owner.js';
+import { parseFilter, type Filter } from '#lib/server/data-policy.js';
+import { DataError, getTable, type Actor, type RowQuery } from '#lib/server/data-editor.js';
+
+type Event = { locals: App.Locals; params: Record; request: Request; url: URL };
+
+export async function ownerTable(event: Event) {
+ const user = requireOwner(event);
+ const table = await getTable(event.params.schema ?? '', event.params.table ?? '');
+ if (!table) error(404, 'Not Found');
+ const actor: Actor = { id: user.id, email: user.email };
+ return { table, actor };
+}
+
+/** Core (app) tables are read-only until the owner unlocks editing in the UI, which sends
+ * this header with every write; the server refuses core-table writes without it. */
+export const unlocked = (request: Request) => request.headers.get('x-data-unlock') === '1';
+
+export function rowQuery(url: URL): RowQuery {
+ const p = url.searchParams;
+ const filters = p
+ .getAll('f')
+ .slice(0, 20)
+ .map(parseFilter)
+ .filter((f): f is Filter => f !== null);
+ const limit = Number(p.get('limit') ?? 50);
+ const page = Number(p.get('page') ?? 1);
+ const safeLimit = [25, 50, 100, 250, 500].includes(limit) ? limit : 50;
+ return {
+ q: (p.get('q') ?? '').slice(0, 200),
+ filters,
+ sort: p.get('sort') || undefined,
+ dir: p.get('dir') === 'desc' ? 'desc' : 'asc',
+ limit: safeLimit,
+ offset: Number.isSafeInteger(page) && page > 1 ? (page - 1) * safeLimit : 0
+ };
+}
+
+export function failure(e: unknown) {
+ if (e instanceof DataError) return json({ message: e.message }, { status: e.status });
+ throw e;
+}
+
+export const noStore = { 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex, nofollow' };
diff --git a/src/lib/server/lists.ts b/src/lib/server/lists.ts
index 2edf996..e94aab5 100644
--- a/src/lib/server/lists.ts
+++ b/src/lib/server/lists.ts
@@ -44,6 +44,7 @@ export type ListSummary = {
checked: number;
};
export type ListView = Omit & {
+ tableName: string;
columns: ListColumn[];
items: ListItem[];
};
@@ -144,6 +145,7 @@ export async function getList(slug: string): Promise {
slug: t.slug,
pictureUrl: t.pictureUrl,
isProgress: t.hasDone,
+ tableName: t.tableName,
columns,
items: [...rows].map((r) => toItem(r, columns))
};
diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts
index e6fd9ba..6ad30fc 100644
--- a/src/lib/server/owner.ts
+++ b/src/lib/server/owner.ts
@@ -22,7 +22,8 @@ export function requireOwner(event: OwnerEvent) {
export const ownerOnlyPrefixes = [
'/trackers/health',
'/trackers/tasks',
- '/trackers/books/files'
+ '/trackers/books/files',
+ '/lists/db'
] as const;
/**
diff --git a/src/lib/server/slugify.ts b/src/lib/server/slugify.ts
index e59465b..6dbd6d1 100644
--- a/src/lib/server/slugify.ts
+++ b/src/lib/server/slugify.ts
@@ -28,10 +28,13 @@ export async function uniqueSlug(title: string): Promise {
return slug;
}
+/** /lists/db is the database browser, so no list may use that slug. */
+export const RESERVED_LIST_SLUGS = new Set(['db']);
+
export async function uniqueListSlug(name: string): Promise {
const base = slugify(name, 'list');
- let slug = base;
- let suffix = 2;
+ let slug = RESERVED_LIST_SLUGS.has(base) ? `${base}-2` : base;
+ let suffix = RESERVED_LIST_SLUGS.has(base) ? 3 : 2;
while (
(await db.select({ id: metaTables.id }).from(metaTables).where(eq(metaTables.slug, slug)))
.length > 0
diff --git a/src/routes/lists/+page.server.ts b/src/routes/lists/+page.server.ts
index 7b36dec..edbe78d 100644
--- a/src/routes/lists/+page.server.ts
+++ b/src/routes/lists/+page.server.ts
@@ -2,10 +2,21 @@ import { requireAdmin } from '#lib/server/authorization.js';
import { uniqueListSlug } from '#lib/server/slugify.js';
import { listImageUrl } from '#lib/server/r2.js';
import { createList, listSummaries } from '#lib/server/lists.js';
+import { listTables, type TableSummary } from '#lib/server/data-editor.js';
+import { isOwner } from '#lib/server/owner.js';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
-export const load: PageServerLoad = async () => ({ lists: await listSummaries() });
+export const load: PageServerLoad = async (event) => {
+ const owner = isOwner(event);
+ // The database browser (every schema/table) is only ever loaded for the owner.
+ const [lists, tables] = await Promise.all([
+ listSummaries(),
+ owner ? listTables() : Promise.resolve([] as TableSummary[])
+ ]);
+ if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
+ return { lists, tables };
+};
export const actions: Actions = {
newList: async (event) => {
diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte
index 3d386c7..2fb79fd 100644
--- a/src/routes/lists/+page.svelte
+++ b/src/routes/lists/+page.svelte
@@ -14,6 +14,24 @@
import { Input } from '#lib/components/ui/input/index.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
+ let tableFilter = $state('');
+ const schemas = $derived.by(() => {
+ const q = tableFilter.trim().toLowerCase();
+ const groups = new Map();
+ for (const t of data.tables) {
+ if (q && !`${t.schema}.${t.name} ${t.displayName}`.toLowerCase().includes(q)) continue;
+ groups.set(t.schema, [...(groups.get(t.schema) ?? []), t]);
+ }
+ // lists first, then alphabetical
+ return [...groups].sort(([a], [b]) => (a === 'lists' ? -1 : b === 'lists' ? 1 : a.localeCompare(b)));
+ });
+ const totalRows = $derived(data.tables.reduce((n, t) => n + (t.rows ?? 0), 0));
+ const badge = (access: string, relkind: string) =>
+ access === 'open'
+ ? { text: 'editable', cls: 'border-emerald-600/40 text-emerald-500' }
+ : access === 'core'
+ ? { text: 'app table', cls: 'border-amber-500/40 text-amber-500' }
+ : { text: relkind === 'v' || relkind === 'm' ? 'view' : 'read-only', cls: 'border-[var(--site-border)] text-[var(--site-muted)]' };
let uploadError = $state('');
let uploading = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
@@ -137,4 +155,51 @@
{/each}
+
+ {#if data.isOwner && data.tables.length}
+
+
+
+
database
+
+ {data.tables.length} tables in {new Set(data.tables.map((t) => t.schema)).size} schemas, ~{totalRows.toLocaleString('en-GB')} rows. Only you can see this. Lists are editable; app tables are read-only until you unlock them; a new list is a new table.
+