Skip to content

Feature/version 8 - #463

Open
MrMaz wants to merge 232 commits into
mainfrom
feature/version-8
Open

MrMaz wants to merge 232 commits into
mainfrom
feature/version-8

Conversation

@MrMaz

@MrMaz MrMaz commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

MrMaz added 30 commits February 16, 2026 21:37
MrMaz added 30 commits October 17, 2026 19:53
The core modules never exported the custom-repository token, so forFeature() always built the
default repository. The token is now exported and injected as required, and each override is
built once per forFeature key around that key's repository. Subclasses can reach the mapper,
which is now protected.

BREAKING CHANGE: repositories.cache, repositories.otp, repositories.role and
repositories.roleAssignment must be classes constructed like CacheRepository, OtpRepository,
RoleRepository or RoleAssignmentRepository; extend the matching class.
…ns subscribers

The optimistic-lock check is an UPDATE, so TypeORM stamps @UpdateDateColumn on it and
broadcasts it to subscribers. The stamp can't cause a lost update or a false conflict, so
it is documented rather than worked around, and the TODO is closed.
… WebSocket

Overlays, @ctx(), @authuser() and TransactionalRunner read the HTTP request on every
transport, so on WebSocket the context stuck to the socket (one message's route hooks
applied to the next) and on RPC a primitive payload threw a TypeError.

findAppContext/requireAppContext resolve the context from the ExecutionContext: the
request on HTTP; on RPC (preview) a host per call, keyed by the transport's context
object and the handler; none on WebSocket or gRPC call-stream handlers, where @ctx(),
@authuser() and @transactional() throw AppContextUnavailableException. Overlays declare
the transports they attach on (default http) and use this.appContext(context). Hooks
and the otp/cache/role namespaces attach on RPC; correlation, auth user and crud stay
HTTP-only.
…ly or not at all

- A relation marked federated in forRoot() is read through the related entity's own repository,
  found by entity name, after hooks and row scope, on find, findOne, count and findAndCount.
- Joins attach to-one, to-many and many-to-many (through a registered junction) relations; a
  filtered join attaches only the related rows that matched.
- A filter on a federated relation becomes a condition on the root's own column, so the root's
  store pages, sorts and counts the read: pages and totals are exact.
- federation.maxKeys (default 1000) bounds every key set: the root side or the related side is
  used, whichever fits; a filter too broad on both sides is refused.
- Related reads run under the caller's ctx as it arrived, and are refused if a hook redefined
  the row scope on it.
- Startup checks each federated relation: its target is registered exactly once, its columns
  match the target repository's, both sides have the column it is read by, the junction is
  registered with its key columns, and distinctFilter names a target column.
- Removes the relation-first orchestrator, its strategies and its mock-based tests, replaced by
  tests against real adapters.

BREAKING CHANGE: a read is refused with a 400 FederationException when it sorts by a federated
relation, uses isNull on one, combines one with anything else under OR, joins one without
selecting the column it is read by, or needs more keys than federation.maxKeys.
find, findOne and count now read federated relations, not only findAndCount, and a filtered
join attaches only the matching related rows.
Startup refuses a federated relation whose target is unregistered or registered more than
once, whose columns differ from its repository's, or that names a missing key column;
allowEagerRelations alongside any federated relation; a relations entry naming a relation the
entity lacks; and distinctFilter without federated: true.
RepositoryRelationMetadataInterface loses federated and distinctFilter, and
RepositoryBindingInterface.federationOrchestrator becomes federation.
The TypeORM driver no longer takes relationsConfig.
The relation-first strategy and the FEDERATION_* constants are removed.
nestjs-crud no longer exports CrudResponseMetrics, and paginated responses lose metrics.
- RowScopeBase no longer re-compares the tenant columns of the row its
  lookup returns; the lookup's own WHERE decides.
- upsert reads the stored row once, unscoped, and refuses a soft-deleted
  row after row scope runs, so another tenant's soft-deleted row is a 404.
