app: lists on meta tables, schema-qualified SQL, typed columns

- lists read/write real lists.* tables via meta.tables/meta.columns and the
  SECURITY DEFINER meta functions; typed extra columns in the item forms
- old numeric list URLs 308-redirect to the slug
- include_recurring is boolean, osu accuracy/pp/rate numeric
- raw SQL qualified with the new schemas
This commit is contained in:
2026-10-23 22:03:42 +00:00
parent 64fb8df262
commit 4cfe6bad6c
25 changed files with 529 additions and 218 deletions

View File

@@ -0,0 +1,59 @@
<script lang="ts">
// Form inputs for a list's own typed columns (meta.columns), posted as `extra:<column>`.
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
import type { ExtraValue, ListColumn } from '#lib/server/lists.js';
let { columns, values = {} }: { columns: ListColumn[]; values?: Record<string, ExtraValue> } =
$props();
const str = (v: ExtraValue | undefined) => (v === null || v === undefined ? '' : String(v));
</script>
{#each columns as column (column.name)}
{@const name = `extra:${column.name}`}
{#if column.uiType === 'bool'}
<label class="text-sm"
><Checkbox {name} value="on" checked={values[column.name] === true} /> {column.label}</label
>
{:else if column.uiType === 'select' && column.options.length}
<label
>{column.label}<NativeSelect
{name}
class="block w-full rounded border p-2"
value={str(values[column.name])}
>
<option value="">—</option>
{#each column.options as option (option)}<option value={option}>{option}</option>{/each}
</NativeSelect></label
>
{:else if column.uiType === 'longtext'}
<label
>{column.label}<Textarea
{name}
rows={3}
maxlength={2000}
class="block w-full rounded border p-2"
value={str(values[column.name])}
/></label
>
{:else if ['text', 'url', 'image', 'number', 'cents', 'date', 'select'].includes(column.uiType)}
<label
>{column.label}<Input
{name}
type={column.uiType === 'number' || column.uiType === 'cents'
? 'number'
: column.uiType === 'date'
? 'date'
: column.uiType === 'url' || column.uiType === 'image'
? 'url'
: 'text'}
step={column.uiType === 'number' ? 'any' : undefined}
maxlength={column.uiType === 'text' ? 2000 : undefined}
class="block w-full rounded border p-2"
value={str(values[column.name])}
/></label
>
{/if}
{/each}

View File

@@ -94,7 +94,7 @@ describe('money tracker calculations', () => {
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: 1,
includeRecurring: true,
endDate: '2026-11-30'
};
const accounts = [
@@ -140,7 +140,7 @@ describe('money tracker calculations', () => {
annualReturnPercent: '5.00',
contributionMode: 'excess',
fixedMonthlyCents: 0,
includeRecurring: 1,
includeRecurring: true,
endDate: '2026-11-30'
},
'2026-09-23'

View File

@@ -101,7 +101,7 @@ type ProjectionSettings = {
annualReturnPercent: string | number;
contributionMode: string;
fixedMonthlyCents: number;
includeRecurring: number;
includeRecurring: boolean;
endDate?: string | null;
};
@@ -160,7 +160,7 @@ export function wealthProjection(
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
} else spendable += entry.amountCents;
}
if (settings?.includeRecurring !== 0 && index > 0) {
if (settings?.includeRecurring !== false && index > 0) {
for (const item of recurring) {
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
continue;

296
src/lib/server/lists.ts Normal file
View File

@@ -0,0 +1,296 @@
// 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<string, ExtraValue>;
};
export type ListSummary = {
id: number;
name: string;
slug: string;
pictureUrl: string | null;
isProgress: boolean;
total: number;
checked: number;
};
export type ListView = Omit<ListSummary, 'total' | 'checked'> & {
columns: ListColumn[];
items: ListItem[];
};
function table(t: Pick<MetaTable, 'schemaName' | 'tableName'>) {
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<ListSummary[]> {
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<ListColumn[]> {
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<string, unknown> & { id: number; name: string };
function toItem(row: Row, columns: ListColumn[]): ListItem {
const extras: Record<string, ExtraValue> = {};
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<ListView | null> {
const t = await findList(slug);
if (!t) return null;
const columns = await listColumns(t);
const rows = await db.execute<Row>(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:<column>` form fields for this list's columns. */
export function extraFields(
form: FormData,
columns: ListColumn[]
): { values: Record<string, ExtraValue> } | { error: string } {
const values: Record<string, ExtraValue> = {};
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<string, ExtraValue>) {
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<string, ExtraValue>;
};
function rowValues(v: ItemValues): Record<string, ExtraValue> {
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<Row>(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})`);
}

View File

@@ -1,5 +1,5 @@
import { db } from '#lib/server/db/index.js';
import { blogArticles, lists } from '#lib/server/db/schema/index.js';
import { blogArticles, metaTables } from '#lib/server/db/schema/index.js';
import { eq } from 'drizzle-orm';
export function slugify(title: string, fallback = 'article'): string {
@@ -32,7 +32,10 @@ export async function uniqueListSlug(name: string): Promise<string> {
const base = slugify(name, 'list');
let slug = base;
let suffix = 2;
while ((await db.select({ id: lists.id }).from(lists).where(eq(lists.slug, slug))).length > 0) {
while (
(await db.select({ id: metaTables.id }).from(metaTables).where(eq(metaTables.slug, slug)))
.length > 0
) {
slug = `${base}-${suffix++}`;
}
return slug;