// Generic list engine. Every list is a real table in schema `lists`, registered // in meta.tables / meta.columns. Table and column names only ever come from the // registry (written by the SECURITY DEFINER meta.* functions, which validate // identifiers) and are re-checked here before being used as identifiers. import { and, asc, desc, eq, isNull, max, notInArray, sql } from 'drizzle-orm'; import { db } from '#lib/server/db/index.js'; import { metaColumns, metaTables, type MetaTable, type UiType } from '#lib/server/db/schema/index.js'; const IDENT = /^[a-z][a-z0-9_]{0,62}$/; /** Columns every list has; everything else is a list-specific extra column. */ const STANDARD = [ 'id', 'name', 'notes', 'picture_url', 'done_at', 'sort_order', 'created_at', 'updated_at' ]; export type ExtraValue = string | number | boolean | null; export type ListColumn = { name: string; label: string; uiType: UiType; options: string[] }; export type ListItem = { id: number; name: string; description: string | null; pictureUrl: string | null; doneAt: Date | null; extras: Record; }; export type ListSummary = { id: number; name: string; slug: string; pictureUrl: string | null; isProgress: boolean; total: number; checked: number; }; export type ListView = Omit & { columns: ListColumn[]; items: ListItem[]; }; function table(t: Pick) { if (t.schemaName !== 'lists' || !IDENT.test(t.tableName)) throw new Error(`refusing unexpected list table ${t.schemaName}.${t.tableName}`); return sql`${sql.identifier('lists')}.${sql.identifier(t.tableName)}`; } const activeList = and(eq(metaTables.kind, 'list'), isNull(metaTables.archivedAt)); export async function findList(slug: string) { const [row] = await db .select() .from(metaTables) .where(and(activeList, eq(metaTables.slug, slug))); return row ?? null; } export async function listSummaries(): Promise { const rows = await db .select() .from(metaTables) .where(activeList) .orderBy(desc(metaTables.sortOrder), desc(metaTables.id)); if (!rows.length) return []; const counts = await db.execute<{ id: number; total: number; checked: number }>( sql.join( rows.map( (t) => sql`SELECT ${t.id}::int AS id, count(*)::int AS total, ${ t.hasDone ? sql`count(done_at)::int` : sql`0` } AS checked FROM ${table(t)}` ), sql` UNION ALL ` ) ); const byId = new Map([...counts].map((c) => [Number(c.id), c])); return rows.map((t) => ({ id: t.id, name: t.displayName, slug: t.slug, pictureUrl: t.pictureUrl, isProgress: t.hasDone, total: byId.get(t.id)?.total ?? 0, checked: byId.get(t.id)?.checked ?? 0 })); } export async function listColumns(t: MetaTable): Promise { const rows = await db .select() .from(metaColumns) .where( and( eq(metaColumns.tableId, t.id), eq(metaColumns.hidden, false), notInArray(metaColumns.columnName, STANDARD) ) ) .orderBy(asc(metaColumns.position)); return rows .filter((c) => IDENT.test(c.columnName)) .map((c) => ({ name: c.columnName, label: c.label ?? c.columnName.replace(/_/g, ' '), uiType: c.uiType, options: c.options.values ?? [] })); } type Row = Record & { id: number; name: string }; function toItem(row: Row, columns: ListColumn[]): ListItem { const extras: Record = {}; for (const c of columns) { const v = row[c.name]; extras[c.name] = v instanceof Date ? v.toISOString() : ((v ?? null) as ExtraValue); } return { id: Number(row.id), name: row.name, description: (row.notes as string | null) ?? null, pictureUrl: (row.picture_url as string | null) ?? null, doneAt: row.done_at ? new Date(row.done_at as string) : null, extras }; } export async function getList(slug: string): Promise { const t = await findList(slug); if (!t) return null; const columns = await listColumns(t); const rows = await db.execute(sql`SELECT * FROM ${table(t)} ORDER BY sort_order, id`); return { id: t.id, name: t.displayName, slug: t.slug, pictureUrl: t.pictureUrl, isProgress: t.hasDone, columns, items: [...rows].map((r) => toItem(r, columns)) }; } /** Parse the `extra:` form fields for this list's columns. */ export function extraFields( form: FormData, columns: ListColumn[] ): { values: Record } | { error: string } { const values: Record = {}; for (const c of columns) { const key = `extra:${c.name}`; if (c.uiType === 'bool') { values[c.name] = form.get(key) === 'on'; continue; } if (!form.has(key)) continue; const raw = String(form.get(key) ?? '').trim(); if (!raw) { values[c.name] = null; continue; } if (raw.length > 2000) return { error: `${c.label} is too long.` }; switch (c.uiType) { case 'number': case 'cents': { const n = Number(raw); if (!Number.isFinite(n) || (c.uiType === 'cents' && !Number.isInteger(n))) return { error: `${c.label} must be a number.` }; values[c.name] = n; break; } case 'select': if (c.options.length && !c.options.includes(raw)) return { error: `${c.label} must be one of: ${c.options.join(', ')}.` }; values[c.name] = raw; break; case 'url': case 'image': if (!/^https?:\/\/\S+$/.test(raw)) return { error: `${c.label} must be an http(s) URL.` }; values[c.name] = raw; break; case 'date': if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return { error: `${c.label} must be a date.` }; values[c.name] = raw; break; case 'text': case 'longtext': values[c.name] = raw; break; default: // json / multiselect / fk are not editable from this form yet break; } } return { values }; } function columnsAndValues(values: Record) { const entries = Object.entries(values).filter(([k]) => IDENT.test(k)); return { cols: entries.map(([k]) => sql.identifier(k)), vals: entries.map(([, v]) => sql`${v}`), sets: entries.map(([k, v]) => sql`${sql.identifier(k)} = ${v}`) }; } export type ItemValues = { name: string; description: string | null; pictureUrl?: string | null; extras: Record; }; function rowValues(v: ItemValues): Record { return { ...v.extras, name: v.name, notes: v.description, ...('pictureUrl' in v ? { picture_url: v.pictureUrl ?? null } : {}) }; } export async function insertItem(t: MetaTable, v: ItemValues) { const { cols, vals } = columnsAndValues(rowValues(v)); const tbl = table(t); await db.execute( sql`INSERT INTO ${tbl} (${sql.join(cols, sql`, `)}, sort_order) VALUES (${sql.join(vals, sql`, `)}, (SELECT coalesce(max(sort_order), 0) + 1 FROM ${tbl}))` ); } export async function getItem(t: MetaTable, id: number) { const [row] = await db.execute(sql`SELECT * FROM ${table(t)} WHERE id = ${id}`); return row ? toItem(row, []) : null; } export async function updateItem(t: MetaTable, id: number, v: ItemValues) { const { sets } = columnsAndValues(rowValues(v)); const rows = await db.execute<{ id: number }>( sql`UPDATE ${table(t)} SET ${sql.join(sets, sql`, `)} WHERE id = ${id} RETURNING id` ); return rows.length > 0; } export async function deleteItem(t: MetaTable, id: number) { const [row] = await db.execute<{ picture_url: string | null }>( sql`DELETE FROM ${table(t)} WHERE id = ${id} RETURNING picture_url` ); return row ?? null; } export async function setDone(t: MetaTable, id: number, done: boolean) { if (!t.hasDone) throw new Error('not a progress list'); const rows = await db.execute<{ id: number }>( sql`UPDATE ${table(t)} SET done_at = ${done ? sql`coalesce(done_at, now())` : sql`NULL`} WHERE id = ${id} RETURNING id` ); return rows.length > 0; } /** Create a new runtime list (a real table) through the meta API. */ export async function createList(input: { slug: string; name: string; isProgress: boolean; pictureUrl: string | null; }) { let base = input.slug.replace(/-/g, '_').replace(/^[^a-z]+/, ''); base = (base || 'list').slice(0, 55); let tableName = base; for (let n = 2; ; n++) { const [taken] = await db .select({ id: metaTables.id }) .from(metaTables) .where(and(eq(metaTables.schemaName, 'lists'), eq(metaTables.tableName, tableName))); if (!taken) break; tableName = `${base}_${n}`; } const [{ top }] = await db.select({ top: max(metaTables.sortOrder) }).from(metaTables); await db.execute( sql`SELECT meta.create_list(${input.slug}, ${tableName}, ${input.name}, ${input.isProgress}, '[]'::jsonb, ${input.pictureUrl}, NULL, NULL, ${(top ?? 0) + 1})` ); } /** Soft delete: the table stays for 7 days (meta.drop_list refuses earlier). */ export async function archiveList(slug: string) { await db.execute(sql`SELECT meta.archive_list(${slug})`); }