- scopeWrite's unhandled-operation branch is a compile-time check.
- Trim comments that repeated the README or narrated design history.
A relation marked `cascade: true` in forRoot() is written with its entity:
each related row goes through its own repository (hooks, own-column checks,
row scope), with its foreign key set to the entity's key.

- create/createMany create related rows after the entity.
- update creates and updates the rows a payload lists and keeps the rest;
  replace/upsert also delete the rows a listed relation leaves out.
- delete/deleteMany/softDelete/restore pass to related rows after the
  entity's row scope check and before the entity is written.
- A to-one names its one stored row; null deletes it.
- Startup refuses cascade on an owning to-one, many-to-many, a federated
  relation, an unregistered target, a composite-key parent, a soft-deleted
  parent over rows without a delete date, a cycle, or append-only rows
  under a mutable parent.
- The repository never opens a transaction: a cascade is atomic only
  inside the implementer's.
- New CascadeWriteException (400) for a malformed cascade payload.
- params.scope is a plain copy of the RowScopeCtx overlay's own values, so
  a key the overlay lacks no longer reads through to the context.
- The repository hooks example no longer shows tenancy: hooks cover only
  the operations they decorate. Tenant isolation belongs to Row Scope.
- README notes that cascades write related rows through their own
  repository, and that writes bypassing the repository bypass row scope.
…them

BREAKING CHANGE: RowScopeBase takes no repository:
`RowScopeBase<Entity, Scope>(repo, { scopeKey, column, label })` becomes
`RowScopeBase<Scope>({ scope: ['tenantId'], joinable? })`; `{ column, key }` maps a column to a
differently named RowScopeCtx key. `resolveScope` is no longer overridable, and `label`,
`RowScopeBaseRepository` and `RowScopeValues` are removed. scopeWrite params gain `repository`.
RowScopeInterface gains optional `scopeColumns` and `joinable`; joins into scoped rows are
allowed when both resolvers are joinable on the same columns and keys and the join includes
them. Every scoped entity must have a primary key, and each scope column must exist on it
(checked at startup). Relation metadata gains optional `joinOn`. Join refusals read
`Cannot join "x" of Entity`.
…rced columns

RowScopeBase takes a relax option: a pure function of the caller's scope
returning the scope columns that caller is not held to on reads. Reads filter
only the columns left; relaxing every column reads all rows, while a resolver
with no scope columns still reads nothing. Writes are never relaxed: every
write is pre-checked and stamped with the caller's full scope.

RowScopeInterface gains an optional relaxedColumns(scope). The join check
between scoped entities is now made per caller on enforced columns: both
resolvers joinable, every column the target enforces is enforced by the root
with the same key, and the join matches each to itself. A target with no
scope columns is never joinable into.

Cascades read related rows as the caller, so a relaxed caller is refused
(404) when a cascade reaches rows they may read but not write; documented.
TypeORM 1.x names an entity manager's data source `dataSource` and keeps
`connection` as a deprecated alias; 0.3 has only `connection`, so reading
`manager.dataSource` broke the 0.3 build. A small helper typed as either shape
narrows with `in`, with no cast and no deprecated access on 1.x.
…eORM's column order

A relation's `on` pair was the first join column TypeORM listed. TypeORM 1.x
lists the column referencing the primary key first; 0.3 keeps declaration
order, so a foreign key declared as `(tenantId, orderId)` reported `on` as
`tenantId`. The pair whose referenced column is primary is now `on` and leads
`joinOn`, whatever order the columns are listed in.
The fixture drew keys from about 30 faker job areas with a fixed type and
assignee, so seeding two rows hit the unique key now and then.
An entity registered with activity: { store, values?, omit? } gets one
record per stored row each write changes, written through the store's own
repository with the caller's ctx, so it joins any open transaction.
Startup refuses composite keys, an unregistered, recorded or
non-append-only store, a store missing activity columns, and an
unscoped store for a scoped entity.

