Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# 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
```ts
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:
```ts
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:
```ts
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.