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:
@@ -21,7 +21,9 @@
|
||||
|
||||
const kind = $derived(column.kind);
|
||||
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)}`;
|
||||
let options = $state.raw<{ value: string; label: string }[]>([]);
|
||||
@@ -64,7 +66,11 @@
|
||||
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)]';
|
||||
const placeholder = $derived(
|
||||
value === null ? (column.hasDefault ? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}` : 'null') : ''
|
||||
value === null
|
||||
? column.hasDefault
|
||||
? `default${column.defaultExpr ? `: ${column.defaultExpr}` : ''}`
|
||||
: 'null'
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -78,7 +84,9 @@
|
||||
{@attach focusOnMount}
|
||||
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="false">false</option>
|
||||
</select>
|
||||
@@ -91,7 +99,9 @@
|
||||
{@attach focusOnMount}
|
||||
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}
|
||||
</select>
|
||||
{:else if long}
|
||||
@@ -103,8 +113,7 @@
|
||||
oninput={(e) => (value = e.currentTarget.value)}
|
||||
onkeydown={keydown}
|
||||
{@attach focusOnMount}
|
||||
aria-label={column.label}
|
||||
></textarea>
|
||||
aria-label={column.label}></textarea>
|
||||
{:else}
|
||||
<input
|
||||
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. */
|
||||
export async function listTables(): Promise<TableSummary[]> {
|
||||
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
|
||||
// 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>();
|
||||
if (exact.length) {
|
||||
const res = await db.transaction(async (tx) => {
|
||||
@@ -133,7 +138,8 @@ export async function listTables(): Promise<TableSummary[]> {
|
||||
return tx.execute<{ k: string; n: number }>(
|
||||
sql.join(
|
||||
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 `
|
||||
)
|
||||
@@ -233,7 +239,8 @@ export async function getTable(schema: string, name: string): Promise<TableInfo
|
||||
.sort((a, b) => a.pk_pos! - b.pk_pos!)
|
||||
.map((c) => c.name);
|
||||
// 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 {
|
||||
...s,
|
||||
access: s.access !== 'locked' && !keyUsable ? 'locked' : s.access,
|
||||
@@ -275,7 +282,9 @@ function whereClause(t: TableInfo, q: RowQuery): SQL {
|
||||
const search = q.q?.trim();
|
||||
if (search) {
|
||||
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 ?? []) {
|
||||
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')}`);
|
||||
}
|
||||
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``;
|
||||
}
|
||||
|
||||
@@ -367,10 +379,13 @@ export async function readRows(t: TableInfo, q: RowQuery): Promise<RowsResult> {
|
||||
const fkLabels: RowsResult['fkLabels'] = {};
|
||||
for (const c of t.columns) {
|
||||
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;
|
||||
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 };
|
||||
});
|
||||
@@ -391,14 +406,19 @@ export async function fkOptions(
|
||||
const key = target.columns.find((x) => x.name === c.fk!.column);
|
||||
if (!key) return [];
|
||||
const labelCol =
|
||||
['name', 'display_name', 'title', 'label', 'slug', 'email', 'code'].map((n) =>
|
||||
target.columns.find((x) => x.name === n)
|
||||
).find(Boolean) ?? key;
|
||||
['name', 'display_name', 'title', 'label', 'slug', 'email', 'code']
|
||||
.map((n) => target.columns.find((x) => x.name === n))
|
||||
.find(Boolean) ?? key;
|
||||
const k = sql`t.${sql.identifier(key.name)}`;
|
||||
const l = sql`t.${sql.identifier(labelCol.name)}`;
|
||||
const conds: SQL[] = [];
|
||||
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()) {
|
||||
const like = `%${opts.q.trim().replace(/[\\%_]/g, '\\$&')}%`;
|
||||
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`
|
||||
);
|
||||
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)[] }[];
|
||||
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.value === null && !c.nullable && !(c.hasDefault && raw === undefined))
|
||||
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(', ')}.`);
|
||||
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 raw = pk[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}.`);
|
||||
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];
|
||||
|
||||
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');
|
||||
const set = assignments(t, values);
|
||||
return write(async (tx) => {
|
||||
const row = one<{ r: Record<string, unknown> }>(
|
||||
await tx.execute(
|
||||
set.length
|
||||
? sql`INSERT INTO ${rel(t.schema, t.name)} AS t (${sql.join(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 (${sql.join(
|
||||
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`
|
||||
)
|
||||
).r;
|
||||
@@ -568,16 +608,29 @@ export async function updateRow(
|
||||
const where = pkWhere(t, pk);
|
||||
return write(async (tx) => {
|
||||
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;
|
||||
if (!before) throw new DataError(404, 'That row no longer exists.');
|
||||
const after = one<{ r: Record<string, unknown> }>(
|
||||
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`)
|
||||
).r;
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -590,7 +643,9 @@ export async function deleteRows(t: TableInfo, pks: Pk[], unlocked: boolean, act
|
||||
let n = 0;
|
||||
for (let i = 0; i < wheres.length; i++) {
|
||||
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;
|
||||
if (!before) continue;
|
||||
n++;
|
||||
@@ -614,7 +669,8 @@ export async function importRows(
|
||||
const names = header.map((h) => h.trim());
|
||||
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 (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) => {
|
||||
if (cells.length !== 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)
|
||||
await tx.execute(
|
||||
set.length
|
||||
? sql`INSERT INTO ${rel(t.schema, t.name)} (${sql.join(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)} (${sql.join(
|
||||
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`
|
||||
);
|
||||
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(). */
|
||||
export async function addListColumn(
|
||||
@@ -657,15 +732,23 @@ export async function addListColumn(
|
||||
if (!t.isList || !t.listSlug || t.access !== 'open')
|
||||
throw new DataError(403, 'Columns can only be added to lists.');
|
||||
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))
|
||||
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 };
|
||||
if (input.label?.trim()) col.label = input.label.trim().slice(0, 100);
|
||||
if (input.uiType === 'select' || input.uiType === 'multiselect') {
|
||||
const values = (input.options ?? []).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.');
|
||||
const values = (input.options ?? [])
|
||||
.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 };
|
||||
}
|
||||
await write(async (tx) => {
|
||||
|
||||
@@ -60,7 +60,13 @@ describe('tableAccess', () => {
|
||||
|
||||
describe('columns and identifiers', () => {
|
||||
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);
|
||||
for (const c of ['key', 'session_id', 'iban', 'author', 'market_hash_name', 'r2_key'])
|
||||
expect(hiddenColumn(c)).toBe(false);
|
||||
@@ -107,20 +113,34 @@ describe('coerce', () => {
|
||||
|
||||
describe('filters and csv', () => {
|
||||
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('x;y:eq:1')).toBeNull();
|
||||
expect(parseFilter('name:like:1')).toBeNull();
|
||||
});
|
||||
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([
|
||||
['a', 'b'],
|
||||
['x, y', 'he said "hi"'],
|
||||
['', '{"k":1}'],
|
||||
['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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* pre-filter so obviously bad input never reaches a query at all. */
|
||||
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). */
|
||||
@@ -61,7 +63,18 @@ export function safeTypeName(type: string): boolean {
|
||||
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 {
|
||||
if (type.endsWith('[]')) return 'array';
|
||||
@@ -106,7 +119,10 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
|
||||
try {
|
||||
items = JSON.parse(s);
|
||||
} 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.` };
|
||||
@@ -138,7 +154,8 @@ export function coerce(kind: Kind, raw: unknown, label: string): Coerced {
|
||||
return { ok: false, error: `${label} must be true or false.` };
|
||||
}
|
||||
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 };
|
||||
case 'datetime':
|
||||
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>). */
|
||||
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 Filter = { column: string; op: FilterOp; value: string };
|
||||
|
||||
|
||||
@@ -44,4 +44,7 @@ export function failure(e: unknown) {
|
||||
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 {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
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 MetaColumn = typeof metaColumns.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user