createMany and deleteMany now refuse a driver result without one row per
input, for every entity.
Revoking an invitation cleared the user's OTPs from an event listener after
commit, on a fresh context. A lost event, or a listener failure, left a
revoked invitation's passcode working. revokeByUserId now clears the OTPs
once, in the revoke's own transaction, and InvitationRevokedListener is
removed. accept() revokes sibling invitations through the same path, so the
clear is atomic with the acceptance too.
The no-factory warning and its TSDoc said onRollback callbacks never fire.
They do: a throwing operation marks the scope failed and settle() flushes the
rollback callbacks, factory or not. What is lost is atomicity, so writes made
before the throw stay while onRollback runs. Reword both, and pin the
behaviour with a test.
Add the optional/outbox entry point to nestjs-repository. DurableMessaging
publishes @nestjs/outbox messages and records inbox deliveries in the
transaction a repository registration's writes use, so a message commits or
rolls back with the writes beside it and a redelivered message runs its work
once.

DurableMessagingModule.forRoot({ key, driver, outbox }) sets up OutboxModule,
asks the registration's driver for a store on its datasource through the new
DurableDriverInterface port (bind(repository) -> transaction key + store),
and registers that store before the outbox locks its registry. Implementers
never build a store or touch the native connection. Startup refuses an
unregistered key or a registration its driver cannot carry
(UnsupportedDurableDriverException), and a second forRoot() in one
application.

@nestjs/outbox is an optional peer dependency, imported only under
src/durable/ (lint guard added). Tests run against InMemoryOutboxStore with
a transaction fixture the store joins, so rollback cases are not vacuous.
Add the optional/outbox entry point to nestjs-repository-typeorm.
TypeOrmDurable implements the durable driver port: it builds the
@nestjs/outbox store on the registration's own data source,
PostgresOutboxStore for postgres and MySqlOutboxStore for mysql, and refuses
any other database (MariaDB included), a non-TypeORM repository, or one
built without a transaction with UnsupportedDurableDriverException.

The data source is resolved through ModuleRef from the TypeORM module's own
provider, so the repository never hands it out: TypeOrmRepository gains only
string getters, transactionKey and dataSourceName.

@nestjs/outbox is an optional peer, and the lint guard now keeps it out of
the main entry of the driver and nestjs-crud too, while they stay free to
import typeorm. pg and mysql2 are pinned dev dependencies so the tests can
build unconnected data sources of each dialect. The README gains the
durable messaging recipe.
Port @concepta/nestjs-file to the v8 DDD/CQRS style, registered the fixed
way (entities.file, FILE_REPOSITORY_TOKEN).

- File aggregate with a status lifecycle: pending, active, removing,
  remove-failed. Uploads are confirmed with ConfirmFileUploadCommand.
- Commands: create, confirm upload, soft delete, restore and remove.
  Remove commits `removing` before storage deletes the object, records
  `remove-failed` when storage fails, and refuses a caller's transaction.
- Queries: GetFileQuery and GetFileByKeyQuery, with a status filter.
- Storage services take ctx first and may implement exists and delete.
  Their failures are wrapped in module exceptions.
- Events: created, upload confirmed, soft-deleted, restored, remove
  failed and removed, each published once its write commits.
- Writes are version-checked; a lost race throws OptimisticLockException.
- Zod schemas, abstract TypeORM entities (optional/typeorm) and a
  seeding factory (optional/seeding).

BREAKING CHANGE: the v7 FileService, DTOs, entities and exceptions are
gone. Register the module with storageServices and entities.file,
dispatch the commands and queries, and implement
FileStorageServiceInterface (getSignedUploadUrl/getSignedDownloadUrl take
ctx first). Files are now created `pending` and must be confirmed.
- Rank first: cache's soft delete never persists, because it stamps
  dateDeleted before softDelete. Rename its "archive" command and
  softRemove to the repository's soft delete terms in the same change.
