Skip to content

Feature/version 8 - #463

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

MrMaz wants to merge 225 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 16, 2026 19:36
…egate presets

Records that must never change can now be declared append-only end to end.

nestjs-core: DomainAggregate is now a base with no persistence opinions
(id, props, events). MutableAggregate carries what DomainAggregate did
(version, audit dates, stamps); AppendOnlyAggregate carries only
dateCreated. mutableAggregateSchema and appendOnlyAggregateSchema pair
with them, and CorrectableInterface types a record that is an original
or a correction with a reason. DomainMapper drops its redundant Props
parameter.

nestjs-repository: writePolicy on forFeature() entries. An append-only
entity refuses update, replace, upsert, delete, deleteMany, softDelete
and restore with AppendOnlyViolationException (409); create stays open.
Startup refuses a soft-delete column on an append-only entity, and
requireWritePolicyDeclaration makes a declaration mandatory.
resolveCorrectionChain follows corrections to the record in effect.

nestjs-repository-typeorm: AppendOnlyPostgresEntity and
AppendOnlySqliteEntity base entities.

BREAKING CHANGE: extend MutableAggregate where you extended
DomainAggregate; DomainMapper<Entity, Props, Aggregate> is now
DomainMapper<Entity, Aggregate>; domainAggregateSchema is now
mutableAggregateSchema.
An identity referred to its user through a relation property with no
column of its own, the one shipped entity whose foreign key could only be
written as a relation object. It now holds the user's id: the domain,
schemas and service use userId, the base entities store it in a userId
column, and the mapper copies only the identity's own fields so a loaded
relation never reaches a write.

BREAKING CHANGE: IdentityInterface.user (ReferenceIdInterface) is now
userId (string), and Identity.user is now Identity.userId. The abstract
user property is gone from IdentitySqliteEntity and IdentityPostgresEntity,
which now declare a userId column. Rename your join column to userId (or
add the column), keep a user relation only if you want the foreign key
constraint, declared with @joincolumn({ name: 'userId' }), and read
identity.userId instead of identity.user.id.
… and registration

- Writes carry the entity's own columns only; the stored row a write names is reduced to its own
  columns.
- Reads name own columns and declared, joined relations; results holding anything else throw.
  allowEagerRelations is the one opt-in. Joins into row-scoped rows are refused.
- count is decided by where, join and withDeleted; text operators match literally; database errors
  surface as RepositoryDatabaseException.
- Drivers report own columns, relation target columns and URNs; the TypeORM driver adds nothing
  on its own.
- Every entity is registered once in RepositoryModule.forRoot and exposed with forFeature(keys);
  startup checks throw while the application is created.
- Row scope, hook and write policy tests move into nestjs-repository.

BREAKING CHANGE: register entities in RepositoryModule.forRoot({ drivers: [{ driver, entities }] })
and import them with forFeature(keys); drivers implement register(entities) and adapters are bound
with bind() instead of the set* methods. Write payloads refuse relations (send the foreign key
column) and non-columns; transform, merge, prepare, force, joinType and the Join helpers are
removed. Reads naming non-columns, unjoined relation filters or sorts, or joins into row-scoped
rows return 400. Drivers must report targetColumns, urn, targetUrn and through.urn, implement
doCount and describeDatabaseError, and return only what the call asked for. Text conditions use
WhereTextOperator. Eager relations load only with allowEagerRelations.


@usehooks stored any class it was given. A class without the class-level
@hook() decorator got no subsystem type, so every operation filtered it
out and it silently never ran, although the core README promised a hard
failure. @usehooks now throws HookNotDecoratedException as soon as the
decorated class is defined, whether the hook is passed bare or as
{ hook, type }.

BREAKING CHANGE: a class passed to @usehooks() without @hook() (or a
subsystem decorator such as @RepoHook()) now throws HookNotDecoratedException
at class definition instead of being ignored. Add the missing decorator.
The response filter returned after the first grant on a route, even when
the user's roles satisfied none of it, so a route whose matching grant
came second was returned unfiltered. It now tries each grant in order and
applies the first one granted, as the guard does. Single-grant routes are
unchanged.
- crud: @RepoHook takes a spec; @AfterRead skips count and findAndCount;
  add the count, upsert and deleteMany hook rows
- core: @usehooks refuses undecorated hooks at class definition;
  HookWithSpec has no spec; Operation is an enum with tuple and union helpers
- user: no passwordSalt; passwordHash is not nullable; entities defaults
- user, role, invitation: nestjs-crud is an optional peer
- role: CrudContextOverlay; Context Overlay in the table of contents
- invitation: constraints is PlainLiteralObject | null
- otp: expiresIn uses ms strings, bare numbers are milliseconds; four
  handlers use OtpPolicy; resolveTypeService takes no override
- cache, otp, user: register @entity() subclasses in RepositoryModule.forRoot
- cache, otp, role, user, authentication, federated: drop the global option,
  which callers cannot pass
- authentication: the invalid-credentials message on the wire
- access-control: how the guard matches grants; `any` grants still run query
  services and attribute filters
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.

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