4.2 KiB
Base repository
Purpose
This module owns the shared repository scaffold that BankRepository
and BankAccountRepository both extend. It holds the DatabaseAdapter
reference, the table name, the SQL-injection-proof field-whitelist
guard, generic record-id normalisation helpers, and the common CRUD
primitives (getById, getAll, count, exists, update, delete,
findBy, findOneBy).
Features
- Field whitelist enforced on every generated
UPDATEstatement — unknown field names throwBankingError(INVALID_FIELD_NAME)rather than reaching the SurQL planner. - Parameterised values via the genesis adapter's positional
$p0, $p1, ...convention; SQL strings never carry user-supplied data inline. flattenRecordIdscollapses RecordId-shaped values ({ tb, id }, driver-specific RecordId instances, plain strings) to the canonicaltable:idstring at the public boundary.- Errors uniformly wrapped: every thrown SurrealDB error is converted
to
BankingError(DATABASE_ERROR)withcauseand structuredcontextpreserved.
Usage: Basic
import { BaseRepository } from "@suaveplan/api-plugin-banking";
class WidgetRepository extends BaseRepository<{ id: string; name: string }> {
protected getAllowedFields(): readonly string[] {
return ["name", "updated_at"];
}
}
const repo = new WidgetRepository(adapter, "widget");
const w = await repo.getById("widget:abc");
await repo.update("widget:abc", { name: "renamed" });
await repo.delete("widget:abc");
Usage: Advanced
The repository class is generic in the record type. Use the type parameter to enforce the row shape across the public API:
import { BaseRepository, type BankRecord } from "@suaveplan/api-plugin-banking";
class BankRepo extends BaseRepository<BankRecord> {
protected getAllowedFields(): readonly string[] {
return ["name", "sort_code", "site_id", "status", "updated_at"];
}
async getActive(): Promise<BankRecord[]> {
return this.findBy("status", 4);
}
}
For ad-hoc queries that the base scaffold does not cover, child classes
call the protected runQuery<T>(sql, params) helper directly:
const rows = await this.runQuery<{ count: number }>(
`SELECT count() AS count FROM bank WHERE status = $p0 GROUP ALL`,
[4]
);
API Reference
class BaseRepository<TRecord>
constructor(db: DatabaseAdapter, tableName: string)
Protected runQuery<T>(sql, params?): Promise<T[]> — every child SQL
call routes through this so the DATABASE_ERROR wrapper is uniform.
Protected validateFieldName(field: string): void — throws
BankingError(INVALID_FIELD_NAME) when the name is outside the
child's whitelist.
Public methods (all async, all return flattened records or null):
getById(id),getAll(limit?, offset?),count(),exists(id),update(id, data),delete(id),findBy(field, value),findOneBy(field, value).
Abstract getAllowedFields(): readonly string[] — child classes
return the writable-field whitelist (the union of every column the
public surface ever updates plus updated_at for the auto-touched
timestamp).
Helpers
extractId(id: string): string— strips atable:prefix.normaliseId(id: string, tableName: string): string— ensures the prefix is present.flattenRecordIds<T>(record): T— flattens nested RecordId-shaped values totable:idstrings.
Implementation Notes
The class is abstract rather than instantiable because the
getAllowedFields() contract is load-bearing: any concrete repository
that forgets to override it cannot pass field-name validation and so
the SQL-injection guard fails closed. This is enforced at the type
level (abstract method) rather than at runtime.
update() returns the current record when called with an empty
data object (after filtering undefined and updated_at). This is
the SQL-side equivalent of a no-op and matches the upstream
package's behaviour — call sites that pass data: {} get the
fetched record back, not null.
getAll() short-circuits to db.selectAll(table) when no pagination
is requested (the typed adapter path), and only falls back to a raw
SQL pagination query when limit or offset is non-default. This
preserves the SurrealDB type narrowing for the common case.