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
19 changes: 19 additions & 0 deletions .changeset/typed-crud-field-vocabularies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@nestm/crud": minor
"@nestm/crud-memory": minor
"@nestm/crud-typeorm": minor
"@nestm/crud-drizzle": minor
"@nestm/crud-prisma": minor
---

Require every resource to declare one authoritative `fields` tuple and remove
the duplicated `fields` option from bindings. IDs, nested path mappings, query
filters/search/sort, soft delete, relations, scopes, cursor bindings, mapping
callbacks, predicates, and ordering now autocomplete that vocabulary and reject
unknown names.

Preserve persistence-field vocabularies across adapters and bindings. Upsert
conflict and overwrite tuples now autocomplete adapter fields, including typed
TypeORM property paths. Memory uniqueness constraints, TypeORM references,
Drizzle columns and record keys, and Prisma identity/model/record mappings are
derived from their corresponding persistence models.
5 changes: 2 additions & 3 deletions .github/scripts/consumer-fixtures/crud-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const consumerOperationDecorator: MethodDecorator = (_target, _propertyKey, desc
};

const resource = defineCrudResource({
fields: ["id", "name"],
name: "consumer-items",
path: "/api/consumer-items/",
itemPath: ":id",
Expand All @@ -88,14 +89,12 @@ const resource = defineCrudResource({
version: "1",
});

const binding = bindMemoryCrud<typeof resource, Item, readonly ["id", "name"], Item, UpdateItem>({
const binding = bindMemoryCrud<typeof resource, Item, Item, UpdateItem>({
resource,
fields: ["id", "name"],
initialRecords: [{ id: 1, name: "packed artifact" }],
mappings: {
create: (input) => input,
update: (input) => input,
persistence: (values) => values,
response: (record) => record,
},
});
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const UserResponse = z.object({
});

export const users = defineCrudResource({
fields: ["id", "name"],
name: "users",
path: "users",
itemPath: ":id",
Expand Down Expand Up @@ -84,7 +85,6 @@ export const users = defineCrudResource({

const usersBinding = bindMemoryCrud({
resource: users,
fields: ["id", "name"],
unique: [["id"]],
mappings: {
create: (input) => input,
Expand Down Expand Up @@ -210,6 +210,7 @@ and cursor tie-breaking:

```ts
const tenantUsers = defineCrudResource({
fields: ["tenantId", "id"],
name: "tenant-users",
path: "tenant-users",
itemPath: ":tenantId/:id",
Expand All @@ -224,8 +225,8 @@ 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.
The resource's `fields` tuple is the authoritative logical vocabulary. ORM
adapter column maps can translate those logical names to physical database columns.

## Nested resources

Expand All @@ -235,6 +236,7 @@ continues to describe the complete item route:

```ts
const versions = defineCrudResource({
fields: ["artifactId", "versionId"],
name: "artifact-versions",
path: "artifacts/:artifactId/versions",
pathParams: {
Expand Down Expand Up @@ -395,13 +397,13 @@ from a compatibility controller:
```ts
const viewerBindings = defineCrudResource({
// ...path, complete ID, and ordinary contracts
fields: ["artifactId", "viewerUserId", "mcpServerId", "toolPrefix", "allowedTools"],
contracts: { id, create, update, upsert: UpsertViewerBinding, response },
operations: crudOperations.only("upsert", "delete"),
});

const binding = bindTypeOrmCrud({
resource: viewerBindings,
fields,
adapter,
scopeCreateFields: ["viewerUserId"],
upsert: {
Expand Down Expand Up @@ -452,6 +454,7 @@ export class ProjectArtifactCounts implements CrudProjection {

defineCrudResource({
// …
fields: ["id"],
projections: [ProjectArtifactCounts],
});
```
Expand Down
2 changes: 0 additions & 2 deletions packages/crud-drizzle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const usersAdapter = createDrizzleCrudAdapter({

const usersBinding = bindDrizzleCrud({
resource: users,
fields: ["id", "email", "createdAt"],
adapter: { useValue: usersAdapter },
mappings: {
create: (input) => input,
Expand All @@ -51,7 +50,6 @@ receives its complete inferred insert model:
const documentsBinding = bindDrizzleCrud({
resource: documentsResource,
adapter: { useValue: documentsAdapter },
fields: ["id", "organizationId", "ownerId", "title"],
scopeCreateFields: ["organizationId", "ownerId"],
mappings: {
create: (input) => ({ title: input.title }),
Expand Down
57 changes: 42 additions & 15 deletions packages/crud-drizzle/src/bind-drizzle-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
type CrudBindingUpsertOptions,
type CrudBindingMappings,
type CompleteCrudFieldSelection,
type DefineCrudBindingOptions,
type CrudPersistenceField,
type CrudScopeCreateField,
type CrudResourceBinding,
} from "@nestm/crud/adapter";
Expand All @@ -15,19 +17,25 @@ export type DrizzleCrudAdapterProvider<
RecordType,
CreateValues extends object = object,
UpdateValues extends object = object,
> = CrudAdapterProvider<RecordType, CreateValues, UpdateValues>;
QueryField extends string = string,
> = CrudAdapterProvider<
RecordType,
CreateValues,
UpdateValues,
CrudPersistenceField<CreateValues>,
QueryField
>;

interface BindDrizzleCrudOptionsBase<
Resource extends BindableResource,
RecordType,
Fields extends readonly string[],
CreateValues extends object,
UpdateValues extends object,
ScopeCreateFields extends readonly CrudScopeCreateField<CreateValues, UpdateValues>[],
QueryField extends string,
> {
readonly resource: Resource;
readonly imports?: ModuleMetadata["imports"];
readonly fields: Fields;
readonly mappings: CrudBindingMappings<
Resource,
NoInfer<RecordType>,
Expand All @@ -38,54 +46,73 @@ 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;
readonly upsert?: CrudBindingUpsertOptions<CrudPersistenceField<CreateValues>>;
/** Standard Nest provider form for an adapter; injected databases remain application-owned. */
readonly adapter: DrizzleCrudAdapterProvider<RecordType, CreateValues, UpdateValues>;
readonly adapter: DrizzleCrudAdapterProvider<RecordType, CreateValues, UpdateValues, QueryField>;
}

export type BindDrizzleCrudOptions<
Resource extends BindableResource,
RecordType,
Fields extends readonly string[] = readonly string[],
CreateValues extends object = object,
UpdateValues extends object = object,
ScopeCreateFields extends readonly CrudScopeCreateField<CreateValues, UpdateValues>[] =
readonly [],
QueryField extends string = Resource["fields"][number],
> = BindDrizzleCrudOptionsBase<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields
ScopeCreateFields,
QueryField
> &
CompleteCrudFieldSelection<Resource, Fields>;
CompleteCrudFieldSelection<Resource, QueryField>;

/** Creates a core binding without taking ownership of the application's Drizzle client. */
export function bindDrizzleCrud<
const Resource extends BindableResource,
RecordType,
const Fields extends readonly string[],
CreateValues extends object = object,
UpdateValues extends object = object,
const ScopeCreateFields extends readonly CrudScopeCreateField<CreateValues, UpdateValues>[] =
readonly [],
QueryField extends string = Resource["fields"][number],
>(
options: BindDrizzleCrudOptions<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields
ScopeCreateFields,
QueryField
>,
): CrudResourceBinding<
Resource,
RecordType,
Fields,
CreateValues,
UpdateValues,
ScopeCreateFields[number]
ScopeCreateFields[number],
CrudPersistenceField<CreateValues>,
QueryField
> {
return defineCrudBinding(options);
return defineCrudBinding<
Resource,
RecordType,
CreateValues,
UpdateValues,
ScopeCreateFields,
CrudPersistenceField<CreateValues>,
QueryField
>(
options as DefineCrudBindingOptions<
Resource,
RecordType,
CreateValues,
UpdateValues,
ScopeCreateFields,
CrudPersistenceField<CreateValues>,
QueryField
>,
);
}
36 changes: 24 additions & 12 deletions packages/crud-drizzle/src/drizzle-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
CrudFindManyInput,
CrudFindManyResult,
CrudFindOneInput,
CrudPersistenceField,
CrudPredicate,
CrudUpdateInput,
} from "@nestm/crud/adapter";
Expand All @@ -28,6 +29,10 @@ import { compileDrizzlePredicate, type DrizzleCrudColumns } from "./drizzle-pred

export type DrizzleCrudCreateValues<Table extends AnyPgTable> = InferInsertModel<Table>;
export type DrizzleCrudUpdateValues<Table extends AnyPgTable> = Partial<InferInsertModel<Table>>;
export type DrizzleCrudLogicalField<Columns extends Readonly<Record<string, unknown>>> = Extract<
keyof Columns,
string
>;

export type DrizzleCrudDatabase<
QueryResult extends PgQueryResultHKT,
Expand Down Expand Up @@ -100,14 +105,17 @@ export interface DrizzleCrudAdapterOptions<
QueryResult extends PgQueryResultHKT,
FullSchema extends Record<string, unknown>,
Schema extends TablesRelationalConfig,
Columns extends DrizzleCrudColumns<Table> = DrizzleCrudColumns<Table>,
> {
/** A Drizzle database owned and lifecycle-managed by the consuming application. */
readonly database: PgDatabase<QueryResult, FullSchema, Schema>;
readonly table: Table;
/** Maps public logical field names to columns on `table`. */
readonly columns: DrizzleCrudColumns;
readonly columns: Columns;
/** Maps logical fields to keys in returned row objects; defaults to the logical field. */
readonly recordKeys?: Readonly<Record<string, string>>;
readonly recordKeys?: Readonly<
Partial<Record<Extract<keyof Columns, string>, Extract<keyof InferSelectModel<Table>, string>>>
>;
/**
* Minimum isolation for the complete CRUD operation, including scopes,
* lifecycle hooks, validators, mappings, projections, and persistence.
Expand Down Expand Up @@ -278,10 +286,13 @@ export class DrizzleCrudAdapter<
QueryResult extends PgQueryResultHKT,
FullSchema extends Record<string, unknown>,
Schema extends TablesRelationalConfig,
Columns extends DrizzleCrudColumns<Table> = DrizzleCrudColumns<Table>,
> implements CrudAdapter<
InferSelectModel<Table>,
DrizzleCrudCreateValues<Table>,
DrizzleCrudUpdateValues<Table>
DrizzleCrudUpdateValues<Table>,
CrudPersistenceField<DrizzleCrudCreateValues<Table>>,
DrizzleCrudLogicalField<Columns>
> {
readonly capabilities = Object.freeze({
transactions: true,
Expand All @@ -293,7 +304,7 @@ export class DrizzleCrudAdapter<
readonly #database: PgDatabase<QueryResult, FullSchema, Schema>;
readonly #table: Table;
readonly #columns: DrizzleCrudColumns;
readonly #recordKeys: Readonly<Record<string, string>>;
readonly #recordKeys: Readonly<Partial<Record<string, string>>>;
readonly #operationIsolationLevel: DrizzleCrudTransactionIsolationLevel | undefined;
readonly #transactionRunner:
DrizzleCrudTransactionRunner<QueryResult, FullSchema, Schema> | undefined;
Expand All @@ -308,7 +319,7 @@ export class DrizzleCrudAdapter<
>
>();

constructor(options: DrizzleCrudAdapterOptions<Table, QueryResult, FullSchema, Schema>) {
constructor(options: DrizzleCrudAdapterOptions<Table, QueryResult, FullSchema, Schema, Columns>) {
this.#database = options.database;
this.#table = options.table;
this.#columns = Object.freeze({ ...options.columns });
Expand Down Expand Up @@ -370,7 +381,7 @@ export class DrizzleCrudAdapter<
}

async findOne(
input: CrudFindOneInput,
input: CrudFindOneInput<DrizzleCrudLogicalField<Columns>>,
context: CrudAdapterContext,
): Promise<InferSelectModel<Table> | null> {
try {
Expand Down Expand Up @@ -404,7 +415,7 @@ export class DrizzleCrudAdapter<
}

async findMany(
input: CrudFindManyInput,
input: CrudFindManyInput<DrizzleCrudLogicalField<Columns>>,
context: CrudAdapterContext,
): Promise<CrudFindManyResult<InferSelectModel<Table>>> {
try {
Expand Down Expand Up @@ -443,7 +454,7 @@ export class DrizzleCrudAdapter<
}

async update(
input: CrudUpdateInput<DrizzleCrudUpdateValues<Table>>,
input: CrudUpdateInput<DrizzleCrudUpdateValues<Table>, DrizzleCrudLogicalField<Columns>>,
context: CrudAdapterContext,
): Promise<InferSelectModel<Table> | null> {
try {
Expand Down Expand Up @@ -474,7 +485,7 @@ export class DrizzleCrudAdapter<
}

async delete(
input: CrudDeleteInput,
input: CrudDeleteInput<DrizzleCrudLogicalField<Columns>>,
context: CrudAdapterContext,
): Promise<InferSelectModel<Table> | null> {
try {
Expand All @@ -500,7 +511,7 @@ export class DrizzleCrudAdapter<
}
}

getField(record: InferSelectModel<Table>, field: string): unknown {
getField(record: InferSelectModel<Table>, field: DrizzleCrudLogicalField<Columns>): unknown {
const key = this.#recordKeys[field] ?? field;
return (record as Readonly<Record<string, unknown>>)[key];
}
Expand Down Expand Up @@ -723,8 +734,9 @@ export function createDrizzleCrudAdapter<
QueryResult extends PgQueryResultHKT,
FullSchema extends Record<string, unknown>,
Schema extends TablesRelationalConfig,
const Columns extends DrizzleCrudColumns<Table>,
>(
options: DrizzleCrudAdapterOptions<Table, QueryResult, FullSchema, Schema>,
): DrizzleCrudAdapter<Table, QueryResult, FullSchema, Schema> {
options: DrizzleCrudAdapterOptions<Table, QueryResult, FullSchema, Schema, Columns>,
): DrizzleCrudAdapter<Table, QueryResult, FullSchema, Schema, Columns> {
return new DrizzleCrudAdapter(options);
}
Loading
Loading