Initial Commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user