Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s

This commit is contained in:
2026-10-24 02:52:17 +00:00
parent 941a9f16e1
commit 9d08e6af23
14 changed files with 15698 additions and 123 deletions

View File

@@ -0,0 +1,29 @@
CREATE TABLE "meta"."edit_log" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "meta"."edit_log_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"at" timestamp with time zone DEFAULT now() NOT NULL,
"user_id" text,
"user_email" text,
"schema_name" text NOT NULL,
"table_name" text NOT NULL,
"action" text NOT NULL,
"pk" jsonb,
"before" jsonb,
"after" jsonb,
CONSTRAINT "edit_log_action_check" CHECK (action IN ('insert','update','delete','import','add_column'))
);
--> statement-breakpoint
CREATE INDEX "edit_log_table_at_index" ON "meta"."edit_log" ("schema_name","table_name","at");--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- The app role appends to and reads the log; nobody updates or deletes through the app.
-- personal_ro gets no access: before/after values can hold private (health) data.
-- Grants only run on prod (as `personal`); the dev DB is owned by personal_dev.
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT ON meta.edit_log TO personal_app;
GRANT USAGE ON SEQUENCE meta.edit_log_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
REVOKE ALL ON meta.edit_log FROM personal_ro;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,9 @@
const kind = $derived(column.kind); const kind = $derived(column.kind);
const long = $derived( const long = $derived(
kind === 'json' || column.uiType === 'longtext' || (kind === 'text' && (value?.length ?? 0) > 80) kind === 'json' ||
column.uiType === 'longtext' ||
(kind === 'text' && (value?.length ?? 0) > 80)
); );
const listId = `fk-${Math.random().toString(36).slice(2)}`; const listId = `fk-${Math.random().toString(36).slice(2)}`;
let options = $state.raw<{ value: string; label: string }[]>([]); let options = $state.raw<{ value: string; label: string }[]>([]);
@@ -64,7 +66,11 @@
const base = const base =
'w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1 text-sm focus:outline-2 focus:outline-[var(--site-text)]'; 'w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1 text-sm focus:outline-2 focus:outline-[var(--site-text)]';
const placeholder = $derived( const placeholder = $derived(
value === null ? (column.hasDefault ? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}` : 'null') : '' value === null
? column.hasDefault
? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}`
: 'null'
: ''
); );
</script> </script>
@@ -78,7 +84,9 @@
{@attach focusOnMount} {@attach focusOnMount}
aria-label={column.label} aria-label={column.label}
> >
{#if column.nullable || column.hasDefault}<option value="">{column.hasDefault ? '(default)' : 'null'}</option>{/if} {#if column.nullable || column.hasDefault}<option value=""
>{column.hasDefault ? '(default)' : 'null'}</option
>{/if}
<option value="true">true</option> <option value="true">true</option>
<option value="false">false</option> <option value="false">false</option>
</select> </select>
@@ -91,7 +99,9 @@
{@attach focusOnMount} {@attach focusOnMount}
aria-label={column.label} aria-label={column.label}
> >
{#if column.nullable || column.hasDefault}<option value="">{column.hasDefault ? '(default)' : 'null'}</option>{/if} {#if column.nullable || column.hasDefault}<option value=""
>{column.hasDefault ? '(default)' : 'null'}</option
>{/if}
{#each column.enumValues as v (v)}<option value={v}>{v}</option>{/each} {#each column.enumValues as v (v)}<option value={v}>{v}</option>{/each}
</select> </select>
{:else if long} {:else if long}
@@ -103,8 +113,7 @@
oninput={(e) => (value = e.currentTarget.value)} oninput={(e) => (value = e.currentTarget.value)}
onkeydown={keydown} onkeydown={keydown}
{@attach focusOnMount} {@attach focusOnMount}
aria-label={column.label} aria-label={column.label}></textarea>
></textarea>
{:else} {:else}
<input <input
class={base} class={base}

View File

@@ -117,15 +117,20 @@ function summarise(r: CatalogRow): Omit<TableSummary, 'rows' | 'approximate'> {
}; };
} }
const rel = (schema: string, name: string) => sql`${sql.identifier(schema)}.${sql.identifier(name)}`; 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. */ /** Every table/view the app role can read, minus hidden ones, with row counts. */
export async function listTables(): Promise<TableSummary[]> { export async function listTables(): Promise<TableSummary[]> {
const rows = (await db.execute<CatalogRow>(catalogQuery(sql`true`))) as unknown as CatalogRow[]; const rows = (await db.execute<CatalogRow>(catalogQuery(sql`true`))) as unknown as CatalogRow[];
const visible = rows.map((r) => ({ r, s: summarise(r) })).filter(({ s }) => s.access !== 'hidden'); 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 // exact counts for real tables up to MAX_EXACT rows; estimates for huge ones; views are
// counted only when opened (some aggregate big tables). // counted only when opened (some aggregate big tables).
const exact = visible.filter(({ r }) => (r.relkind === 'r' || r.relkind === 'p') && r.estimate < MAX_EXACT); const exact = visible.filter(
({ r }) => (r.relkind === 'r' || r.relkind === 'p') && r.estimate < MAX_EXACT
);
const counts = new Map<string, number>(); const counts = new Map<string, number>();
if (exact.length) { if (exact.length) {
const res = await db.transaction(async (tx) => { const res = await db.transaction(async (tx) => {
@@ -133,7 +138,8 @@ export async function listTables(): Promise<TableSummary[]> {
return tx.execute<{ k: string; n: number }>( return tx.execute<{ k: string; n: number }>(
sql.join( sql.join(
exact.map( exact.map(
({ r }) => sql`SELECT ${`${r.schema}.${r.name}`}::text AS k, count(*)::int AS n FROM ${rel(r.schema, r.name)}` ({ r }) =>
sql`SELECT ${`${r.schema}.${r.name}`}::text AS k, count(*)::int AS n FROM ${rel(r.schema, r.name)}`
), ),
sql` UNION ALL ` sql` UNION ALL `
) )
@@ -233,7 +239,8 @@ export async function getTable(schema: string, name: string): Promise<TableInfo
.sort((a, b) => a.pk_pos! - b.pk_pos!) .sort((a, b) => a.pk_pos! - b.pk_pos!)
.map((c) => c.name); .map((c) => c.name);
// a key column that is hidden or has an odd type makes rows unaddressable: read-only // 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)); const keyUsable =
primaryKey.length > 0 && primaryKey.every((k) => columns.some((c) => c.name === k));
return { return {
...s, ...s,
access: s.access !== 'locked' && !keyUsable ? 'locked' : s.access, access: s.access !== 'locked' && !keyUsable ? 'locked' : s.access,
@@ -275,7 +282,9 @@ function whereClause(t: TableInfo, q: RowQuery): SQL {
const search = q.q?.trim(); const search = q.q?.trim();
if (search) { if (search) {
const texts = t.columns.map((c) => sql`t.${sql.identifier(c.name)}::text`); 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, '\\$&')}%`}`); parts.push(
sql`concat_ws(' ', ${sql.join(texts, sql`, `)}) ILIKE ${`%${search.replace(/[\\%_]/g, '\\$&')}%`}`
);
} }
for (const f of q.filters ?? []) { for (const f of q.filters ?? []) {
const { c, ref } = colRef(t, f.column); const { c, ref } = colRef(t, f.column);
@@ -302,7 +311,10 @@ function orderClause(t: TableInfo, q: RowQuery): SQL {
keys.push(sql`${ref} ${sql.raw(q.dir === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST')}`); keys.push(sql`${ref} ${sql.raw(q.dir === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST')}`);
} }
for (const k of t.primaryKey.length ? t.primaryKey : []) 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')}`); 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``; return keys.length ? sql`ORDER BY ${sql.join(keys, sql`, `)}` : sql``;
} }
@@ -367,10 +379,13 @@ export async function readRows(t: TableInfo, q: RowQuery): Promise<RowsResult> {
const fkLabels: RowsResult['fkLabels'] = {}; const fkLabels: RowsResult['fkLabels'] = {};
for (const c of t.columns) { for (const c of t.columns) {
if (!c.fk) continue; if (!c.fk) continue;
const values = [...new Set(visible.map((r) => r[c.name]).filter((v) => v !== null))].map(String); const values = [...new Set(visible.map((r) => r[c.name]).filter((v) => v !== null))].map(
String
);
if (!values.length) continue; if (!values.length) continue;
const labels = await fkOptions(c, { values }, tx).catch(() => []); const labels = await fkOptions(c, { values }, tx).catch(() => []);
if (labels.length) fkLabels[c.name] = Object.fromEntries(labels.map((o) => [o.value, o.label])); if (labels.length)
fkLabels[c.name] = Object.fromEntries(labels.map((o) => [o.value, o.label]));
} }
return { rows: visible, total, approximate, fkLabels }; return { rows: visible, total, approximate, fkLabels };
}); });
@@ -391,14 +406,19 @@ export async function fkOptions(
const key = target.columns.find((x) => x.name === c.fk!.column); const key = target.columns.find((x) => x.name === c.fk!.column);
if (!key) return []; if (!key) return [];
const labelCol = const labelCol =
['name', 'display_name', 'title', 'label', 'slug', 'email', 'code'].map((n) => ['name', 'display_name', 'title', 'label', 'slug', 'email', 'code']
target.columns.find((x) => x.name === n) .map((n) => target.columns.find((x) => x.name === n))
).find(Boolean) ?? key; .find(Boolean) ?? key;
const k = sql`t.${sql.identifier(key.name)}`; const k = sql`t.${sql.identifier(key.name)}`;
const l = sql`t.${sql.identifier(labelCol.name)}`; const l = sql`t.${sql.identifier(labelCol.name)}`;
const conds: SQL[] = []; const conds: SQL[] = [];
if (opts.values?.length) if (opts.values?.length)
conds.push(sql`${k}::text IN (${sql.join(opts.values.slice(0, 500).map((v) => sql`${v}`), sql`, `)})`); conds.push(
sql`${k}::text IN (${sql.join(
opts.values.slice(0, 500).map((v) => sql`${v}`),
sql`, `
)})`
);
if (opts.q?.trim()) { if (opts.q?.trim()) {
const like = `%${opts.q.trim().replace(/[\\%_]/g, '\\$&')}%`; const like = `%${opts.q.trim().replace(/[\\%_]/g, '\\$&')}%`;
conds.push(sql`(${l}::text ILIKE ${like} OR ${k}::text ILIKE ${like})`); conds.push(sql`(${l}::text ILIKE ${like} OR ${k}::text ILIKE ${like})`);
@@ -427,7 +447,7 @@ export async function exportRows(t: TableInfo, q: RowQuery, cap = 200_000) {
: sql`t.${sql.identifier(c.name)}::text` : sql`t.${sql.identifier(c.name)}::text`
); );
const rows = (await tx.execute<{ v: (string | null)[] }>( 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}` sql`SELECT to_jsonb(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)[] }[]; )) as unknown as { v: (string | null)[] }[];
return rows.map((r) => r.v); return rows.map((r) => r.v);
}); });
@@ -465,7 +485,12 @@ function assignments(t: TableInfo, values: Record<string, unknown>) {
if (!v.ok) throw new DataError(400, v.error); if (!v.ok) throw new DataError(400, v.error);
if (v.value === null && !c.nullable && !(c.hasDefault && raw === undefined)) if (v.value === null && !c.nullable && !(c.hasDefault && raw === undefined))
throw new DataError(400, `${c.label} is required.`); throw new DataError(400, `${c.label} is required.`);
if (c.kind === 'enum' && v.value !== null && c.enumValues.length && !c.enumValues.includes(v.value)) 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(', ')}.`); throw new DataError(400, `${c.label} must be one of ${c.enumValues.join(', ')}.`);
out.push({ c, v: v.value }); out.push({ c, v: v.value });
} }
@@ -479,7 +504,11 @@ function pkWhere(t: TableInfo, pk: Pk) {
const c = t.columns.find((x) => x.name === k)!; const c = t.columns.find((x) => x.name === k)!;
const raw = pk[k]; const raw = pk[k];
if (raw === undefined || raw === null) throw new DataError(400, `Missing key ${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); 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}.`); if (!v.ok || v.value === null) throw new DataError(400, `Invalid key ${k}.`);
return sql`t.${sql.identifier(k)} = ${cast(v.value, c)}`; return sql`t.${sql.identifier(k)} = ${cast(v.value, c)}`;
}), }),
@@ -537,15 +566,26 @@ async function write<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
const one = <T>(rows: unknown) => (rows as T[])[0]; const one = <T>(rows: unknown) => (rows as T[])[0];
export async function insertRow(t: TableInfo, values: Record<string, unknown>, unlocked: boolean, actor: Actor) { export async function insertRow(
t: TableInfo,
values: Record<string, unknown>,
unlocked: boolean,
actor: Actor
) {
assertWritable(t, unlocked, 'insert'); assertWritable(t, unlocked, 'insert');
const set = assignments(t, values); const set = assignments(t, values);
return write(async (tx) => { return write(async (tx) => {
const row = one<{ r: Record<string, unknown> }>( const row = one<{ r: Record<string, unknown> }>(
await tx.execute( await tx.execute(
set.length set.length
? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(set.map((s) => sql.identifier(s.c.name)), sql`, `)}) ? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(
VALUES (${sql.join(set.map((s) => cast(s.v, s.c)), sql`, `)}) RETURNING ${rowJson(t)} AS r` 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` : sql`INSERT INTO ${rel(t.schema, t.name)} AS t DEFAULT VALUES RETURNING ${rowJson(t)} AS r`
) )
).r; ).r;
@@ -568,16 +608,29 @@ export async function updateRow(
const where = pkWhere(t, pk); const where = pkWhere(t, pk);
return write(async (tx) => { return write(async (tx) => {
const before = one<{ r: Record<string, unknown> }>( const before = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`SELECT ${rowJson(t)} AS r FROM ${rel(t.schema, t.name)} t WHERE ${where} FOR UPDATE`) await tx.execute(
sql`SELECT ${rowJson(t)} AS r FROM ${rel(t.schema, t.name)} t WHERE ${where} FOR UPDATE`
)
)?.r; )?.r;
if (!before) throw new DataError(404, 'That row no longer exists.'); if (!before) throw new DataError(404, 'That row no longer exists.');
const after = one<{ r: Record<string, unknown> }>( const after = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`UPDATE ${rel(t.schema, t.name)} AS t 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`, `)} 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`) WHERE ${where} RETURNING ${rowJson(t)} AS r`)
).r; ).r;
const changed = Object.fromEntries(set.map((s) => [s.c.name, before[s.c.name]])); 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]]))); await audit(
tx,
actor,
t,
'update',
pk,
changed,
Object.fromEntries(set.map((s) => [s.c.name, after[s.c.name]]))
);
return after; return after;
}); });
} }
@@ -590,7 +643,9 @@ export async function deleteRows(t: TableInfo, pks: Pk[], unlocked: boolean, act
let n = 0; let n = 0;
for (let i = 0; i < wheres.length; i++) { for (let i = 0; i < wheres.length; i++) {
const before = one<{ r: Record<string, unknown> }>( const before = one<{ r: Record<string, unknown> }>(
await tx.execute(sql`DELETE FROM ${rel(t.schema, t.name)} AS t WHERE ${wheres[i]} RETURNING ${rowJson(t)} AS r`) await tx.execute(
sql`DELETE FROM ${rel(t.schema, t.name)} AS t WHERE ${wheres[i]} RETURNING ${rowJson(t)} AS r`
)
)?.r; )?.r;
if (!before) continue; if (!before) continue;
n++; n++;
@@ -614,7 +669,8 @@ export async function importRows(
const names = header.map((h) => h.trim()); const names = header.map((h) => h.trim());
const unknown = names.filter((n) => n && !t.columns.some((c) => c.name === n)); 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 (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.'); if (new Set(names).size !== names.length)
throw new DataError(400, 'Duplicate column in the header.');
const prepared = body.map((cells, i) => { const prepared = body.map((cells, i) => {
if (cells.length !== names.length) if (cells.length !== names.length)
throw new DataError(400, `Row ${i + 2} has ${cells.length} cells, expected ${names.length}.`); throw new DataError(400, `Row ${i + 2} has ${cells.length} cells, expected ${names.length}.`);
@@ -637,8 +693,14 @@ export async function importRows(
for (const set of prepared) for (const set of prepared)
await tx.execute( await tx.execute(
set.length set.length
? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(set.map((s) => sql.identifier(s.c.name)), sql`, `)}) ? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(
VALUES (${sql.join(set.map((s) => cast(s.v, s.c)), sql`, `)})` 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` : sql`INSERT INTO ${rel(t.schema, t.name)} DEFAULT VALUES`
); );
await audit(tx, actor, t, 'import', null, null, { rows: prepared.length, columns: names }); await audit(tx, actor, t, 'import', null, null, { rows: prepared.length, columns: names });
@@ -646,7 +708,20 @@ export async function importRows(
}); });
} }
export const LIST_UI_TYPES = ['text', 'longtext', 'url', 'image', 'number', 'cents', 'date', 'datetime', 'bool', 'select', 'multiselect', 'json'] as const; 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(). */ /** Add a typed column to a runtime list (lists.* only) through meta.add_column(). */
export async function addListColumn( export async function addListColumn(
@@ -657,15 +732,23 @@ export async function addListColumn(
if (!t.isList || !t.listSlug || t.access !== 'open') if (!t.isList || !t.listSlug || t.access !== 'open')
throw new DataError(403, 'Columns can only be added to lists.'); throw new DataError(403, 'Columns can only be added to lists.');
if (!/^[a-z][a-z0-9_]{0,62}$/.test(input.name)) 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.'); 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)) if (!(LIST_UI_TYPES as readonly string[]).includes(input.uiType))
throw new DataError(400, 'Unknown column type.'); throw new DataError(400, 'Unknown column type.');
if (t.columns.some((c) => c.name === input.name)) throw new DataError(409, 'That column already exists.'); if (t.columns.some((c) => c.name === input.name))
throw new DataError(409, 'That column already exists.');
const col: Record<string, unknown> = { name: input.name, ui_type: input.uiType }; const col: Record<string, unknown> = { name: input.name, ui_type: input.uiType };
if (input.label?.trim()) col.label = input.label.trim().slice(0, 100); if (input.label?.trim()) col.label = input.label.trim().slice(0, 100);
if (input.uiType === 'select' || input.uiType === 'multiselect') { if (input.uiType === 'select' || input.uiType === 'multiselect') {
const values = (input.options ?? []).map((o) => o.trim()).filter(Boolean).slice(0, 100); const values = (input.options ?? [])
if (input.uiType === 'select' && !values.length) throw new DataError(400, 'Give the select at least one option.'); .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 }; col.options = { values };
} }
await write(async (tx) => { await write(async (tx) => {

View File

@@ -60,7 +60,13 @@ describe('tableAccess', () => {
describe('columns and identifiers', () => { describe('columns and identifiers', () => {
it('hides credential-like columns only', () => { it('hides credential-like columns only', () => {
for (const c of ['password', 'access_token', 'refresh_token_expires_at', 'api_key', 'client_secret']) for (const c of [
'password',
'access_token',
'refresh_token_expires_at',
'api_key',
'client_secret'
])
expect(hiddenColumn(c)).toBe(true); expect(hiddenColumn(c)).toBe(true);
for (const c of ['key', 'session_id', 'iban', 'author', 'market_hash_name', 'r2_key']) for (const c of ['key', 'session_id', 'iban', 'author', 'market_hash_name', 'r2_key'])
expect(hiddenColumn(c)).toBe(false); expect(hiddenColumn(c)).toBe(false);
@@ -107,20 +113,34 @@ describe('coerce', () => {
describe('filters and csv', () => { describe('filters and csv', () => {
it('parses filters', () => { it('parses filters', () => {
expect(parseFilter('name:contains:foo:bar')).toEqual({ column: 'name', op: 'contains', value: 'foo:bar' }); 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('done_at:null')).toEqual({ column: 'done_at', op: 'null', value: '' });
expect(parseFilter('x;y:eq:1')).toBeNull(); expect(parseFilter('x;y:eq:1')).toBeNull();
expect(parseFilter('name:like:1')).toBeNull(); expect(parseFilter('name:like:1')).toBeNull();
}); });
it('round-trips CSV', () => { it('round-trips CSV', () => {
const csv = toCsv(['a', 'b'], [['x, y', 'he said "hi"'], [null, { k: 1 }], ['line\nbreak', 3]]); const csv = toCsv(
['a', 'b'],
[
['x, y', 'he said "hi"'],
[null, { k: 1 }],
['line\nbreak', 3]
]
);
expect(parseCsv(csv)).toEqual([ expect(parseCsv(csv)).toEqual([
['a', 'b'], ['a', 'b'],
['x, y', 'he said "hi"'], ['x, y', 'he said "hi"'],
['', '{"k":1}'], ['', '{"k":1}'],
['line\nbreak', '3'] ['line\nbreak', '3']
]); ]);
expect(parseCsv('\ufeffa,b\r\n1,2')).toEqual([['a', 'b'], ['1', '2']]); expect(parseCsv('\ufeffa,b\r\n1,2')).toEqual([
['a', 'b'],
['1', '2']
]);
expect(() => parseCsv('a\n"open')).toThrow(); expect(() => parseCsv('a\n"open')).toThrow();
}); });
}); });

View File

@@ -53,7 +53,9 @@ export function writeAllowed(access: Access, unlocked: boolean): boolean {
/** Identifiers coming from the URL/body are looked up in the catalog; this is a cheap /** 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. */ * pre-filter so obviously bad input never reaches a query at all. */
export function plausibleIdent(s: unknown): s is string { 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); 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). */ /** format_type() output that is safe to splice into a CAST (it comes from pg_catalog). */
@@ -61,7 +63,18 @@ export function safeTypeName(type: string): boolean {
return /^[A-Za-z0-9_ ."(),[\]]{1,120}$/.test(type) && !type.includes('--') && !type.includes(';'); 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 type Kind =
| 'text'
| 'number'
| 'bool'
| 'date'
| 'datetime'
| 'time'
| 'json'
| 'enum'
| 'array'
| 'uuid'
| 'other';
export function typeKind(type: string, isEnum: boolean): Kind { export function typeKind(type: string, isEnum: boolean): Kind {
if (type.endsWith('[]')) return 'array'; if (type.endsWith('[]')) return 'array';
@@ -106,7 +119,10 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
try { try {
items = JSON.parse(s); items = JSON.parse(s);
} catch { } catch {
items = s.split(',').map((x) => x.trim()).filter(Boolean); items = s
.split(',')
.map((x) => x.trim())
.filter(Boolean);
} }
} }
if (!Array.isArray(items)) return { ok: false, error: `${label} must be a list.` }; if (!Array.isArray(items)) return { ok: false, error: `${label} must be a list.` };
@@ -138,7 +154,8 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
return { ok: false, error: `${label} must be true or false.` }; return { ok: false, error: `${label} must be true or false.` };
} }
case 'date': case 'date':
if (!/^\d{4}-\d{2}-\d{2}/.test(t)) return { ok: false, error: `${label} must be a date (YYYY-MM-DD).` }; 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 }; return { ok: true, value: t };
case 'datetime': case 'datetime':
if (Number.isNaN(Date.parse(t)) && !/^\d{4}-\d{2}-\d{2}/.test(t)) if (Number.isNaN(Date.parse(t)) && !/^\d{4}-\d{2}-\d{2}/.test(t))
@@ -154,7 +171,17 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
} }
/** Filter operators accepted by the grid (?f=<column>:<op>:<value>). */ /** Filter operators accepted by the grid (?f=<column>:<op>:<value>). */
export const FILTER_OPS = ['contains', 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'null', 'notnull'] as const; export const FILTER_OPS = [
'contains',
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'null',
'notnull'
] as const;
export type FilterOp = (typeof FILTER_OPS)[number]; export type FilterOp = (typeof FILTER_OPS)[number];
export type Filter = { column: string; op: FilterOp; value: string }; export type Filter = { column: string; op: FilterOp; value: string };

View File

@@ -44,4 +44,7 @@ export function failure(e: unknown) {
throw e; throw e;
} }
export const noStore = { 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex, nofollow' }; export const noStore = {
'cache-control': 'private, no-store',
'x-robots-tag': 'noindex, nofollow'
};

View File

@@ -1,4 +1,5 @@
import { import {
bigint,
boolean, boolean,
check, check,
index, index,
@@ -88,5 +89,29 @@ export const metaColumns = metaSchema.table(
] ]
); );
/** Audit trail of every write made through the database browser (/lists/db). */
export const metaEditLog = metaSchema.table(
'edit_log',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
at: ts('at').notNull().defaultNow(),
userId: text('user_id'),
userEmail: text('user_email'),
schemaName: text('schema_name').notNull(),
tableName: text('table_name').notNull(),
action: text('action').notNull(),
pk: jsonb('pk'),
before: jsonb('before'),
after: jsonb('after')
},
(t) => [
index('edit_log_table_at_index').on(t.schemaName, t.tableName, t.at),
check(
'edit_log_action_check',
sql`action IN ('insert','update','delete','import','add_column')`
)
]
);
export type MetaTable = typeof metaTables.$inferSelect; export type MetaTable = typeof metaTables.$inferSelect;
export type MetaColumn = typeof metaColumns.$inferSelect; export type MetaColumn = typeof metaColumns.$inferSelect;

View File

@@ -17,13 +17,16 @@
let tableFilter = $state(''); let tableFilter = $state('');
const schemas = $derived.by(() => { const schemas = $derived.by(() => {
const q = tableFilter.trim().toLowerCase(); const q = tableFilter.trim().toLowerCase();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, rebuilt on every derive
const groups = new Map<string, typeof data.tables>(); const groups = new Map<string, typeof data.tables>();
for (const t of data.tables) { for (const t of data.tables) {
if (q && !`${t.schema}.${t.name} ${t.displayName}`.toLowerCase().includes(q)) continue; if (q && !`${t.schema}.${t.name} ${t.displayName}`.toLowerCase().includes(q)) continue;
groups.set(t.schema, [...(groups.get(t.schema) ?? []), t]); groups.set(t.schema, [...(groups.get(t.schema) ?? []), t]);
} }
// lists first, then alphabetical // lists first, then alphabetical
return [...groups].sort(([a], [b]) => (a === 'lists' ? -1 : b === 'lists' ? 1 : a.localeCompare(b))); 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 totalRows = $derived(data.tables.reduce((n, t) => n + (t.rows ?? 0), 0));
const badge = (access: string, relkind: string) => const badge = (access: string, relkind: string) =>
@@ -31,7 +34,10 @@
? { text: 'editable', cls: 'border-emerald-600/40 text-emerald-500' } ? { text: 'editable', cls: 'border-emerald-600/40 text-emerald-500' }
: access === 'core' : access === 'core'
? { text: 'app table', cls: 'border-amber-500/40 text-amber-500' } ? { 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)]' }; : {
text: relkind === 'v' || relkind === 'm' ? 'view' : 'read-only',
cls: 'border-[var(--site-border)] text-[var(--site-muted)]'
};
let uploadError = $state(''); let uploadError = $state('');
let uploading = $state(false); let uploading = $state(false);
const uploadPicture: SubmitFunction = async ({ formData, cancel }) => { const uploadPicture: SubmitFunction = async ({ formData, cancel }) => {
@@ -162,7 +168,10 @@
<div> <div>
<h2 class="text-2xl font-semibold tracking-tight">database</h2> <h2 class="text-2xl font-semibold tracking-tight">database</h2>
<p class="mt-1 text-sm text-[var(--site-muted)]"> <p class="mt-1 text-sm text-[var(--site-muted)]">
{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. {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.
</p> </p>
</div> </div>
<input <input
@@ -177,7 +186,9 @@
<details open class="border-t border-[var(--site-border)] py-3" id="schema-{schema}"> <details open class="border-t border-[var(--site-border)] py-3" id="schema-{schema}">
<summary class="cursor-pointer text-lg font-medium"> <summary class="cursor-pointer text-lg font-medium">
{schema} {schema}
<span class="ml-2 text-sm font-normal text-[var(--site-muted)]">{tables.length} {tables.length === 1 ? 'table' : 'tables'}</span> <span class="ml-2 text-sm font-normal text-[var(--site-muted)]"
>{tables.length} {tables.length === 1 ? 'table' : 'tables'}</span
>
</summary> </summary>
<ul class="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3"> <ul class="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
{#each tables as t (t.name)} {#each tables as t (t.name)}
@@ -187,10 +198,14 @@
href={resolve('/lists/db/[schema]/[table]', { schema: t.schema, table: t.name })} href={resolve('/lists/db/[schema]/[table]', { schema: t.schema, table: t.name })}
class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-[var(--site-hover)]" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-[var(--site-hover)]"
> >
<span class="min-w-0 flex-1 truncate" title={t.description ?? t.displayName}>{t.name}</span> <span class="min-w-0 flex-1 truncate" title={t.description ?? t.displayName}
>{t.name}</span
>
<span class={['rounded-full border px-1.5 text-[10px]', b.cls]}>{b.text}</span> <span class={['rounded-full border px-1.5 text-[10px]', b.cls]}>{b.text}</span>
<span class="w-16 text-right text-xs text-[var(--site-muted)] tabular-nums"> <span class="w-16 text-right text-xs text-[var(--site-muted)] tabular-nums">
{t.rows === null ? '–' : `${t.approximate ? '~' : ''}${t.rows.toLocaleString('en-GB')}`} {t.rows === null
? '–'
: `${t.approximate ? '~' : ''}${t.rows.toLocaleString('en-GB')}`}
</span> </span>
</a> </a>
</li> </li>

View File

@@ -100,7 +100,10 @@
</div> </div>
</div> </div>
{#if data.isOwner}<a {#if data.isOwner}<a
href={resolve('/lists/db/[schema]/[table]', { schema: 'lists', table: data.list.tableName })} href={resolve('/lists/db/[schema]/[table]', {
schema: 'lists',
table: data.list.tableName
})}
class="text-base underline underline-offset-4">table view</a class="text-base underline underline-offset-4">table view</a
>{/if} >{/if}
{#if data.isAdmin}<Button {#if data.isAdmin}<Button

View File

@@ -5,7 +5,6 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const { table } = await ownerTable(event); const { table } = await ownerTable(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
const query = rowQuery(event.url); const query = rowQuery(event.url);
try { try {
const result = await readRows(table, query); const result = await readRows(table, query);

View File

@@ -6,7 +6,13 @@
import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js'; import * as AlertDialog from '#lib/components/ui/alert-dialog/index.js';
import { Button } from '#lib/components/ui/button/index.js'; import { Button } from '#lib/components/ui/button/index.js';
import FieldInput from '#lib/components/data/FieldInput.svelte'; import FieldInput from '#lib/components/data/FieldInput.svelte';
import { display, fromEditor, shortType, toEditor, type Col } from '#lib/components/data/data-format.js'; import {
display,
fromEditor,
shortType,
toEditor,
type Col
} from '#lib/components/data/data-format.js';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -42,14 +48,14 @@
node.value = search; node.value = search;
} }
function navigate(change: Record<string, string | string[] | null>, keepPage = false) { function navigate(change: Record<string, string | string[] | null>, keepPage = false) {
const url = new URL(page.url); const url = new URL(page.url.href);
for (const [k, v] of Object.entries(change)) { for (const [k, v] of Object.entries(change)) {
url.searchParams.delete(k); url.searchParams.delete(k);
if (Array.isArray(v)) for (const x of v) url.searchParams.append(k, x); if (Array.isArray(v)) for (const x of v) url.searchParams.append(k, x);
else if (v !== null && v !== '') url.searchParams.set(k, v); else if (v !== null && v !== '') url.searchParams.set(k, v);
} }
if (!keepPage) url.searchParams.delete('page'); if (!keepPage) url.searchParams.delete('page');
goto(url, { keepFocus: true, noScroll: true, replaceState: true }); goto(url, { reset: false, replace: true });
} }
let searchTimer: ReturnType<typeof setTimeout> | undefined; let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearch(value: string) { function onSearch(value: string) {
@@ -107,7 +113,13 @@
let message = $state<{ kind: 'error' | 'ok'; text: string } | null>(null); let message = $state<{ kind: 'error' | 'ok'; text: string } | null>(null);
let grid = $state<HTMLTableElement>(); let grid = $state<HTMLTableElement>();
async function api(path: string, method: string, body?: unknown, raw?: BodyInit, type?: string) { async function api(
path: string,
method: string,
body?: unknown,
raw?: string | Blob,
type?: string
) {
const res = await fetch(`${base}/${path}`, { const res = await fetch(`${base}/${path}`, {
method, method,
headers: { headers: {
@@ -125,7 +137,10 @@
if (kind === 'ok') setTimeout(() => message?.text === text && (message = null), 2500); if (kind === 'ok') setTimeout(() => message?.text === text && (message = null), 2500);
} }
function focusCell(r: number, c: number) { function focusCell(r: number, c: number) {
active = { r: Math.max(0, Math.min(rows.length - 1, r)), c: Math.max(0, Math.min(cols.length - 1, c)) }; active = {
r: Math.max(0, Math.min(rows.length - 1, r)),
c: Math.max(0, Math.min(cols.length - 1, c))
};
queueMicrotask(() => queueMicrotask(() =>
grid?.querySelector<HTMLElement>(`[data-cell="${active.r}:${active.c}"]`)?.focus() grid?.querySelector<HTMLElement>(`[data-cell="${active.r}:${active.c}"]`)?.focus()
); );
@@ -151,7 +166,10 @@
} }
saving = true; saving = true;
try { try {
await api('rows', 'PATCH', { pk: pkOf(rows[r]), values: { [col.name]: fromEditor(col, draft) } }); await api('rows', 'PATCH', {
pk: pkOf(rows[r]),
values: { [col.name]: fromEditor(col, draft) }
});
editing = null; editing = null;
await invalidateAll(); await invalidateAll();
flash('ok', 'Saved'); flash('ok', 'Saved');
@@ -257,7 +275,10 @@
name: colName, name: colName,
label: colLabel, label: colLabel,
uiType: colType, uiType: colType,
options: colOptions.split(',').map((s) => s.trim()).filter(Boolean) options: colOptions
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}); });
colOpen = false; colOpen = false;
colName = colLabel = colOptions = ''; colName = colLabel = colOptions = '';
@@ -282,7 +303,13 @@
async function confirmImport() { async function confirmImport() {
if (!importFile) return; if (!importFile) return;
try { try {
const { inserted } = await api('import', 'POST', undefined, await importFile.text(), 'text/csv'); const { inserted } = await api(
'import',
'POST',
undefined,
await importFile.text(),
'text/csv'
);
flash('ok', `Imported ${inserted} row${inserted === 1 ? '' : 's'}`); flash('ok', `Imported ${inserted} row${inserted === 1 ? '' : 's'}`);
await invalidateAll(); await invalidateAll();
} catch (e) { } catch (e) {
@@ -297,7 +324,12 @@
let searchBox = $state<HTMLInputElement>(); let searchBox = $state<HTMLInputElement>();
function globalKey(e: KeyboardEvent) { function globalKey(e: KeyboardEvent) {
const el = e.target as HTMLElement; const el = e.target as HTMLElement;
if (el.closest('input, textarea, select, [contenteditable], [role="dialog"], [role="alertdialog"]')) return; if (
el.closest(
'input, textarea, select, [contenteditable], [role="dialog"], [role="alertdialog"]'
)
)
return;
if (e.key === '/') { if (e.key === '/') {
e.preventDefault(); e.preventDefault();
searchBox?.focus(); searchBox?.focus();
@@ -311,9 +343,15 @@
? { text: 'list · editable', cls: 'border-emerald-600/40 text-emerald-500' } ? { text: 'list · editable', cls: 'border-emerald-600/40 text-emerald-500' }
: t.access === 'core' : t.access === 'core'
? unlocked ? unlocked
? { text: 'app table · editing unlocked', cls: 'border-amber-500/60 bg-amber-500/10 text-amber-500' } ? {
text: 'app table · editing unlocked',
cls: 'border-amber-500/60 bg-amber-500/10 text-amber-500'
}
: { text: 'app table · read-only', cls: 'border-amber-500/40 text-amber-500' } : { text: 'app table · read-only', cls: 'border-amber-500/40 text-amber-500' }
: { text: t.relkind === 'v' || t.relkind === 'm' ? 'view · read-only' : 'read-only', cls: 'border-[var(--site-border)] text-[var(--site-muted)]' } : {
text: t.relkind === 'v' || t.relkind === 'm' ? 'view · read-only' : 'read-only',
cls: 'border-[var(--site-border)] text-[var(--site-muted)]'
}
); );
</script> </script>
@@ -328,22 +366,35 @@
</nav> </nav>
<div class="mb-4 flex flex-wrap items-center gap-3"> <div class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-semibold tracking-tight">{t.displayName}</h1> <h1 class="text-3xl font-semibold tracking-tight">{t.displayName}</h1>
<span class={['rounded-full border px-2.5 py-0.5 text-xs', accessBadge.cls]}>{accessBadge.text}</span> <span class={['rounded-full border px-2.5 py-0.5 text-xs', accessBadge.cls]}
{#if t.listSlug}<a class="text-sm underline underline-offset-4" href={resolve('/lists/[slug]', { slug: t.listSlug })}>list view</a>{/if} >{accessBadge.text}</span
>
{#if t.listSlug}<a
class="text-sm underline underline-offset-4"
href={resolve('/lists/[slug]', { slug: t.listSlug })}>list view</a
>{/if}
<span class="ml-auto text-sm text-[var(--site-muted)]"> <span class="ml-auto text-sm text-[var(--site-muted)]">
{#if data.total !== null}{data.approximate ? '~' : ''}{data.total.toLocaleString('en-GB')} rows{:else}rows: ?{/if} {#if data.total !== null}{data.approximate ? '~' : ''}{data.total.toLocaleString('en-GB')} rows{:else}rows:
?{/if}
</span> </span>
</div> </div>
{#if t.description}<p class="mb-4 text-sm text-[var(--site-muted)]">{t.description}</p>{/if} {#if t.description}<p class="mb-4 text-sm text-[var(--site-muted)]">{t.description}</p>{/if}
{#if t.access === 'core'} {#if t.access === 'core'}
<div class="mb-4 flex flex-wrap items-center gap-3 rounded border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm"> <div
class="mb-4 flex flex-wrap items-center gap-3 rounded border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm"
>
{#if unlocked} {#if unlocked}
<span>Editing is unlocked for this table (this browser tab). Changes go straight into data the {t.schema} app uses, and are logged.</span> <span
>Editing is unlocked for this table (this browser tab). Changes go straight into data the {t.schema}
app uses, and are logged.</span
>
<Button size="sm" variant="outline" onclick={() => setUnlocked(false)}>lock again</Button> <Button size="sm" variant="outline" onclick={() => setUnlocked(false)}>lock again</Button>
{:else} {:else}
<span>This table belongs to the {t.schema} app, so it is read-only by default.</span> <span>This table belongs to the {t.schema} app, so it is read-only by default.</span>
<Button size="sm" variant="outline" onclick={() => (unlockOpen = true)}>unlock editing…</Button> <Button size="sm" variant="outline" onclick={() => (unlockOpen = true)}
>unlock editing…</Button
>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -359,14 +410,27 @@
aria-label="Search rows" aria-label="Search rows"
/> />
<form class="flex flex-wrap items-center gap-1" onsubmit={addFilter}> <form class="flex flex-wrap items-center gap-1" onsubmit={addFilter}>
<select bind:value={fCol} class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm" aria-label="Filter column"> <select
bind:value={fCol}
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
aria-label="Filter column"
>
{#each cols as c (c.name)}<option value={c.name}>{c.name}</option>{/each} {#each cols as c (c.name)}<option value={c.name}>{c.name}</option>{/each}
</select> </select>
<select bind:value={fOp} class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm" aria-label="Filter operator"> <select
bind:value={fOp}
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
aria-label="Filter operator"
>
{#each OPS as [k, label] (k)}<option value={k}>{label}</option>{/each} {#each OPS as [k, label] (k)}<option value={k}>{label}</option>{/each}
</select> </select>
{#if fOp !== 'null' && fOp !== 'notnull'} {#if fOp !== 'null' && fOp !== 'notnull'}
<input bind:value={fVal} class="w-36 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm" placeholder="value" aria-label="Filter value" /> <input
bind:value={fVal}
class="w-36 rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5 text-sm"
placeholder="value"
aria-label="Filter value"
/>
{/if} {/if}
<Button size="sm" variant="outline" type="submit">filter</Button> <Button size="sm" variant="outline" type="submit">filter</Button>
</form> </form>
@@ -374,14 +438,34 @@
{#if editable && t.canInsert} {#if editable && t.canInsert}
<Button size="sm" onclick={openNew} title="New row (n)">+ row</Button> <Button size="sm" onclick={openNew} title="New row (n)">+ row</Button>
<Button size="sm" variant="outline" onclick={() => fileInput?.click()}>import CSV</Button> <Button size="sm" variant="outline" onclick={() => fileInput?.click()}>import CSV</Button>
<input bind:this={fileInput} type="file" accept=".csv,text/csv" class="hidden" onchange={pickImport} /> <input
bind:this={fileInput}
type="file"
accept=".csv,text/csv"
class="hidden"
onchange={pickImport}
/>
{/if} {/if}
{#if t.isList && t.access === 'open'} {#if t.isList && t.access === 'open'}
<Button size="sm" variant="outline" onclick={() => { colError = ''; colOpen = true; }}>+ column</Button> <Button
size="sm"
variant="outline"
onclick={() => {
colError = '';
colOpen = true;
}}>+ column</Button
>
{/if} {/if}
<a class="rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-hover)]" href={exportHref} data-sveltekit-reload download>export CSV</a> <a
class="rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-hover)]"
href={exportHref}
data-sveltekit-reload
download>export CSV</a
>
{#if editable && t.canDelete && selectedRows.length} {#if editable && t.canDelete && selectedRows.length}
<Button size="sm" variant="destructive" onclick={() => askDelete(selectedRows)}>delete {selectedRows.length}</Button> <Button size="sm" variant="destructive" onclick={() => askDelete(selectedRows)}
>delete {selectedRows.length}</Button
>
{/if} {/if}
</div> </div>
</div> </div>
@@ -399,11 +483,24 @@
{/if} {/if}
{#if data.queryError}<p role="alert" class="mb-3 text-sm text-red-500">{data.queryError}</p>{/if} {#if data.queryError}<p role="alert" class="mb-3 text-sm text-red-500">{data.queryError}</p>{/if}
{#if message} {#if message}
<p role={message.kind === 'error' ? 'alert' : 'status'} class={['mb-3 text-sm', message.kind === 'error' ? 'text-red-500' : 'text-emerald-500']}>{message.text}</p> <p
role={message.kind === 'error' ? 'alert' : 'status'}
class={['mb-3 text-sm', message.kind === 'error' ? 'text-red-500' : 'text-emerald-500']}
>
{message.text}
</p>
{/if} {/if}
<div class="overflow-auto rounded border border-[var(--site-border)]" style="max-height: calc(100vh - 16rem)"> <div
<table bind:this={grid} class="w-full border-collapse text-sm" role="grid" aria-rowcount={data.total ?? -1}> class="overflow-auto rounded border border-[var(--site-border)]"
style="max-height: calc(100vh - 16rem)"
>
<table
bind:this={grid}
class="w-full border-collapse text-sm"
role="grid"
aria-rowcount={data.total ?? -1}
>
<thead class="sticky top-0 z-10 bg-[var(--site-surface)]"> <thead class="sticky top-0 z-10 bg-[var(--site-surface)]">
<tr> <tr>
{#if t.primaryKey.length} {#if t.primaryKey.length}
@@ -412,38 +509,73 @@
type="checkbox" type="checkbox"
aria-label="Select all rows on this page" aria-label="Select all rows on this page"
checked={rows.length > 0 && selectedRows.length === rows.length} checked={rows.length > 0 && selectedRows.length === rows.length}
onchange={(e) => (selected = e.currentTarget.checked ? Object.fromEntries(rows.map((r) => [keyOf(r), true])) : {})} onchange={(e) =>
(selected = e.currentTarget.checked
? Object.fromEntries(rows.map((r) => [keyOf(r), true]))
: {})}
/> />
</th> </th>
{/if} {/if}
{#each cols as c (c.name)} {#each cols as c (c.name)}
{@const sorted = params.get('sort') === c.name} {@const sorted = params.get('sort') === c.name}
<th class="border-b border-[var(--site-border)] px-2 py-1.5 text-left align-bottom font-medium whitespace-nowrap"> <th
<button class="text-left hover:underline" onclick={() => sortBy(c)} title={c.help ?? `Sort by ${c.name}`}> class="border-b border-[var(--site-border)] px-2 py-1.5 text-left align-bottom font-medium whitespace-nowrap"
{c.name}{#if c.pk}<span class="ml-1 text-[10px] text-amber-500">PK</span>{/if}{#if sorted}<span aria-hidden="true">{params.get('dir') === 'desc' ? ' ↓' : ' ↑'}</span>{/if} >
<button
class="text-left hover:underline"
onclick={() => sortBy(c)}
title={c.help ?? `Sort by ${c.name}`}
>
{c.name}{#if c.pk}<span class="ml-1 text-[10px] text-amber-500">PK</span
>{/if}{#if sorted}<span aria-hidden="true"
>{params.get('dir') === 'desc' ? ' ↓' : ' ↑'}</span
>{/if}
</button> </button>
<div class="text-[11px] font-normal text-[var(--site-muted)]"> <div class="text-[11px] font-normal text-[var(--site-muted)]">
{shortType(c.type)}{c.nullable ? '' : ' · req'}{c.fk ? ` → ${c.fk.table}` : ''}{c.readOnly ? ' · auto' : ''} {shortType(c.type)}{c.nullable ? '' : ' · req'}{c.fk
? ` → ${c.fk.table}`
: ''}{c.readOnly ? ' · auto' : ''}
</div> </div>
</th> </th>
{/each} {/each}
{#if editable && t.canDelete}<th class="w-8 border-b border-[var(--site-border)]"></th>{/if} {#if editable && t.canDelete}<th class="w-8 border-b border-[var(--site-border)]"
></th>{/if}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each rows as row, r (keyOf(row) + r)} {#each rows as row, r (keyOf(row) + r)}
<tr class={['hover:bg-[var(--site-hover)]', selected[keyOf(row)] && 'bg-[var(--site-hover)]']}> <tr
class={[
'hover:bg-[var(--site-hover)]',
selected[keyOf(row)] && 'bg-[var(--site-hover)]'
]}
>
{#if t.primaryKey.length} {#if t.primaryKey.length}
<td class="border-b border-[var(--site-border)] px-2"> <td class="border-b border-[var(--site-border)] px-2">
<input type="checkbox" aria-label="Select row" bind:checked={selected[keyOf(row)]} /> <input
type="checkbox"
aria-label="Select row"
bind:checked={selected[keyOf(row)]}
/>
</td> </td>
{/if} {/if}
{#each cols as c, ci (c.name)} {#each cols as c, ci (c.name)}
{@const v = row[c.name]} {@const v = row[c.name]}
{#if editing && editing.r === r && editing.c === ci} {#if editing && editing.r === r && editing.c === ci}
<td class="min-w-48 border-b border-[var(--site-border)] p-1 align-top"> <td class="min-w-48 border-b border-[var(--site-border)] p-1 align-top">
<FieldInput column={c} bind:value={draft} lookupUrl={`${base}/lookup`} autofocus compact onsave={saveEdit} oncancel={cancelEdit} /> <FieldInput
<div class="mt-1 text-[11px] text-[var(--site-muted)]">Enter{c.kind === 'json' || c.uiType === 'longtext' ? ' + Ctrl' : ''} save · Esc cancel · Tab next</div> column={c}
bind:value={draft}
lookupUrl={`${base}/lookup`}
autofocus
compact
onsave={saveEdit}
oncancel={cancelEdit}
/>
<div class="mt-1 text-[11px] text-[var(--site-muted)]">
Enter{c.kind === 'json' || c.uiType === 'longtext' ? ' + Ctrl' : ''} save · Esc cancel
· Tab next
</div>
</td> </td>
{:else} {:else}
<td <td
@@ -454,43 +586,81 @@
(c.kind === 'number' || c.uiType === 'cents') && 'text-right tabular-nums', (c.kind === 'number' || c.uiType === 'cents') && 'text-right tabular-nums',
editable && !c.readOnly && t.canUpdate && 'cursor-text' editable && !c.readOnly && t.canUpdate && 'cursor-text'
]} ]}
title={v === null ? 'null' : typeof v === 'object' ? JSON.stringify(v) : String(v)} title={v === null
? 'null'
: typeof v === 'object'
? JSON.stringify(v)
: String(v)}
onclick={() => (active = { r, c: ci })} onclick={() => (active = { r, c: ci })}
ondblclick={() => startEdit(r, ci)} ondblclick={() => startEdit(r, ci)}
onkeydown={(e) => cellKey(e, r, ci)} onkeydown={(e) => cellKey(e, r, ci)}
> >
{#if v === null}<span class="text-xs text-[var(--site-muted)] opacity-60">null</span> {#if v === null}<span class="text-xs text-[var(--site-muted)] opacity-60"
{:else if c.fk && data.fkLabels[c.name]?.[String(v)]}<span title={String(v)}>{data.fkLabels[c.name][String(v)]}</span> >null</span
>
{:else if c.fk && data.fkLabels[c.name]?.[String(v)]}<span title={String(v)}
>{data.fkLabels[c.name][String(v)]}</span
>
{:else if (c.uiType === 'url' || c.uiType === 'image') && typeof v === 'string' && /^https?:\/\//.test(v)} {:else if (c.uiType === 'url' || c.uiType === 'image') && typeof v === 'string' && /^https?:\/\//.test(v)}
<a href={v} target="_blank" rel="noopener noreferrer" class="underline underline-offset-2">{display(c, v)}</a> <a
href={v}
target="_blank"
rel="noopener noreferrer"
class="underline underline-offset-2">{display(c, v)}</a
>
{:else}{display(c, v)}{/if} {:else}{display(c, v)}{/if}
</td> </td>
{/if} {/if}
{/each} {/each}
{#if editable && t.canDelete} {#if editable && t.canDelete}
<td class="border-b border-[var(--site-border)] px-1 text-center"> <td class="border-b border-[var(--site-border)] px-1 text-center">
<button class="rounded px-1.5 text-[var(--site-muted)] hover:bg-red-500/10 hover:text-red-500" title="Delete row" aria-label="Delete row" onclick={() => askDelete([row])}>✕</button> <button
class="rounded px-1.5 text-[var(--site-muted)] hover:bg-red-500/10 hover:text-red-500"
title="Delete row"
aria-label="Delete row"
onclick={() => askDelete([row])}>✕</button
>
</td> </td>
{/if} {/if}
</tr> </tr>
{:else} {:else}
<tr><td colspan={cols.length + 2} class="px-3 py-8 text-center text-[var(--site-muted)]">no rows</td></tr> <tr
><td colspan={cols.length + 2} class="px-3 py-8 text-center text-[var(--site-muted)]"
>no rows</td
></tr
>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm"> <div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
<Button size="sm" variant="outline" disabled={pageNo <= 1} onclick={() => navigate({ page: String(pageNo - 1) }, true)}>← prev</Button> <Button
size="sm"
variant="outline"
disabled={pageNo <= 1}
onclick={() => navigate({ page: String(pageNo - 1) }, true)}>← prev</Button
>
<span>page {pageNo}{pages ? ` of ${pages.toLocaleString('en-GB')}` : ''}</span> <span>page {pageNo}{pages ? ` of ${pages.toLocaleString('en-GB')}` : ''}</span>
<Button size="sm" variant="outline" disabled={pages !== null ? pageNo >= pages : rows.length < limit} onclick={() => navigate({ page: String(pageNo + 1) }, true)}>next →</Button> <Button
size="sm"
variant="outline"
disabled={pages !== null ? pageNo >= pages : rows.length < limit}
onclick={() => navigate({ page: String(pageNo + 1) }, true)}>next →</Button
>
<label class="ml-2 flex items-center gap-1 text-[var(--site-muted)]"> <label class="ml-2 flex items-center gap-1 text-[var(--site-muted)]">
rows per page rows per page
<select class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-1 py-0.5" value={String(limit)} onchange={(e) => navigate({ limit: e.currentTarget.value })}> <select
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-1 py-0.5"
value={String(limit)}
onchange={(e) => navigate({ limit: e.currentTarget.value })}
>
{#each [25, 50, 100, 250, 500] as n (n)}<option value={String(n)}>{n}</option>{/each} {#each [25, 50, 100, 250, 500] as n (n)}<option value={String(n)}>{n}</option>{/each}
</select> </select>
</label> </label>
<span class="ml-auto text-xs text-[var(--site-muted)]">↑↓←→ move · Enter edit · Space select · / search · n new row</span> <span class="ml-auto text-xs text-[var(--site-muted)]"
>↑↓←→ move · Enter edit · Space select · / search · n new row</span
>
</div> </div>
</div> </div>
@@ -499,12 +669,19 @@
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Unlock editing {t.schema}.{t.name}?</AlertDialog.Title> <AlertDialog.Title>Unlock editing {t.schema}.{t.name}?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
This is an app table, not a list. Edits change what the {t.schema} pages show and could break them. Every change is written to the edit log. Editing stays unlocked for this table until you lock it again or close the tab. This is an app table, not a list. Edits change what the {t.schema} pages show and could break
them. Every change is written to the edit log. Editing stays unlocked for this table until you
lock it again or close the tab.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel> <AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action onclick={() => { setUnlocked(true); unlockOpen = false; }}>unlock editing</AlertDialog.Action> <AlertDialog.Action
onclick={() => {
setUnlocked(true);
unlockOpen = false;
}}>unlock editing</AlertDialog.Action
>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
@@ -512,14 +689,21 @@
<AlertDialog.Root bind:open={deleteOpen}> <AlertDialog.Root bind:open={deleteOpen}>
<AlertDialog.Content> <AlertDialog.Content>
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Delete {deleteTargets.length} row{deleteTargets.length === 1 ? '' : 's'}?</AlertDialog.Title> <AlertDialog.Title
>Delete {deleteTargets.length} row{deleteTargets.length === 1
? ''
: 's'}?</AlertDialog.Title
>
<AlertDialog.Description> <AlertDialog.Description>
From {t.schema}.{t.name}. This can't be undone here (the old values are kept in the edit log). From {t.schema}.{t.name}. This can't be undone here (the old values are kept in the edit
log).
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
<AlertDialog.Cancel>cancel</AlertDialog.Cancel> <AlertDialog.Cancel>cancel</AlertDialog.Cancel>
<AlertDialog.Action class="bg-red-600 text-white hover:bg-red-700" onclick={confirmDelete}>delete</AlertDialog.Action> <AlertDialog.Action class="bg-red-600 text-white hover:bg-red-700" onclick={confirmDelete}
>delete</AlertDialog.Action
>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Content> </AlertDialog.Content>
</AlertDialog.Root> </AlertDialog.Root>
@@ -529,11 +713,17 @@
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>Import {importFile?.name}?</AlertDialog.Title> <AlertDialog.Title>Import {importFile?.name}?</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>
Appends every row to {t.schema}.{t.name}. The first line must hold column names; empty cells use the column default. If any row fails, nothing is imported. Appends every row to {t.schema}.{t.name}. The first line must hold column names; empty cells
use the column default. If any row fails, nothing is imported.
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Footer> <AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => { importFile = null; if (fileInput) fileInput.value = ''; }}>cancel</AlertDialog.Cancel> <AlertDialog.Cancel
onclick={() => {
importFile = null;
if (fileInput) fileInput.value = '';
}}>cancel</AlertDialog.Cancel
>
<AlertDialog.Action onclick={confirmImport}>import</AlertDialog.Action> <AlertDialog.Action onclick={confirmImport}>import</AlertDialog.Action>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Content> </AlertDialog.Content>
@@ -548,9 +738,18 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="mb-1 flex items-baseline gap-2"> <span class="mb-1 flex items-baseline gap-2">
<span class="font-medium">{c.name}</span> <span class="font-medium">{c.name}</span>
<span class="text-xs text-[var(--site-muted)]">{shortType(c.type)}{!c.nullable && !c.hasDefault ? ' · required' : ''}{c.fk ? ` → ${c.fk.table}` : ''}</span> <span class="text-xs text-[var(--site-muted)]"
>{shortType(c.type)}{!c.nullable && !c.hasDefault ? ' · required' : ''}{c.fk
? ` → ${c.fk.table}`
: ''}</span
>
</span> </span>
<FieldInput column={c} bind:value={newValues[c.name]} lookupUrl={`${base}/lookup`} autofocus={i === 0} /> <FieldInput
column={c}
bind:value={newValues[c.name]}
lookupUrl={`${base}/lookup`}
autofocus={i === 0}
/>
</label> </label>
{/each} {/each}
<Button type="submit">add row</Button> <Button type="submit">add row</Button>
@@ -563,20 +762,42 @@
<Dialog.Header><Dialog.Title>new column</Dialog.Title></Dialog.Header> <Dialog.Header><Dialog.Title>new column</Dialog.Title></Dialog.Header>
{#if colError}<p role="alert" class="text-sm text-red-500">{colError}</p>{/if} {#if colError}<p role="alert" class="text-sm text-red-500">{colError}</p>{/if}
<form class="space-y-3" onsubmit={addColumn}> <form class="space-y-3" onsubmit={addColumn}>
<label class="block text-sm">name (lowercase, a–z, 0–9, _) <label class="block text-sm"
<input bind:value={colName} required pattern="[a-z][a-z0-9_]*" maxlength="63" class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5" /> >name (lowercase, a–z, 0–9, _)
<input
bind:value={colName}
required
pattern="[a-z][a-z0-9_]*"
maxlength="63"
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label> </label>
<label class="block text-sm">label (optional) <label class="block text-sm"
<input bind:value={colLabel} maxlength="100" class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5" /> >label (optional)
<input
bind:value={colLabel}
maxlength="100"
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label> </label>
<label class="block text-sm">type <label class="block text-sm"
<select bind:value={colType} class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"> >type
{#each ['text', 'longtext', 'number', 'cents', 'bool', 'date', 'datetime', 'url', 'image', 'select', 'multiselect', 'json'] as ty (ty)}<option value={ty}>{ty}</option>{/each} <select
bind:value={colType}
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
>
{#each ['text', 'longtext', 'number', 'cents', 'bool', 'date', 'datetime', 'url', 'image', 'select', 'multiselect', 'json'] as ty (ty)}<option
value={ty}>{ty}</option
>{/each}
</select> </select>
</label> </label>
{#if colType === 'select' || colType === 'multiselect'} {#if colType === 'select' || colType === 'multiselect'}
<label class="block text-sm">options (comma separated) <label class="block text-sm"
<input bind:value={colOptions} class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5" /> >options (comma separated)
<input
bind:value={colOptions}
class="mt-1 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-2 py-1.5"
/>
</label> </label>
{/if} {/if}
<Button type="submit">add column</Button> <Button type="submit">add column</Button>

View File

@@ -8,7 +8,10 @@ export const GET: RequestHandler = async (event) => {
const { table } = await ownerTable(event); const { table } = await ownerTable(event);
try { try {
const rows = await exportRows(table, { ...rowQuery(event.url), limit: undefined, offset: 0 }); const rows = await exportRows(table, { ...rowQuery(event.url), limit: undefined, offset: 0 });
const csv = toCsv(table.columns.map((c) => c.name), rows); const csv = toCsv(
table.columns.map((c) => c.name),
rows
);
const stamp = new Date().toISOString().slice(0, 10); const stamp = new Date().toISOString().slice(0, 10);
return new Response(csv, { return new Response(csv, {
headers: { headers: {

View File

@@ -24,7 +24,12 @@ export const POST: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event); const { table, actor } = await ownerTable(event);
const b = await body(event.request); const b = await body(event.request);
try { try {
const row = await insertRow(table, isObject(b.values) ? b.values : {}, unlocked(event.request), actor); const row = await insertRow(
table,
isObject(b.values) ? b.values : {},
unlocked(event.request),
actor
);
return json({ row }, { status: 201, headers: noStore }); return json({ row }, { status: 201, headers: noStore });
} catch (e) { } catch (e) {
return failure(e); return failure(e);
@@ -35,7 +40,8 @@ export const PATCH: RequestHandler = async (event) => {
const { table, actor } = await ownerTable(event); const { table, actor } = await ownerTable(event);
const b = await body(event.request); const b = await body(event.request);
try { try {
if (!isObject(b.pk) || !isObject(b.values)) return json({ message: 'Bad request.' }, { status: 400 }); if (!isObject(b.pk) || !isObject(b.values))
return json({ message: 'Bad request.' }, { status: 400 });
const row = await updateRow(table, b.pk, b.values, unlocked(event.request), actor); const row = await updateRow(table, b.pk, b.values, unlocked(event.request), actor);
return json({ row }, { headers: noStore }); return json({ row }, { headers: noStore });
} catch (e) { } catch (e) {