Lists: owner-only database browser/editor for all tables (meta.edit_log audit)
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
This commit is contained in:
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal file
29
drizzle/20261024024249_meta_edit_log/migration.sql
Normal 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 $$;
|
||||||
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
15132
drizzle/20261024024249_meta_edit_log/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user