- nestjs-user custom repositories never receive the registered
  repository; build them from a subclass constructor instead.
- Park mapping common database errors (unique violations) to module
  exceptions.
A driver registration in RepositoryModule.forRoot() takes an `options`
slot beside `entities`, passed to the driver as register(entities,
options). forRoot() now infers each registration from the driver class,
so `entities` and `options` are both type-checked against the driver's
own register signature.

TransactionFactoryInterface.create() receives the context the scope runs
under, so a driver can prepare the transaction it starts from the
caller's overlays (row scope included).

BREAKING CHANGE: forRoot(), forRootAsync() and the exported
RepositoryDriverRegistration, RepositoryRootOptions and
RepositoryRootAsyncOptions types are now generic over driver types
rather than entity option types; code naming them with an explicit type
argument must pass a driver type. A custom driver whose register()
declares a narrower `entities` type than it is given no longer compiles;
declare its real entity options type. TransactionFactoryInterface.create
takes `ctx: AppContextHost`; factories that ignore it still compile, but
direct callers must pass a context.
The TypeORM driver takes `options.transactionSettings`, a function from
the scope's context (and data source name) to settings. Every
transaction the driver opens applies them with set_config(name, value,
true) before any query runs, so Postgres row-level security policies can
read the caller's tenant from the same RowScopeCtx overlay row scope
uses. Settings end with the transaction; a failure rolls the transaction
back before the connection is released. Declaring the option for a data
source that isn't Postgres stops startup with
TypeOrmTransactionSettingsUnsupportedException.

start() now also rolls back whenever the query runner reports an open
transaction after a failed start, rather than only releasing it.

The README gains a Row-Level Security (Postgres) section with the policy
and role recipe.
`pnpm test:docker` starts the databases in docker/compose.test.yml
(Postgres 16 on port 55432), runs the `*.docker-spec.ts` suites with
vitest.config-docker.ts, and always tears them down, Ctrl-C included
(DOCKER_TEST_KEEP=1 leaves them up). It is local only: not part of
test:all or CI.

Two suites run against the README's policy recipe, connected as a role
that owns nothing and can't bypass RLS, through one pooled connection:
- transactionSettings alone: tenant isolation, no tenant carried to the
  next transaction or outside one, refused inserts, and a failed setting
  rolled back without leaving the connection in a transaction;
- row scope and RLS together over HTTP: per-user and tenant-admin reads,
  the join rule, SQL that bypasses the repository held to the tenant, an
  endpoint without a transaction reading nothing, forged writes refused,
  and the composite foreign key refusing a cross-tenant reference.

supertest is added as a devDependency of nestjs-repository-typeorm.
The cache repository stamped the delete date before handing the row to
softDelete, so the repository saw an already-deleted row and wrote nothing:
the entry stayed visible. The row now goes over as read and the aggregate is
stamped afterwards. The e2e fixture gains a soft delete route, and a test
reads the row back with and without withDeleted.

BREAKING CHANGE: ArchiveCacheCommand is now SoftDeleteCacheCommand,
ArchiveCacheHandler is now SoftDeleteCacheHandler, and
CacheRepositoryInterface.softRemove is now softDelete.
repositories.user and repositories.userCredentials were registered with
useClass while their entity keys were dropped from forFeature, so a
UserRepository subclass received neither the driver repository nor the
mapper. The module now builds the class with both, as cache, file, otp and
role do, always registers the entity keys, and exports UserMapper and
UserCredentialsMapper. The repositories' mapper is now protected.

BREAKING CHANGE: repositories.user and repositories.userCredentials take a
UserRepositoryConstructor / UserCredentialsRepositoryConstructor: extend
UserRepository or UserCredentialsRepository instead of passing a standalone
class. The entity keys must be registered even when a custom repository is
given, and repositories.userCredentials applies only when
entities.credentials is set.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants