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:
59
src/lib/components/ListExtraFields.svelte
Normal file
59
src/lib/components/ListExtraFields.svelte
Normal 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}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
296
src/lib/server/lists.ts
Normal 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})`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user