Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions src/shared/accounting/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ import {
} from "#shared/db/client.ts";
import {
clauseArgs,
queryTail,
rowsUnlessNoneMatch,
type WhereClause,
whereSql,
} from "#shared/db/where-clauses.ts";
import type { AccountRef, Transfer } from "#shared/ledger/types.ts";

/** Newest first by business time, ties broken by id so the order is stable. */
const NEWEST_FIRST = "occurred_at DESC, id DESC";

/** A parameterised "this leg's <role> side IS the account" match — two bound `?`
* for (type, id), built from the shared transfers column names so every balance
* read filters accounts identically. */
Expand All @@ -60,11 +61,14 @@ const legMatchesAccount = (role: "source" | "dest"): string =>

/** Every transfer touching `account`, as source or destination. */
export const transfersByAccount = (acct: AccountRef): Promise<Transfer[]> =>
selectTransfers(
fromDb,
` WHERE (${legMatchesAccount("source")}) OR (${legMatchesAccount("dest")})`,
[acct.type, acct.id, acct.type, acct.id],
);
selectTransfers(fromDb, {
where: [
{
args: [acct.type, acct.id, acct.type, acct.id],
clause: `(${legMatchesAccount("source")}) OR (${legMatchesAccount("dest")})`,
},
],
});

