Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/nested-resources-atomic-upsert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@nestm/crud": minor
"@nestm/crud-typeorm": minor
---

Add first-class nested resources whose collection-path parameters are validated,
bound to persistence fields, included in operation contexts, and enforced on
collection and item operations without coupling headless services to HTTP.

Add a first-class atomic upsert operation across resource contracts, lifecycle
hooks, scopes, bindings, services, generated controllers, and adapter
capabilities. Upsert bindings declare complete persistence conflict fields and an
explicit overwrite allowlist, while the adapter conflict branch must enforce the
normal resource and scope predicate.

Certify TypeORM upsert on PostgreSQL with a single `INSERT ... ON CONFLICT ... DO
UPDATE ... WHERE ... RETURNING` statement. The adapter validates the complete
physical primary identity, rejects unsafe overwrite fields and unsupported entity
models, combines native row authorization with CRUD predicates, preserves narrow
selected-record hydration, and performs no pre-read or reload.
98 changes: 94 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ plugin, Swagger UI continues to show the ordinary generated parameters.
| List | `GET /users` | `200`, `{ data, meta }` |
| Read | `GET /users/:id` | `200`, response DTO |
| Update | `PATCH /users/:id` | `200`, response DTO |
| Upsert | `PUT /users/:id` | `200`, response DTO |
| Delete | `DELETE /users/:id` | `204`, no body |
| Restore | `POST /users/:id/restore` | `200`, response DTO |

