Files
suaveplan/packages/api/api-plugin-banking/src/repositories/base-repository.md
2026-05-27 23:29:53 +01:00

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 UPDATE statement — unknown field names throw BankingError(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.
  • flattenRecordIds collapses RecordId-shaped values ({ tb, id }, driver-specific RecordId instances, plain strings) to the canonical table:id string at the public boundary.
  • Errors uniformly wrapped: every thrown SurrealDB error is converted to BankingError(DATABASE_ERROR) with cause and structured context preserved.

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 a table: prefix.
  • normaliseId(id: string, tableName: string): string — ensures the prefix is present.
  • flattenRecordIds<T>(record): T — flattens nested RecordId-shaped values to table:id strings.

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.