Repository
The Repository is the low-level engine that turns entity metadata + the
Query DSL into parameterized SQL. The Dal gateway delegates to
an internal Repository(pool), so dal.findById(...) and repo.findById(...)
are the same call. Inside dal.transaction(txRepo => …) the callback gets a
tx-scoped Repository — see Connections & transactions.
Code
Finders
findById(entity, id, options?)
Find a single row by its @Key() column. id is bigint | string (matches
bigint PKs). Throws NotFoundError by default if no row matches; pass
throwIfNotFound: false to return null instead.
Code
findByUUID(entity, uuid, options?)
Find a single row by its UUID. Assumes the entity has a uuid column (the
convention enforced by IExposableEntity). Same throw/return semantics as
findById.
Code
find(entity, projections, options?)
Find a single row matching the filters. Throws NotFoundError by default if
zero rows match; throws MultipleRowsError if more than one row matches. Pass
throwIfNotFound: false to return null instead of throwing on zero rows.
Code
findAll(entity, projections, options?)
Find all rows matching the filters. Returns TResult[] (empty array if no
matches). Pass stream: true to get an AsyncIterable instead — see
Streaming below.
Code
findByPage(entity, projections, options?)
Paginated find. Returns PaginatedEntity<TEntity> — { entities, total_records }.
total_records is bigint (PostgreSQL COUNT(*) returns bigint). Pass
limit and offset on the options.
Code
count(entity, options?)
Count rows matching the filters. Returns bigint.
Code
FindOptions
| Field | Type | Default | Purpose |
|---|---|---|---|
throwIfNotFound | boolean | true (for find) | Throw NotFoundError on zero rows |
deletedRecords | "EXCLUDED" | "ONLY" | "INCLUDED" | "EXCLUDED" | How to handle soft-deleted rows |
filters | FilterExpr[] | [] | WHERE predicates |
sorting | SortingExpr[] | [] | ORDER BY clauses |
joins | JoinExpr[] | [] | JOIN clauses |
stream | boolean | false | Stream results via pg-query-stream |
tableName | string | entity's table | Override the table name (e.g. for audit tables) |
deletedRecords modes
| Mode | Behavior |
|---|---|
"EXCLUDED" (default) | WHERE deleted_at IS NULL — soft-deleted rows invisible |
"INCLUDED" | No deleted_at filter — all rows returned |
"ONLY" | WHERE deleted_at IS NOT NULL — only soft-deleted rows returned |
Code
Writes
Every write returns the full hydrated row (RETURNING *). For auditable
entities, pass AuditableWriteOptions (which requires actor); for
non-auditable entities, pass plain WriteOptions.
add(entity, values, options?)
Insert a single row. The DAL auto-fills created_at, created_by,
updated_at, updated_by, version for auditable entities — you only pass
business fields.
Code
upsert(entity, values, options?)
Insert or update on conflict. conflictTarget defaults to the @Key() column;
override with UpsertOptions.conflictTarget (e.g. "email" for a natural key).
The version guard applies on the ON CONFLICT (update) branch only — the
pure-INSERT branch starts at version = 1.
Code
update(entity, updates, options?)
Update a single row — exactly one row, guaranteed. The row is identified
by identity fields carried in updates itself; matchBy is only needed for
non-standard selectors. For auditable entities, updates must include the
version you read — the version guard runs atomically in the same UPDATE
statement. See Optimistic locking.
Identity resolution (automatic):
idanduuidpresent inupdatesalways becomeANDmatch conditions — supplying both acts as a consistency check: if they point at different rows the write fails withERR10(412).matchBymay add alternate@Uniqueselectors: a prop belonging to a composite@Uniquegroup pulls in the whole group — every group prop must be present inupdates(e.g.matchBy: ["dtype", "model_id"]).- A
matchByprop that is not@Unique/@Key, an unknown prop, an incomplete composite group, or a payload with no identity field at all →ERR09(422) before any SQL is emitted. - Identity fields are never SET columns —
uuid/idcannot be rewritten byupdate(). Amatched/guardCTE counts the rows the identity would touch; if it resolves to >1 row the statement aborts withERR10and nothing is written (atomic).
Code
Unique conflicts. When updates touches a column belonging to a declared
@Unique group, the UPDATE statement carries a conflict CTE that checks
whether another live/soft-deleted row already owns the attempted values —
raising ERR04/ERR05 with detail { entity, table, uuid, constraint, keys }
(keys = the attempted unique values; composite groups include untouched
column values read from the target row). The conflict check is gated on the
target row passing the version guard, so ERR01/ERR03 always win over a
unique conflict. NULL attempted values never conflict (unique indexes are
NULLS DISTINCT). The check only runs when the payload touches @Unique
columns — a plain name-only UPDATE emits the same SQL as before.
delete(entity, match, options?) — soft delete
Soft-delete a row: sets deleted_at and deleted_by, increments version.
The row stays in the table and is excluded from default finders. Returns the
deleted row. match carries the identity fields — same resolution rules as
update() (auto id/uuid, optional matchBy @Unique selectors, ERR09/
ERR10).
Code
restore(entity, match, options?)
Restore a soft-deleted row: clears deleted_at and deleted_by, increments
version. Returns the restored row.
Code
hardDelete(entity, match, options?)
Permanently delete a row (DELETE FROM ...). The row is gone — no soft-delete
flag, no restore. The version guard and the same identity-resolution rules as
update() apply (auto id/uuid, ERR09/ERR10). Returns void.
Code
WriteOptions and AuditableWriteOptions
| Field | Type | Required for | Purpose |
|---|---|---|---|
actor | string | auditable entities | Stamped into created_by/updated_by/deleted_by |
audit | AuditPort | optional | If injected, write ops emit field-level audit deltas |
logger | LoggerPort | optional | If injected, audit errors are logged instead of swallowed |
tableName | string | optional | Override the table name (e.g. for audit tables) |
MatchByOptions
| Field | Type | Default | Purpose |
|---|---|---|---|
matchBy | keyof TEntity & string or ReadonlyArray<keyof TEntity & string> | auto id/uuid from payload | Alternate @Unique selector — every prop must be unique; composite groups require all group props in the payload. Same rules for single-row and *Many ops. |
Bulk operations
All *Many ops share the same contract:
- Atomic — one dedicated transaction; any failing row rolls back the whole operation. There is no partial persistence.
- TEMP TABLE strategy — rows are bulk-loaded into a temp table, then a single set-based statement joins it to the target (no per-row round-trips, no 65535-parameter limit).
BulkResultreturn —{ received, affected }.receivedis the row count submitted;affectedis the count actually written. On success they are always equal (a mismatch would have rolled back).- Same identity contract as single-row writes —
id/uuidcarried by EVERY item are auto-matched (AND);matchByaccepts a prop or prop array of@Uniqueselectors (composite groups require all group props in every item). The identity must be uniform across the batch: an identity prop present in only some items isERR09. Illegal selectors areERR09before any SQL runs. - Per-item uniqueness guard — each temp-table item must match at most
one target row. A set-based aggregate (
GROUP BY row_ix HAVING count>1) raisesERR10and aborts the whole transaction before any write; thedetaillists the offendingrow_ixvalues. | - Per-row version guard (auditable entities) — every item must carry the
caller-observed
version; it lands in the temp table asexpected_versionand is enforced in the write join (target.version = tmp.expected_version). Offenders are reported in ONEpg_raisewhosedetailjsonb carries{ entity, table, stale|missing, rows[≤10] }with per-rowuuid/expected_version/actual_version/vanished/code:
| Failure | Code |
|---|---|
Row submitted without version | ERR02 |
Row exists but version differs (modified meanwhile) | ERR01 |
| Row is gone (hard-deleted meanwhile) | ERR03 |
Item identity is incoherent (e.g. id of row A + uuid of row B) | ERR10 |
| Item matches more than one target row | ERR10 (pre-write guard) |
addMany(entity, rows, options?)
Batched multi-row INSERT through the temp-table strategy. Unique-constraint
violations are collected and raised as ONE pg_raise — ERR04 when the
conflicting row is live, ERR05 when every conflict hits a soft-deleted row
(restore it instead of duplicating). err.detail carries
{ entity, table, conflicts, rows[≤10] } — each row with uuid, input_uuid,
per-row code (ERR04/ERR05), deleted, constraint, and keys (the
attempted unique-key values).
Code
updateMany(entity, rows, options?)
Batched UPDATE: UPDATE target SET ... FROM tmp WHERE target.<id> = tmp.<id> AND target.<uuid> = tmp.<uuid> AND target.version = tmp.expected_version —
the join predicate is the AND of every identity column. Atomic and
SQL-injection safe.
Code
deleteMany(entity, matches, options?)
Bulk soft-delete with the same version guard. matches is an array of match
objects — { uuid, version } for auditable entities.
Code
restoreMany(entity, matches, options?)
Bulk restore (clears soft-delete fields, bumps version, stamps audit
fields). Same temp-table + version-guard semantics as deleteMany.
upsertMany — currently DISABLED
upsertMany is commented out pending a design decision: its
ON CONFLICT DO UPDATE branch historically overwrote conflicting rows
without a version check. It will return either with a guarded
expected_version on the conflict branch or as an explicit
sync/import-semantics operation. Do not call it.
BulkOptions
| Field | Type | Default | Purpose |
|---|---|---|---|
batchSize | number | auto-calculated | Override the temp-table batch size (rarely needed) |
timeoutMs | number | DalConfig.bulkTimeoutMs (30min) | Whole-operation wall-clock budget in ms — applied as SET LOCAL statement_timeout per statement plus a JS deadline across the op; expiry throws BulkTimeoutError (ERR06) and rolls back |
Clone
clone(entity, sourceUuid, options)
Copy a record by UUID. Fetches the source (including soft-deleted rows),
excludes the PK and @Unique columns, stamps the @CloneField column with the
source UUID, resets audit/deletable fields, and inserts the new row. See
Clone for the full 7-step flow.
Code
Streaming
findAll with stream: true returns an AsyncIterable backed by
pg-query-stream. Each FETCH batch is bounded by the session
statement_timeout — safe for very large result sets that would otherwise
exhaust memory.
Code
The stream is a Node.js async iterator; you can use for await ... of, pipe it
through a transform, or collect it with Array.fromAsync (Node 22+).
Error handling
Every DAL error extends the abstract DalError class, which adds a stable
code: string property. Branch on instanceof or on err.code — both work.
Code
Error codes at a glance
| Code | Raised when | HTTP (via mapDalError) |
|---|---|---|
ERR01 | Stale version on a guarded write (single or bulk row) | 409 |
ERR02 | version missing on an auditable-entity write | 400 |
ERR03 | Target row vanished (hard-deleted) before the write | 404 |
ERR04 | Unique conflict against a live row | 409 |
ERR05 | Unique conflict(s) against soft-deleted rows only | 409 |
ERR06 | Bulk op exceeded its wall-clock timeoutMs budget (rolled back) | 408 |
ERR07 | PG 57014 — a statement was killed by statement_timeout | 500 |
ERR08 | Raw 23505 on a constraint not declared as @Unique (manual/deferred/partial index) — signals a missing @Unique in metadata | 409 |
ERR09 | Illegal match selector: matchBy prop not @Unique/@Key, incomplete composite group, or no identity field in the payload — thrown pre-SQL | 422 |
ERR10 | Identity incoherence (id+uuid pointing at different rows) or the match resolved to >1 row (guard CTE abort, nothing written) | 412 |
NOT_FOUND / VALIDATION / UNKNOWN_COLUMN / MULTIPLE_ROWS | Finder/validation failures | 404 / 400 |
The DAL never emits HTTP status codes — the @primebrick/sdk mapDalError
function performs the pure err → { status, body } RFC 7807 mapping shared by
the BE and all microservices. Bulk raises carry structured detail (≤10
offending rows) which the boundary surfaces as extra.issues.
See Architecture: error handling philosophy
for the full code table, and Optimistic locking for the
ERR01/ERR02/ERR03 flow.
Next steps
- Connections & transactions — pool config,
dal.transaction, per-call timeouts, graceful shutdown. - Query DSL — the expressions passed to finders.
- Audit trail — the
AuditPortsystem that hooks into write ops. - Optimistic locking — the version guard on auditable writes.
- Clone — the 7-step clone flow.
- API reference — the full mechanical listing of every exported symbol.