Expand Down Expand Up @@ -228,6 +229,46 @@ const tenantUsers = defineCrudResource({
The binding's `fields` must include both logical ID fields. ORM adapter column
maps can translate those logical names to physical database columns.

## Nested resources

Collection paths may own part of a resource's identity. Declare those route
parameters separately for collection validation, while the ordinary ID contract
continues to describe the complete item route:

```ts
const versions = defineCrudResource({
name: "artifact-versions",
path: "artifacts/:artifactId/versions",
pathParams: {
contract: z.object({ artifactId: z.string().uuid() }),
fields: { artifactId: "artifactId" },
},
itemPath: ":versionId",
idFields: { artifactId: "artifactId", versionId: "versionId" },
contracts: {
id: z.object({ artifactId: z.string().uuid(), versionId: z.string().uuid() }),
create: CreateVersion,
update: UpdateVersion,
response: VersionResponse,
},
operations: crudOperations.all(),
});
```

Nested list/count predicates always include the mapped parent values. Nested
creates treat those values as framework-owned insert values and therefore
require `mappings.scopeCreate` plus `scopeCreateFields`; request-body mappings
cannot override them. Scopes, hooks, adapter transaction runners, row
predicates, and after-commit events receive `context.pathParams`. Direct
headless calls use `crud.list(query, pathParams, context?)` and
`crud.create(input, pathParams, context?)`; item calls still receive the full
ID object.

Cursor tokens for a nested collection are bound to its parent values, so a
valid cursor issued under one parent is rejected under another. A nested
resource may be a relation source, but cannot be a relation target in this
release because a batched relation query has no single parent-path context.

## Custom controllers

For a fully custom controller, make the feature headless and inject the same
Expand Down Expand Up @@ -280,7 +321,10 @@ keeping binding and adapter providers, registry entries, service exports, and
resource imports available. This is intended for fully custom compatibility
controllers. To replace only selected operations, leave generation enabled,
omit those operations from the resource, and add custom routes alongside the
generated controller.
generated controller. `operations` controls generated route exposure; it is not
a runtime authorization boundary on direct `CrudService` calls. A custom route
must still declare an operation when its adapter capability or binding
configuration (for example atomic upsert) is validated from that declaration.

`CrudService` is where scopes, hooks, transactions, soft deletion, error
sanitization, and response mapping run, so custom controllers reuse the same
Expand Down Expand Up @@ -320,7 +364,7 @@ persistence keys.
## Scopes and hooks

Resource scopes are ordered injectable providers. Their predicates apply to
list/count/read/update/delete/restore and relation queries. Scope `createValues`
list/count/read/update/upsert/delete/restore and relation queries. Scope `createValues`
overwrite client values only while inserting, which supports tenant and owner
isolation without making immutable ownership updateable. A scope that
intentionally owns an update field must return it through distinct
Expand All @@ -343,6 +387,52 @@ rolls back the mutation; an `afterCommit` failure is sent to
An adapter transaction must resolve only after the real commit it owns; a
savepoint or joined ambient transaction cannot satisfy this mutation contract.

## Atomic upsert

Upsert is an explicit opt-in operation; `crudOperations.all()` does not enable
it. Add an `upsert` request contract and select the operation to generate
`PUT itemPath`, or keep the resource headless and call `CrudService.upsert()`
from a compatibility controller:

```ts
const viewerBindings = defineCrudResource({
// ...path, complete ID, and ordinary contracts
contracts: { id, create, update, upsert: UpsertViewerBinding, response },
operations: crudOperations.only("upsert", "delete"),
});

const binding = bindTypeOrmCrud({
resource: viewerBindings,
fields,
adapter,
scopeCreateFields: ["viewerUserId"],
upsert: {
conflictFields: ["artifactId", "viewerUserId", "mcpServerId"],
overwriteFields: ["toolPrefix", "allowedTools"],
},
mappings: {
upsert: (id, input) => ({ ...id, ...input }),
scopeCreate: (values) => ({ viewerUserId: values.viewerUserId }),
// ...ordinary mappings
},
});
```

The mapper produces one proposed final insert row; scope-owned values are
merged last. `conflictFields` and `overwriteFields` are adapter persistence
paths, not public field names. The conflict target may include scope-owned
identity columns that are absent from the URL, while overwrite fields must be
disjoint from both the conflict identity and `scopeCreateFields`.

An adapter advertising the optional atomic-upsert capability must perform one
race-free statement, apply the normal resource and scope predicate inside the
conflict-update arm, return `null` for a hidden conflict without changing it,
and return the resulting record without a reload. Upsert has dedicated
`beforeUpsert`/`afterUpsert`/`afterCommit` lifecycle events and deliberately has
no pre-read, `prior` record, or created-versus-updated branch signal. The
TypeORM PostgreSQL adapter is certified for this contract; the other bundled
adapters currently reject resources that enable upsert.

## Projections

Some response fields are not columns. An adapter selects from one table with no
Expand Down Expand Up @@ -419,7 +509,7 @@ Register both resource bindings. `include=posts` batches the target query,
supports composite join tuples, and always applies the target resource's scopes
and soft-delete policy. To-many includes fetch one row beyond `maxItems` (or the
root `maxRelatedRows`) and return `422` if the bound is exceeded; data is never
silently truncated. Nested relation traversal and nested writes are deferred.
silently truncated. Nested relation traversal is deferred.

## Errors

Expand Down Expand Up @@ -493,7 +583,7 @@ Swagger metadata, and four adapters. The broader
[hono-crud feature surface](https://github.com/kshdotdev/hono-crud/blob/80de807d7c18691b7ddedf6ccca6db47b5cb1b57/README.md#features)
is a staged roadmap, not an alpha release gate.

Batch operations, upsert/bulk patch, nested writes, optimistic concurrency,
Batch operations, bulk patch, optimistic concurrency,
aggregates, full-text search, sparse fieldsets, import/export, computed fields,
audit history, record versioning, GraphQL, microservices, and schematics are
deferred. Cache, rate limiting, idempotency, logging/events/webhooks,
Expand Down
5 changes: 5 additions & 0 deletions packages/crud-drizzle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ or explicitly annotate it with
`CrudCreateMappingValues<DocumentInsert, "organizationId" | "ownerId">` from
`@nestm/crud/adapter`.

The Drizzle adapter does not yet advertise the core atomic-upsert capability.
Although `bindDrizzleCrud` forwards generic binding metadata for custom capable
adapters, the bundled adapter rejects resources that enable `upsert` until its
PostgreSQL conflict-update authorization path is certified.

## Application-owned transactions and native row policy

Use `transactionRunner` when every standalone CRUD statement must execute in an
Expand Down
3 changes: 3 additions & 0 deletions packages/crud-drizzle/src/bind-drizzle-crud.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
defineCrudBinding,
type CrudAdapterProvider,
type CrudBindingUpsertOptions,
type CrudBindingMappings,
type CompleteCrudFieldSelection,
type CrudScopeCreateField,
Expand Down Expand Up @@ -36,6 +37,8 @@ interface BindDrizzleCrudOptionsBase<
>;
/** Insert fields supplied by CRUD scopes through `mappings.scopeCreate`. */
readonly scopeCreateFields?: ScopeCreateFields;
/** Atomic-upsert persistence fields. The configured adapter must advertise that capability. */
readonly upsert?: CrudBindingUpsertOptions;
/** Standard Nest provider form for an adapter; injected databases remain application-owned. */
readonly adapter: DrizzleCrudAdapterProvider<RecordType, CreateValues, UpdateValues>;
}
Expand Down
10 changes: 10 additions & 0 deletions packages/crud-memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const usersBinding = bindMemoryCrud({
unique: [["id"]],
mappings: {
create: (input) => ({ ...input, id: randomUUID() }),
// Nested path values and application scope values can be mapped separately
// through scopeCreate when they own required insert fields.
update: (input) => input,
// Maps scope and soft-delete logical values to stored record keys.
persistence: (values) => values,
Expand All @@ -31,7 +33,15 @@ or package-created store; it does not open an external connection. The
`adapter` override accepts Nest's `useValue`, `useClass`, `useExisting`, and
`useFactory` provider forms when an application-managed adapter is preferable.

Like the SQL binders, `bindMemoryCrud` accepts `scopeCreateFields` plus
`mappings.scopeCreate`. Use them for required insert fields owned by a nested
collection path or application scope rather than the request body.

`fields` names are logical API fields used by filters, ordering, IDs, scopes,
soft deletion, and relations. For non-object records, provide `createRecord`,
`updateRecord`, and `getField`. Declare `unique` logical-field tuples to exercise
the same `409` conflict path used by SQL unique constraints.

The bundled memory adapter does not advertise atomic upsert. The binder forwards
generic upsert metadata only when an application supplies a custom capable
adapter; ordinary memory resources that enable upsert fail at bootstrap.
58 changes: 52 additions & 6 deletions packages/crud-memory/src/bind-memory-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import type { ModuleMetadata } from "@nestjs/common";
import {
defineCrudBinding,
type CrudAdapterProvider,
type CrudBindingUpsertOptions,
type CrudBindingMappings,
type CompleteCrudFieldSelection,
type DefineCrudBindingOptions,
type CrudScopeCreateField,
type CrudResourceBinding,
type CrudValues,
} from "@nestm/crud/adapter";
Expand All @@ -19,6 +21,8 @@ interface BindMemoryCrudOptionsBase<
Fields extends readonly string[] = readonly string[],
CreateValues extends object = object,
UpdateValues extends object = object,
ScopeCreateFields extends readonly CrudScopeCreateField<CreateValues, UpdateValues>[] =
readonly [],
> extends MemoryCrudAdapterOptions<RecordType, CreateValues, UpdateValues> {
readonly resource: Resource;
readonly imports?: ModuleMetadata["imports"];
Expand All @@ -27,8 +31,13 @@ interface BindMemoryCrudOptionsBase<
Resource,
NoInfer<RecordType>,
NoInfer<CreateValues>,
NoInfer<UpdateValues>
NoInfer<UpdateValues>,
NoInfer<ScopeCreateFields[number]>
>;
/** Insert fields supplied by path parameters or CRUD scopes through `mappings.scopeCreate`. */
readonly scopeCreateFields?: ScopeCreateFields;
/** Atomic-upsert persistence fields. The configured adapter must advertise that capability. */
readonly upsert?: CrudBindingUpsertOptions;
/** Overrides the convenient package-owned adapter with any standard Nest provider form. */
readonly adapter?: CrudAdapterProvider<RecordType, CreateValues, UpdateValues>;
}
Expand All @@ -39,7 +48,16 @@ export type BindMemoryCrudOptions<
Fields extends readonly string[] = readonly string[],
CreateValues extends object = object,
UpdateValues extends object = object,
> = BindMemoryCrudOptionsBase<Resource, RecordType, Fields, CreateValues, UpdateValues> &
ScopeCreateFields extends readonly CrudScopeCreateField<CreateValues, UpdateValues>[] =
readonly [],
> = BindMemoryCrudOptionsBase<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields
> &
CompleteCrudFieldSelection<Resource, Fields>;

/** Creates a core binding without installing or owning any external dependency. */
Expand All @@ -49,14 +67,32 @@ export function bindMemoryCrud<
const Fields extends readonly string[] = readonly string[],
CreateValues extends object = object,
UpdateValues extends object = object,
const ScopeCreateFields extends readonly CrudScopeCreateField<CreateValues, UpdateValues>[] =
readonly [],
>(
options: BindMemoryCrudOptions<Resource, RecordType, Fields, CreateValues, UpdateValues>,
): CrudResourceBinding<Resource, RecordType, Fields, CreateValues, UpdateValues> {
options: BindMemoryCrudOptions<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields
>,
): CrudResourceBinding<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields[number]
> {
const {
resource,
imports,
fields,
mappings,
scopeCreateFields,
upsert,
adapter,
store,
initialRecords,
Expand Down Expand Up @@ -84,13 +120,23 @@ export function bindMemoryCrud<
...(imports === undefined ? {} : { imports }),
fields,
mappings,
...(scopeCreateFields === undefined ? {} : { scopeCreateFields }),
...(upsert === undefined ? {} : { upsert }),
adapter: resolvedAdapter,
} as unknown as DefineCrudBindingOptions<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues
UpdateValues,
ScopeCreateFields
>;
return defineCrudBinding<Resource, RecordType, Fields, CreateValues, UpdateValues>(coreOptions);
return defineCrudBinding<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields
>(coreOptions);
}
8 changes: 8 additions & 0 deletions packages/crud-prisma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ adapter compile `isnull=true|false` to a constant false/true predicate without s
The binder accepts Nest `useValue`, `useClass`, `useExisting`, and `useFactory`
adapter providers. A factory can inject an application-owned `PrismaClient`;
this package never constructs it and never calls `$connect` or `$disconnect`.
Required insert fields owned by nested path parameters or application scopes
can be declared with `scopeCreateFields` and mapped separately through
`mappings.scopeCreate`, matching the core binding contract.

The Prisma adapter does not currently advertise atomic, predicate-guarded
upsert support. A delegate `upsert` cannot generally apply the full CRUD scope
predicate only to its conflict-update arm, so resources enabling that operation
must use an adapter that can preserve the authorization contract.

Prisma unique violations map to `409`, supported database/model constraints to
`400`, and unrecognized failures to a sanitized `500`. The `0.1` alpha is
Expand Down
Loading
Loading