Skip to content
Merged
4 changes: 2 additions & 2 deletions src/features/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { QuestionListingMap } from "#shared/db/questions/queries.ts";
import type {
Image,
ImageUseItemType,
ItemImageProjection,
ItemImageColumns,
ListingWithCount,
} from "#shared/types.ts";
import type {
Expand Down Expand Up @@ -46,7 +46,7 @@ export type TicketSharedContext = {
childDatesById: Map<string, ChildDatesByDayCount>;
groupName?: string;
groupDescription?: string;
groupImage?: ItemImageProjection;
groupImage?: ItemImageColumns;
/** The header entity whose image gallery the public page renders (the group
* on a group page, or the sole listing on a single-listing page); null for a
* multi-listing combo. Just the reference — the images are read lazily on the
Expand Down
14 changes: 8 additions & 6 deletions src/shared/accounting/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ import {
} from "#shared/db/client.ts";
import {
clauseArgs,
matchesNoRows,
queryTail,
rowsUnlessNoneMatch,
type WhereClause,
whereSql,
} from "#shared/db/where-clauses.ts";
Expand Down Expand Up @@ -148,11 +149,12 @@ export const visibleTransfers = (
...occurredAtRange(range),
...listingLegScope(listingIds),
];
if (matchesNoRows(parts)) return Promise.resolve([]);
return selectTransfers(
fromDb,
`${whereSql(parts)} ORDER BY occurred_at DESC, id DESC LIMIT ?`,
[...clauseArgs(parts), limit],
const tail = queryTail(parts, {
limit,
order: "occurred_at DESC, id DESC",
});
return rowsUnlessNoneMatch(parts, () =>
selectTransfers(fromDb, tail.sql, tail.args),
);
};

Expand Down
7 changes: 4 additions & 3 deletions src/shared/db/activityLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { decryptListingWithCount } from "#shared/db/listings/records.ts";
import { listingStatement } from "#shared/db/listings/select.ts";
import { CONFIG_KEYS, settings } from "#shared/db/settings.ts";
import { col, defineTable } from "#shared/db/table.ts";
import { clauseArgs, equals, whereSql } from "#shared/db/where-clauses.ts";
import { equals, queryTail } from "#shared/db/where-clauses.ts";
import { nowIso } from "#shared/now.ts";
import { requireRequestPrivateKey } from "#shared/session-private-key.ts";
import type { ListingWithCount } from "#shared/types.ts";
Expand Down Expand Up @@ -191,10 +191,11 @@ const queryActivityLog = async (
// co-monotonic with created (newest row = highest id) but, being the rowid,
// it is served straight from the primary key / idx_activity_log_listing_id
// without a sort over the unbounded log table.
const tail = queryTail(parts, { limit, order: "id DESC" });
return decryptLogRows(
await queryAll<StoredActivityLogEntry>(
`SELECT ${ACTIVITY_LOG_COLUMNS} FROM activity_log${whereSql(parts)} ORDER BY id DESC LIMIT ?`,
[...clauseArgs(parts), limit],
`SELECT ${ACTIVITY_LOG_COLUMNS} FROM activity_log${tail.sql}`,
tail.args,
),
);
};
Expand Down
26 changes: 12 additions & 14 deletions src/shared/db/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import { decrypt, encrypt } from "#shared/crypto/encryption.ts";
import { hmacHash } from "#shared/crypto/hashing.ts";
import { wrapKeyWithToken } from "#shared/crypto/keys.ts";
import type { BlindIndex, WrappedKey } from "#shared/crypto/sealed.ts";
import { chooseColumns } from "#shared/db/chosen-columns.ts";
import { execute, executeUpdate, queryOne } from "#shared/db/client.ts";
import { idAndCreatedSchema } from "#shared/db/common-schema.ts";
import { defineIdTable } from "#shared/db/define-id-table.ts";
import { col, defineTableProjection } from "#shared/db/table.ts";
import { col } from "#shared/db/table.ts";
import { equals } from "#shared/db/where-clauses.ts";
import { nowIso } from "#shared/now.ts";
import { getTouchOverride } from "#shared/test-overrides.ts";
import type { ApiKey } from "#shared/types.ts";
Expand Down Expand Up @@ -57,17 +59,14 @@ const apiKeysTable = defineIdTable<ApiKeyRow, ApiKeyInput>("api_keys", {
const API_KEY_COLUMNS =
"id, user_id, key_index, wrapped_data_key, name, created, last_used";

const apiKeyListProjection = defineTableProjection(apiKeysTable, [
const apiKeyListColumns = chooseColumns(apiKeysTable, [
"id",
"name",
"created",
"last_used",
]);

const apiKeyNameProjection = defineTableProjection(apiKeysTable, [
"id",
"name",
]);
const apiKeyNameColumns = chooseColumns(apiKeysTable, ["id", "name"]);

/**
* Create a new API key for a user.
Expand Down Expand Up @@ -115,10 +114,10 @@ export const getApiKeysForUser = async (
): Promise<
Array<{ id: number; name: string; created: string; lastUsed: string }>
> => {
const decrypted = await apiKeyListProjection.queryAll(
`SELECT ${apiKeyListProjection.columnsSql()} FROM api_keys WHERE user_id = ? ORDER BY id ASC`,
[userId],
);
const decrypted = await apiKeyListColumns.select({
order: "id ASC",
where: equals("user_id", userId),
});
return decrypted.map((row) => ({
created: row.created,
id: row.id,
Expand All @@ -134,10 +133,9 @@ export const getApiKeyForUser = async (
id: number,
userId: number,
): Promise<{ id: number; name: string }> => {
const decrypted = await apiKeyNameProjection.queryOne(
`SELECT ${apiKeyNameProjection.columnsSql()} FROM api_keys WHERE id = ? AND user_id = ?`,
[id, userId],
);
const decrypted = await apiKeyNameColumns.selectOne({
where: [...equals("id", id), ...equals("user_id", userId)],
});
if (!decrypted) throw new Error(`API key ${id} not found for user ${userId}`);
return { id: decrypted.id, name: decrypted.name };
};
Expand Down
17 changes: 7 additions & 10 deletions src/shared/db/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
/* jscpd:ignore-start */
import { filter, groupToMap, map, reduce, sort, unique, uniqueBy } from "#fp";
import { decrypt, encrypt } from "#shared/crypto/encryption.ts";
import type { StoredRowOf } from "#shared/db/chosen-columns.ts";
import {
deleteByFieldBatch,
executeBatch,
Expand All @@ -23,14 +24,10 @@ import {
import { linkTableSide } from "#shared/db/link-table.ts";
import {
type ListingOption,
listingOptionProjection,
listingOptionColumns,
} from "#shared/db/listings/table.ts";
import { defineOrderedCollection } from "#shared/db/ordered-collection.ts";
import {
col,
defineTable,
type StoredTableProjectionRow,
} from "#shared/db/table.ts";
import { col, defineTable } from "#shared/db/table.ts";
import type { Listing } from "#shared/types.ts";
/* jscpd:ignore-end */

Expand Down Expand Up @@ -259,9 +256,9 @@ export const getAttributeIdsOrdered = async (): Promise<number[]> =>
export const getAllAttributeOptionIds = async (): Promise<Set<number>> =>
new Set(await queryIdColumn("SELECT id FROM attribute_options"));

type OptionListingRow = StoredTableProjectionRow<
type OptionListingRow = StoredRowOf<
Listing,
typeof listingOptionProjection.columns
typeof listingOptionColumns.columns
> & {
id: number;
option_id: number;
Expand All @@ -283,7 +280,7 @@ export const getAttributeListingUse = async (
): Promise<AttributeListingUse> => {
const rows = await queryAll<OptionListingRow>(
`SELECT listingAttribute.option_id,
${listingOptionProjection.columnsSql("listing")}
${listingOptionColumns.columnsSql("listing")}
FROM listing_attribute_options AS listingAttribute
JOIN attribute_options AS attributeOption
ON attributeOption.id = listingAttribute.option_id
Expand All @@ -301,7 +298,7 @@ export const getAttributeListingUse = async (
(row: OptionListingRow) => row.option_id,
(row) => row.id,
)(rows),
listings: await listingOptionProjection.readAll(usedListings),
listings: await listingOptionColumns.readAll(usedListings),
};
};

Expand Down
173 changes: 173 additions & 0 deletions src/shared/db/chosen-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Reading a chosen set of a table's columns.
*
* A chosen set names the columns a read wants and reuses the table's own read
* transforms to open them, so a narrow read decrypts and converts exactly what
* it selected. Paired with the shared filters it also knows how to fetch
* itself — {@link ChosenColumns.select} — so an ordinary single-table read
* needs no SQL of its own.
*/

import type { InValue } from "@libsql/client";
import { mapParallel } from "#fp";
import { queryAll, type SqlStatement } from "#shared/db/client.ts";
import type { Table } from "#shared/db/table.ts";
import {
queryTail,
rowsUnlessNoneMatch,
type WhereClause,
} from "#shared/db/where-clauses.ts";

type TableColumn<Row> = keyof Row & string;
type ColumnNames<Row> = readonly [TableColumn<Row>, ...TableColumn<Row>[]];

/** A selected row before the table's declared read transforms run. Database
* values are unknown here because booleans and encrypted strings have a
* different stored representation from the application's Row type. */
export type StoredRowOf<Row, Columns extends ColumnNames<Row>> = {
[Column in Columns[number]]: unknown;
};

type ChosenRow<Row, Columns extends ColumnNames<Row>> = Pick<
Row,
Columns[number]
>;

type ColumnQuery<Result> = (sql: string, args?: InValue[]) => Promise<Result>;

/**
* A read declared rather than written. The chosen set already knows its table
* and columns, so this is the whole of an ordinary single-table read; one that
* joins or carries a subquery writes its own SQL and runs it through
* {@link ChosenColumns.queryAll}.
*/
export type ReadRequest = {
/** The filters, from `#shared/db/where-clauses.ts`. Absent keeps every row. */
where?: WhereClause[];
/** An `ORDER BY` body without the keyword. Omit for no ordering. */
order?: string;
/** A row cap. Omit for no limit. */
limit?: number;
/** Table alias, when the clauses qualify their columns with one. */
alias?: string;
};

export interface ChosenColumns<Row, Columns extends ColumnNames<Row>> {
readonly columns: Columns;
columnsSql: (alias?: string) => string;
queryAll: ColumnQuery<ChosenRow<Row, Columns>[]>;
read: (
row: StoredRowOf<Row, Columns>,
rowId?: unknown,
) => Promise<ChosenRow<Row, Columns>>;
readAll: (
rows: StoredRowOf<Row, Columns>[],
) => Promise<ChosenRow<Row, Columns>[]>;
/** Run a declared read and return every matching row. A filter that cannot
* match anything skips the round-trip. */
select: (query?: ReadRequest) => Promise<ChosenRow<Row, Columns>[]>;
/** Run a declared read for at most one row. */
selectOne: (query?: ReadRequest) => Promise<ChosenRow<Row, Columns> | null>;
}

/** Choose an explicit set of a table's own columns and reuse its read
* transforms without loading or decrypting the rest of the row. */
export const chooseColumns = <
Row,
Input,
const Columns extends ColumnNames<Row>,
>(
table: Table<Row, Input>,
columns: Columns,
): ChosenColumns<Row, Columns> => {
const missingColumn = columns.find(
(column) => !table.columns.includes(column),
);
if (missingColumn) {
throw new Error(
`Cannot select ${missingColumn} from ${table.name}: it is not one of its columns`,
);
}

const read = async (
row: StoredRowOf<Row, Columns>,
rowId?: unknown,
): Promise<ChosenRow<Row, Columns>> => {
const stored = row as Record<string, unknown>;
const missingValueColumn = columns.find(
(column) => !Object.hasOwn(stored, column),
);
if (missingValueColumn) {
throw new Error(
`Chosen column ${missingValueColumn} is missing from the ${table.name} rows read back`,
);
}
const readRowId = rowId === undefined ? stored[table.primaryKey] : rowId;
const entries = await mapParallel(
async (column: Columns[number]) =>
[
column,
await table.readColumn(
column,
stored[column] as Row[Columns[number]],
readRowId,
),
] as const,
)([...columns]);
return Object.fromEntries(entries) as ChosenRow<Row, Columns>;
};
const readAll = (
rows: StoredRowOf<Row, Columns>[],
): Promise<ChosenRow<Row, Columns>[]> =>
mapParallel((row: StoredRowOf<Row, Columns>) => read(row))(rows);

const qualified = (column: string, alias?: string): string =>
alias ? `${alias}.${column}` : column;

const columnsSql = (alias?: string): string =>
columns.map((column) => qualified(column, alias)).join(", ");

/** What a declared read actually selects: the chosen columns, plus the row's
* own key when it was not chosen. A column's read transform may name the row
* a bad value came from, and it can only do that if the key came back too.
* The key is dropped again before the row is handed back. */
const readColumnsSql = (alias?: string): string =>
columns.some((column) => column === table.primaryKey)
? columnsSql(alias)
: `${columnsSql(alias)}, ${qualified(table.primaryKey, alias)}`;

/** Turn a declared read into SQL and its bound values. The chosen set
* supplies the columns and the table; the caller supplies the rest. */
const statementFor = (query: ReadRequest): SqlStatement => {
const from = query.alias ? `${table.name} AS ${query.alias}` : table.name;
const tail = queryTail(query.where ?? [], query);
return {
args: tail.args,
sql: `SELECT ${readColumnsSql(query.alias)} FROM ${from}${tail.sql}`,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};

/** The stored rows a declared read selects, before the read transforms. */
const storedRowsFor = (
query: ReadRequest,
): Promise<StoredRowOf<Row, Columns>[]> =>
rowsUnlessNoneMatch(query.where ?? [], () => {
const { sql, args } = statementFor(query);
return queryAll<StoredRowOf<Row, Columns>>(sql, args);
});

return {
columns,
columnsSql,
queryAll: async (sql, args) =>
readAll(await queryAll<StoredRowOf<Row, Columns>>(sql, args)),
read,
readAll,
select: async (query = {}) => readAll(await storedRowsFor(query)),
selectOne: async (query = {}) => {
// One row is a read capped at one — the same path, not a second one.
const row = (await storedRowsFor({ ...query, limit: 1 }))[0];
return row === undefined ? null : read(row);
},
};
};
Loading