/** Every leg of one business event (booking, refund, …). */
export const transfersByEventGroup = (
Expand All @@ -86,17 +90,14 @@ export const eventGroupHasLegs = (eventGroup: string): Promise<boolean> =>

/** The whole ledger. For tests and small reports; scoped reads are preferred on
* hot paths. */
export const allTransfers = (): Promise<Transfer[]> =>
selectTransfers(fromDb, "", []);
export const allTransfers = (): Promise<Transfer[]> => selectTransfers(fromDb);

/** The most recent `limit` transfers, newest first (by business time then id, so
* ties are stable). The ordering + limit run in SQL so the whole ledger is never
* loaded into memory; `occurred_at` is the stored INTEGER epoch, so DESC is
* newest-first. */
export const recentTransfers = (limit: number): Promise<Transfer[]> =>
selectTransfers(fromDb, " ORDER BY occurred_at DESC, id DESC LIMIT ?", [
limit,
]);
selectTransfers(fromDb, { limit, order: NEWEST_FIRST });

/** Legs shown on the operator-facing ledger list. Routine checkout cash
* plumbing ("Card / bank → <attendee>" and its refund mirror) stays hidden, but
Expand Down Expand Up @@ -149,13 +150,7 @@ export const visibleTransfers = (
...occurredAtRange(range),
...listingLegScope(listingIds),
];
const tail = queryTail(parts, {
limit,
order: "occurred_at DESC, id DESC",
});
return rowsUnlessNoneMatch(parts, () =>
selectTransfers(fromDb, tail.sql, tail.args),
);
return selectTransfers(fromDb, { limit, order: NEWEST_FIRST, where: parts });
};

/** Distinct-day bounds (earliest/latest `occurred_at`) over the whole ledger, or
Expand Down
32 changes: 22 additions & 10 deletions src/shared/accounting/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
type SqlStatement,
type TxScope,
} from "#shared/db/client.ts";
import { type Read, readStatement } from "#shared/db/read.ts";
import { equals, rowsUnlessNoneMatch } from "#shared/db/where-clauses.ts";
import { account } from "#shared/ledger/account.ts";
import type {
AccountRef,
Expand Down Expand Up @@ -225,26 +227,36 @@ export const fromTx =
async (sql, args) =>
resultRows<TransferRow>(await tx.execute({ args, sql }));

/** Select transfers matching a WHERE clause (pass "" for the whole table). */
export const selectTransfers = async (
/** Which transfers to read, in what order, how many — everything but the
* columns and the table, which a transfer read always knows. */
export type TransferRead = Omit<Read, "columns" | "from">;

/** Select transfers, saying which rows and in what order rather than writing
* the query. `read` decides where the rows come from — the client, or an open
* transaction. Pass nothing to read the whole table. */
export const selectTransfers = (
read: RowReader,
where: string,
args: InValue[],
): Promise<Transfer[]> => {
const rows = await read(`SELECT ${COLUMNS} FROM transfers${where}`, args);
return rows.map(rowToTransfer);
};
query: TransferRead = {},
): Promise<Transfer[]> =>
rowsUnlessNoneMatch(query.where ?? [], async () => {
const { sql, args } = readStatement({
...query,
columns: COLUMNS,
from: "transfers",
});
return (await read(sql, args)).map(rowToTransfer);
});

/** Every leg of one business event (booking, refund, …). */
export const selectByEventGroup = (
read: RowReader,
eventGroup: string,
): Promise<Transfer[]> =>
selectTransfers(read, " WHERE event_group = ?", [eventGroup]);
selectTransfers(read, { where: equals("event_group", eventGroup) });

/** The stored transfer with this id, or null when none exists. */
export const selectById = async (
read: RowReader,
id: number,
): Promise<Transfer | null> =>
(await selectTransfers(read, " WHERE id = ?", [id]))[0] ?? null;
(await selectTransfers(read, { where: equals("id", id) }))[0] ?? null;
12 changes: 4 additions & 8 deletions src/shared/accounting/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ import {
} from "#shared/accounting/rows.ts";
import {
executeBatch,
inPlaceholders,
type SqlStatement,
type TxScope,
} from "#shared/db/client.ts";
import { inList } from "#shared/db/where-clauses.ts";
import type { Transfer, TransferInput } from "#shared/ledger/types.ts";
import { assertValidTransfer } from "#shared/ledger/validate.ts";
import { nowIso } from "#shared/now.ts";
Expand Down Expand Up @@ -144,18 +144,14 @@ type BatchSnapshot = {
readonly originalsById: ReadonlyMap<number, Transfer>;
};

/** Read every transfer whose `column` is one of `values`; [] for an empty set
* without touching the database. `column` is a trusted constant, never input. */
/** Read every transfer whose `column` is one of `values`; an empty set is
* answered without touching the database. `column` is a trusted constant. */
const selectByColumnIn = (
read: RowReader,
column: string,
values: readonly InValue[],
): Promise<Transfer[]> =>
values.length === 0
? Promise.resolve([])
: selectTransfers(read, ` WHERE ${column} IN (${inPlaceholders(values)})`, [
...values,
]);
selectTransfers(read, { where: inList(column, values) });

/** Load everything {@link planGroup} needs to validate the batch, in three bulk
* selects — independent of the number of groups. `read` picks where the
Expand Down
31 changes: 16 additions & 15 deletions src/shared/db/activityLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ import {
import { idAndCreatedSchema } from "#shared/db/common-schema.ts";
import { decryptListingWithCount } from "#shared/db/listings/records.ts";
import { listingStatement } from "#shared/db/listings/select.ts";
import { readRows } from "#shared/db/read.ts";
import { CONFIG_KEYS, settings } from "#shared/db/settings.ts";
import { col, defineTable } from "#shared/db/table.ts";
import { equals, queryTail } from "#shared/db/where-clauses.ts";
import { equals } 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 @@ -184,21 +185,21 @@ const decryptLogRows = async (
const queryActivityLog = async (
listingId: number | null,
limit: number,
): Promise<ActivityLogEntry[]> => {
// A null listing means "every listing", which is no filter at all.
const parts = equals("listing_id", listingId ?? undefined);
// Order by id DESC, not created DESC: id is AUTOINCREMENT so it is
// 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${tail.sql}`,
tail.args,
),
): Promise<ActivityLogEntry[]> =>
decryptLogRows(
await readRows<StoredActivityLogEntry>({
columns: ACTIVITY_LOG_COLUMNS,
from: "activity_log",
limit,
// Order by id DESC, not created DESC: id is AUTOINCREMENT so it is
// 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.
order: "id DESC",
// A null listing means "every listing", which is no filter at all.
where: equals("listing_id", listingId ?? undefined),
}),
);
};

/**
* Get activity log entries for an listing (most recent first)
Expand Down
48 changes: 25 additions & 23 deletions src/shared/db/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@
* Keys inherit admin_level from their parent user.
*/

/* jscpd:ignore-start */
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 { execute, executeUpdate } from "#shared/db/client.ts";
import { idAndCreatedSchema } from "#shared/db/common-schema.ts";
import { defineIdTable } from "#shared/db/define-id-table.ts";
import { col } from "#shared/db/table.ts";
import { equals } from "#shared/db/where-clauses.ts";
import { readerFor } from "#shared/db/table-reader.ts";
import { nowIso } from "#shared/now.ts";
import { getTouchOverride } from "#shared/test-overrides.ts";
import type { ApiKey } from "#shared/types.ts";

/* jscpd:ignore-end */

/** A row with its `name` decrypted for display — the table's read shape. */
type ApiKeyRow = {
id: number;
Expand Down Expand Up @@ -55,18 +57,25 @@ const apiKeysTable = defineIdTable<ApiKeyRow, ApiKeyInput>("api_keys", {
wrapped_data_key: col.simple<WrappedKey>(),
});

/** The api_keys columns, in one place, for the reads that select every column. */
const API_KEY_COLUMNS =
"id, user_id, key_index, wrapped_data_key, name, created, last_used";
/** Every read of this table goes through one reader, so a column name is
* checked against the table rather than spelled out as SQL. */
const apiKeys = readerFor(apiKeysTable);

const apiKeyListColumns = chooseColumns(apiKeysTable, [
/** What the auth path reads: everything but the name, which it never shows —
* so the one encrypted column is neither fetched nor decrypted on a request
* that only needs to know whose key this is. */
const apiKeyAuthColumns = apiKeys.pick([
"id",
"name",
"user_id",
"key_index",
"wrapped_data_key",
"created",
"last_used",
]);

const apiKeyNameColumns = chooseColumns(apiKeysTable, ["id", "name"]);
const apiKeyListColumns = apiKeys.pick(["id", "name", "created", "last_used"]);

const apiKeyNameColumns = apiKeys.pick(["id", "name"]);

/**
* Create a new API key for a user.
Expand Down Expand Up @@ -98,13 +107,8 @@ export const createApiKey = async (
*/
export const getApiKeyByToken = async (
token: string,
): Promise<ApiKey | null> => {
const keyIndex = await hmacHash(token);
return queryOne<ApiKey>(
`SELECT ${API_KEY_COLUMNS} FROM api_keys WHERE key_index = ?`,
[keyIndex],
);
};
): Promise<Omit<ApiKey, "name"> | null> =>
apiKeyAuthColumns.one({ key_index: await hmacHash(token) });

/**
* List all API keys for a user (decrypts names for display).
Expand All @@ -114,10 +118,10 @@ export const getApiKeysForUser = async (
): Promise<
Array<{ id: number; name: string; created: string; lastUsed: string }>
> => {
const decrypted = await apiKeyListColumns.select({
order: "id ASC",
where: equals("user_id", userId),
});
const decrypted = await apiKeyListColumns.many(
{ user_id: userId },
{ order: "id ASC" },
);
return decrypted.map((row) => ({
created: row.created,
id: row.id,
Expand All @@ -133,9 +137,7 @@ export const getApiKeyForUser = async (
id: number,
userId: number,
): Promise<{ id: number; name: string }> => {
const decrypted = await apiKeyNameColumns.selectOne({
where: [...equals("id", id), ...equals("user_id", userId)],
});
const decrypted = await apiKeyNameColumns.one({ id, 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
Loading