diff --git a/.changeset/native-typeorm-isolation-levels.md b/.changeset/native-typeorm-isolation-levels.md new file mode 100644 index 0000000..93f99db --- /dev/null +++ b/.changeset/native-typeorm-isolation-levels.md @@ -0,0 +1,8 @@ +--- +"@nestm/crud-typeorm": minor +--- + +Replace `TypeOrmCrudTransactionIsolationLevel`'s lowercase string enum with a +same-name const object and derived union type whose supported values use +TypeORM's native uppercase `IsolationLevel` spelling. Member access remains +unchanged, while values can now pass directly to TypeORM transaction APIs. diff --git a/.github/scripts/consumer-fixtures/crud-typeorm.ts b/.github/scripts/consumer-fixtures/crud-typeorm.ts index 81b1e53..566ccb7 100644 --- a/.github/scripts/consumer-fixtures/crud-typeorm.ts +++ b/.github/scripts/consumer-fixtures/crud-typeorm.ts @@ -3,14 +3,20 @@ import "reflect-metadata"; import { TypeOrmCrudAdapter, + TypeOrmCrudTransactionIsolationLevel, bindTypeOrmCrud, compileTypeOrmPredicate, createTypeOrmCrudAdapter, } from "@nestm/crud-typeorm"; +import type { IsolationLevel } from "typeorm/driver/types/IsolationLevel.js"; assert.equal(typeof TypeOrmCrudAdapter, "function"); assert.equal(typeof bindTypeOrmCrud, "function"); assert.equal(typeof compileTypeOrmPredicate, "function"); assert.equal(typeof createTypeOrmCrudAdapter, "function"); +const isolationLevel: IsolationLevel = TypeOrmCrudTransactionIsolationLevel.RepeatableRead; +const configuredIsolationLevel: TypeOrmCrudTransactionIsolationLevel = "READ COMMITTED"; +assert.equal(isolationLevel, "REPEATABLE READ"); +assert.equal(configuredIsolationLevel, TypeOrmCrudTransactionIsolationLevel.ReadCommitted); process.stdout.write("@nestm/crud-typeorm imported from its isolated packed artifact.\n"); diff --git a/packages/crud-typeorm/README.md b/packages/crud-typeorm/README.md index 302ece4..baf25ed 100644 --- a/packages/crud-typeorm/README.md +++ b/packages/crud-typeorm/README.md @@ -90,6 +90,10 @@ const documentsAdapter = createTypeOrmCrudAdapter({ }); ``` +The exported members use TypeORM's native uppercase `IsolationLevel` values, +so they can be passed directly to `QueryRunner.startTransaction()` and other +TypeORM transaction APIs. + This is distinct from `rowPredicate.transaction`, which describes only the predicate's own read/update/delete requirement. An operation-wide requirement also applies to create hooks and validators, where a nested policy-store read diff --git a/packages/crud-typeorm/src/typeorm-adapter.ts b/packages/crud-typeorm/src/typeorm-adapter.ts index 66f23b0..8fff023 100644 --- a/packages/crud-typeorm/src/typeorm-adapter.ts +++ b/packages/crud-typeorm/src/typeorm-adapter.ts @@ -25,6 +25,7 @@ import { type Repository, type SelectQueryBuilder, } from "typeorm"; +import type { IsolationLevel } from "typeorm/driver/types/IsolationLevel.js"; import { compileTypeOrmPredicate } from "./typeorm-predicate.ts"; @@ -248,10 +249,13 @@ export const TYPEORM_CRUD_REFERENCE_ALIAS = "crud_reference"; const TYPEORM_CRUD_ADAPTER_FACTORY = Symbol("@nestm/crud-typeorm:adapter-factory"); export type TypeOrmCrudTransactionAccessMode = "read only" | "read write"; -export enum TypeOrmCrudTransactionIsolationLevel { - ReadCommitted = "read committed", - RepeatableRead = "repeatable read", -} +/** TypeORM-native isolation levels supported by the CRUD transaction lifecycle. */ +export const TypeOrmCrudTransactionIsolationLevel = { + ReadCommitted: "READ COMMITTED", + RepeatableRead: "REPEATABLE READ", +} as const satisfies Record<"ReadCommitted" | "RepeatableRead", IsolationLevel>; +export type TypeOrmCrudTransactionIsolationLevel = + (typeof TypeOrmCrudTransactionIsolationLevel)[keyof typeof TypeOrmCrudTransactionIsolationLevel]; export interface TypeOrmCrudTransactionRequirements { readonly accessMode: TypeOrmCrudTransactionAccessMode; @@ -1279,11 +1283,7 @@ export class TypeOrmCrudAdapter< ): Promise { const queryRunner = this.#repository.manager.connection.createQueryRunner(); await queryRunner.connect(); - await queryRunner.startTransaction( - requirements.isolationLevel === TypeOrmCrudTransactionIsolationLevel.RepeatableRead - ? "REPEATABLE READ" - : "READ COMMITTED", - ); + await queryRunner.startTransaction(requirements.isolationLevel); try { if (requirements.accessMode === "read only") { await queryRunner.query("SET TRANSACTION READ ONLY"); diff --git a/packages/crud-typeorm/tests/typeorm-adapter.spec.ts b/packages/crud-typeorm/tests/typeorm-adapter.spec.ts index ef1c5b1..57e17f6 100644 --- a/packages/crud-typeorm/tests/typeorm-adapter.spec.ts +++ b/packages/crud-typeorm/tests/typeorm-adapter.spec.ts @@ -472,7 +472,7 @@ describe("TypeOrmCrudAdapter row predicate", () => { expect(runner.contexts[0]).toMatchObject({ operation: "create", accessMode: "read write", - isolationLevel: "repeatable read", + isolationLevel: "REPEATABLE READ", mustOwnCommit: true, }); }); @@ -546,7 +546,7 @@ describe("TypeOrmCrudAdapter transaction runner", () => { expect(runner.contexts[1]).toMatchObject({ operation: "list", accessMode: "read only", - isolationLevel: "read committed", + isolationLevel: "READ COMMITTED", mustOwnCommit: false, }); // The runner owns the transaction; the adapter must not open its own. diff --git a/packages/crud-typeorm/tests/typeorm-types.ts b/packages/crud-typeorm/tests/typeorm-types.ts index 7e0e165..c6673c7 100644 --- a/packages/crud-typeorm/tests/typeorm-types.ts +++ b/packages/crud-typeorm/tests/typeorm-types.ts @@ -1,6 +1,7 @@ import { crudOperations, defineCrudResource } from "@nestm/crud"; import type { CrudAdapter, CrudAdapterSession } from "@nestm/crud/adapter"; import { Brackets, type DeepPartial, type Repository } from "typeorm"; +import type { IsolationLevel } from "typeorm/driver/types/IsolationLevel.js"; import { z } from "zod"; import { @@ -14,6 +15,14 @@ import { type TypeOrmCrudSelectedRecord, } from "../src/index.ts"; +const nativeIsolationLevel: IsolationLevel = TypeOrmCrudTransactionIsolationLevel.RepeatableRead; +const configuredIsolationLevel: TypeOrmCrudTransactionIsolationLevel = "READ COMMITTED"; +// @ts-expect-error The CRUD lifecycle deliberately supports only its documented subset. +const unsupportedIsolationLevel: TypeOrmCrudTransactionIsolationLevel = "SERIALIZABLE"; +void nativeIsolationLevel; +void configuredIsolationLevel; +void unsupportedIsolationLevel; + class UserProfile { readonly nickname!: string; } @@ -129,7 +138,7 @@ createTypeOrmCrudAdapter({ repository, columns: { id: "id" }, transaction: { - // @ts-expect-error Isolation is intentionally an enum, not a free-form string. + // @ts-expect-error TypeORM isolation values use their native uppercase spelling. isolationLevel: "repeatable read", }, }); diff --git a/tests/postgres/adapters.postgres.spec.ts b/tests/postgres/adapters.postgres.spec.ts index 1a787e5..bc2ab0d 100644 --- a/tests/postgres/adapters.postgres.spec.ts +++ b/tests/postgres/adapters.postgres.spec.ts @@ -292,11 +292,7 @@ async function initializeHarnesses(pgUrl: string): Promise { // issue `SET TRANSACTION READ ONLY`, and it has to be the first statement. const queryRunner = typeOrmDataSource.createQueryRunner(); await queryRunner.connect(); - await queryRunner.startTransaction( - runnerContext.isolationLevel === TypeOrmCrudTransactionIsolationLevel.RepeatableRead - ? "REPEATABLE READ" - : "READ COMMITTED", - ); + await queryRunner.startTransaction(runnerContext.isolationLevel); try { if (runnerContext.accessMode === "read only") { await queryRunner.query("SET TRANSACTION READ ONLY"); @@ -346,11 +342,7 @@ async function initializeHarnesses(pgUrl: string): Promise { run: async (runnerContext, workWithTransaction) => { const queryRunner = typeOrmDataSource.createQueryRunner(); await queryRunner.connect(); - await queryRunner.startTransaction( - runnerContext.isolationLevel === TypeOrmCrudTransactionIsolationLevel.RepeatableRead - ? "REPEATABLE READ" - : "READ COMMITTED", - ); + await queryRunner.startTransaction(runnerContext.isolationLevel); try { const rows: unknown = await queryRunner.query("SHOW transaction_isolation"); const first = Array.isArray(rows) ? rows[0] : undefined; @@ -1138,7 +1130,7 @@ describe.skipIf(skipPostgres)("PostgreSQL adapter conformance", () => { expect(runnerContexts).toHaveLength(3); expect(runnerContexts[0]).toMatchObject({ accessMode: "read only", - isolationLevel: "repeatable read", + isolationLevel: "REPEATABLE READ", mustOwnCommit: false, }); expect(runnerContexts[1]).toMatchObject({ mustOwnCommit: true });