Node.js'ten SQL Server'a: mssql Paketi, Connection Pool, Parameterized Query ve Transaction
Node.js'ten SQL Server'a mssql paketiyle bağlanırken pool uygulama başında bir kez açılır, her değer parametre olarak gider, sipariş ve stok tek transaction'da yazılır ve decimal kolonlar SQL'de kuruşa çevrilir. Gerçek çıktılarla 19,99'u 1998 cent'e çeviren float hatası, 100 sorguda 16,6 sn'ye karşı 0,16 sn, SQL injection ve LIKE joker karakterleri, son kopya yarışı, EREQINPROG ve 1 milyon satırı 139 MB ile stream eden export.
Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS), Express 5.3.0, mssql 12.7.14 (tedious 20.3.9), TypeScript 7.0.2 ve SQL Server 2025 ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) fiyatlar SQL Server'da decimal(10,2) kolonunda duruyor, API ise 12. yazıdan beri parayı kuruş (EUR için cent) cinsinden tam sayı olarak tutuyor. Hakan 19,99 EUR'luk bir baskı ekledi. Repository'yi yazan Emre fiyatı veritabanından okuyup 100 ile çarpıyor ve küsuratı atıyor:
import sql from "mssql";
const pool = await new sql.ConnectionPool(process.env.DB_CONNECTION_STRING!).connect();
// decimal(10,2) kolonunda 19.99 EUR'luk bir baskı; API kaç cent hesaplar?
const { recordset } = await pool.request().query("SELECT CAST(19.99 AS decimal(10, 2)) AS Price");
const price = recordset[0].Price;
console.log(typeof price, price);
console.log(Math.trunc(price * 100));
await pool.close();
Veritabanında değer tam olarak 19,99. İki satırda ne yazar?
node --env-file=.env labs/13/guess.ts
number 19.99
1998
mssql paketi decimal kolonunu JavaScript number'ına çeviriyor; 19,99 binary floating point'te tam ifade edilemiyor, 19.99 * 100 1998,9999999999998 çıkıyor ve Math.trunc bir cent'i siliyor. Dapper'da aynı kolon C# decimal'ına düşer ve bu sorun hiç oluşmaz. Bu yazıda Bookstore API'sini bellekten SQL Server'daki BookstoreDb'ye taşıyoruz: connection pool, parameterized query, stored procedure, sipariş ile stoku birlikte yazan transaction ve 09'daki export'un doğrudan SQL Server'dan stream edilmesi. Dönüşümün doğru yolunu da göreceğiz: kuruş hesabını SQL Server'ın kendi decimal aritmetiğine yaptırmak.
mssql ve Connection Pool
Node.js'ten SQL Server'a bağlanmanın yaygın yolu mssql paketi. Kendisi bir sarmalayıcı: TDS (Tabular Data Stream; SQL Server'ın istemciyle konuştuğu ağ protokolü) konuşan asıl sürücü, saf JavaScript ile yazılmış tedious. Native bağımlılık olmadığı için Windows'ta, Linux container'ında ve CI'da ek kurulum istemiyor. Paket connection pool'u (tarn), parametreleri, transaction'ları ve stream'leri tek bir API'de topluyor.
npm install mssql@12.7.14
npm install -D @types/mssql@12.3.0
Bağlantı bilgisi, .NET'teki ConnectionStrings bölümüne benzeyen bir connection string olarak environment variable'dan geliyor. mssql, .NET'teki SqlConnection'ın kullandığı biçimi tanıyor:
# .env (commit'lenmez); API'nin yalnız ihtiyaç duyduğu yetkilere sahip bir login kullan
DB_CONNECTION_STRING=Server=127.0.0.1,1433;Database=BookstoreDb;User Id=bookstore_app;Password=<password>;Encrypt=true;TrustServerCertificate=true
TrustServerCertificate=true yalnız geliştirme makinesindeki self-signed sertifika için; production'da sunucunun gerçek sertifikası doğrulanmalı. Pool'u uygulama başında bir kez açıyoruz ve bütün repository'ler aynı pool'u paylaşıyor:
import sql from "mssql";
import { log } from "../diagnostics/log.ts";
// Process başına tek pool: açılışta kurulur, bütün repository'ler paylaşır
export async function openPool(connectionString: string): Promise<sql.ConnectionPool> {
const config = sql.ConnectionPool.parseConnectionString(connectionString);
const pool = new sql.ConnectionPool({
...config,
pool: { max: 10, min: 0, idleTimeoutMillis: 30_000 },
});
// Boştaki bağlantıların hataları buraya gelir; listener olmazsa process çöker
pool.on("error", (err: Error) => log("error", "sql pool error", { error: err.message }));
await pool.connect();
return pool;
}
Pool ayarları mssql'ın varsayılanlarıyla aynı (en fazla 10 bağlantı, boşta 30 saniye kalan bağlantı kapanır); bilerek yazdık ki koda bakan sayıyı görsün. SqlConnection'da pool arka planda, connection string'e göre yönetilir; burada pool açık bir object ve onu sen taşıyorsun. server.ts pool'u top-level await ile açıyor, yani ilk bağlantı kurulmadan API port dinlemeye başlamıyor:
// Top-level await: ilk SQL bağlantısı kurulmadan API dinlemeye başlamaz
const pool = await openPool(connectionString);
const app = createApp(createServices(pool));
Veritabanına ulaşılamıyorsa process açılırken çöküyor ve exit code 1 dönüyor; yarım çalışan bir API'den iyidir:
ConnectionError: Failed to connect to 127.0.0.1:14399 - Could not connect (sequence)
at /home/user/bookstore-api/node_modules/mssql/lib/tedious/connection-pool.js:86:17
createServices artık pool'u alıyor ve bellekteki repository'lerin yerine SQL Server implementasyonlarını bağlıyor. Service'ler ve handler'lar değişmedi: 12'de repository interface'lerini bilerek async yapmıştık. Pool'u neden paylaştığımızı bir ölçüm gösteriyor. Aynı küçük sorgu 100 kez; önce her seferinde yeni bağlantı, sonra ortak pool:
node --env-file=.env labs/13/pool-vs-connect.ts
new connection per request: 16619 ms
shared pool: 164 ms
Yeni bağlantı her seferinde TCP bağlantısı, TLS (Transport Layer Security; şifreli bağlantı protokolü) handshake'i ve login demek; burada sorgu başına 166 ms, pool'da 1,6 ms. Benim ölçümümde SQL Server aynı makinedeki bir container'daydı; ağ üzerinden fark daha da büyür.
Şema ve Katalog: Stored Procedure ile Yükleme
Şema sql/schema.sql'da duruyor ve birden çok kez çalıştırılabilir. Tablolar plandaki gibi: Authors, Books, stok için Inventory module'ünün StockItems'ı, Orders ve OrderItems. Sipariş numaraları bir SEQUENCE'tan geliyor:
IF OBJECT_ID(N'dbo.Books', N'U') IS NULL
CREATE TABLE dbo.Books (
Isbn char(13) NOT NULL CONSTRAINT PK_Books PRIMARY KEY,
Title nvarchar(200) NOT NULL,
AuthorId int NOT NULL CONSTRAINT FK_Books_Authors REFERENCES dbo.Authors (Id),
Price decimal(10, 2) NOT NULL CONSTRAINT CK_Books_Price CHECK (Price > 0),
Currency char(3) NOT NULL CONSTRAINT CK_Books_Currency CHECK (Currency IN ('TRY', 'EUR'))
);
GO
IF OBJECT_ID(N'dbo.StockItems', N'U') IS NULL
CREATE TABLE dbo.StockItems (
Isbn char(13) NOT NULL CONSTRAINT PK_StockItems PRIMARY KEY CONSTRAINT FK_StockItems_Books REFERENCES dbo.Books (Isbn),
Quantity int NOT NULL CONSTRAINT CK_StockItems_Quantity CHECK (Quantity >= 0)
);
GO
IF OBJECT_ID(N'dbo.OrderNumbers', N'SO') IS NULL
CREATE SEQUENCE dbo.OrderNumbers AS int START WITH 1001;
GO T-SQL değil; sqlcmd ve SSMS (SQL Server Management Studio; SQL Server'ın masaüstü yönetim aracı) script'i bu satırlarda parçalara bölüyor. mssql böyle bir şey yapmıyor, bu yüzden scripts/db-setup.ts önce veritabanı yoksa master üzerinden oluşturuyor, sonra script'i GO satırlarından bölüp her parçayı request().batch() ile gönderiyor:
// GO bir sqlcmd/SSMS ayracıdır, T-SQL değil: script'i batch'lere kendimiz böleriz
const pool = await new sql.ConnectionPool(config).connect();
const script = await readFile(new URL("../sql/schema.sql", import.meta.url), "utf8");
const batches = script.split(/^\s*GO\s*$/m).filter((b) => b.trim() !== "");
for (const batch of batches) {
await pool.request().batch(batch);
}
npm run db:setup
database BookstoreDb is ready (7 batches)
Kataloğu yüklemek için bir stored procedure yazdık: dbo.UpsertBook yazarı bulur ya da ekler, kitabı günceller, kitap yeni ise ekleyip başlangıç stoğunu yazar ve yeni eklenip eklenmediğini bir OUTPUT parametresiyle döner.
CREATE OR ALTER PROCEDURE dbo.UpsertBook
@Isbn char(13),
@Title nvarchar(200),
@Author nvarchar(200),
@Price decimal(10, 2),
@Currency char(3),
@InitialStock int,
@Inserted bit OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @AuthorId int = (SELECT Id FROM dbo.Authors WHERE Name = @Author);
IF @AuthorId IS NULL
BEGIN
INSERT dbo.Authors (Name) VALUES (@Author);
SET @AuthorId = SCOPE_IDENTITY();
END;
UPDATE dbo.Books SET Title = @Title, AuthorId = @AuthorId, Price = @Price, Currency = @Currency WHERE Isbn = @Isbn;
SET @Inserted = IIF(@@ROWCOUNT = 0, 1, 0);
IF @Inserted = 1
BEGIN
INSERT dbo.Books (Isbn, Title, AuthorId, Price, Currency) VALUES (@Isbn, @Title, @AuthorId, @Price, @Currency);
INSERT dbo.StockItems (Isbn, Quantity) VALUES (@Isbn, @InitialStock);
END;
END;
scripts/load-catalog.ts bir JSON dosyasındaki kitapları tek transaction içinde bu procedure'a gönderiyor. Stored procedure çağrısı execute ile yapılıyor; output() ile tanımlanan parametre result.output'tan okunuyor:
// Her kitap için bir stored procedure çağrısı, hepsi tek transaction'da: katalog ya tamamen yüklenir ya hiç
for (const book of books) {
const result = await new sql.Request(tx)
.input("Isbn", sql.Char(13), book.isbn)
.input("Title", sql.NVarChar(200), book.title)
.input("Author", sql.NVarChar(200), book.author)
.input("Price", sql.Decimal(10, 2), toAmount(book.priceMinor))
.input("Currency", sql.Char(3), book.currency)
.input("InitialStock", sql.Int, Number(stockText))
.output("Inserted", sql.Bit)
.execute("dbo.UpsertBook");
inserted += result.output.Inserted ? 1 : 0;
}
Önce 12 kitaplık seed kataloğu, sonra 08'deki CSV (virgülle ayrılmış değerler) içe aktarmanın ürettiği data/catalog.json:
npm run db:load -- src/db/books.json 20
node scripts/import-catalog.ts data/catalog-sample.csv
npm run db:load -- data/catalog.json
loaded 12 books from src/db/books.json: 12 inserted, 0 updated
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
imported 6 books into data/catalog.json, 2 rows skipped
loaded 6 books from data/catalog.json: 1 inserted, 5 updated
CSV'deki altı geçerli satırın beşi zaten katalogdaydı ve güncellendi; Çalıkuşu yeni eklendi. nvarchar kolonlar Türkçe karakterleri sorunsuz taşıyor; varchar kullansaydık collation'a göre "ş" ve "ı" bozulabilirdi.
Parameterized Query ve SQL Injection
Bir arama kutusuna girilen metni SQL'e yapıştırmak, SQL injection'ın klasik yolu. Web Güvenliği (OWASP Top 10) serisinde injection'ın genel hâlini anlattık; burada Node tarafındaki karşılığına bakıyoruz:
import sql from "mssql";
const pool = await new sql.ConnectionPool(process.env.DB_CONNECTION_STRING!).connect();
const search = "x' OR 1=1 --";
// YANLIŞ: kullanıcı girdisi SQL metnine yapıştırılıyor
const glued = await pool.request().query(`SELECT Title FROM dbo.Books WHERE Title LIKE '%${search}%'`);
console.log(`string concatenation: ${glued.recordset.length} rows`);
// Doğru: değer parametre olarak gider, SQL Server onu hiçbir zaman SQL olarak parse etmez
const param = await pool.request().input("search", sql.NVarChar(102), `%${search}%`).query("SELECT Title FROM dbo.Books WHERE Title LIKE @search");
console.log(`parameter: ${param.recordset.length} rows`);
// Tagged template: aynı parametre, ${search} olarak yazılmış hâli
const tagged = await pool.request().query`SELECT Title FROM dbo.Books WHERE Title LIKE ${"%" + search + "%"}`;
console.log(`tagged template: ${tagged.recordset.length} rows`);
// Parametre de olsa LIKE pattern'idir: % ve _ escape edilmezse "%%" bütün başlıklarla eşleşir
const wildcard = await pool.request().input("search", sql.NVarChar(102), "%%%").query("SELECT Title FROM dbo.Books WHERE Title LIKE @search");
console.log(`unescaped "%%": ${wildcard.recordset.length} rows`);
await pool.close();
string concatenation: 13 rows
parameter: 0 rows
tagged template: 0 rows
unescaped "%%": 13 rows
Yapıştırılan sürümde ' OR 1=1 -- sorgunun koşulunu değiştirdi ve 13 kitabın hepsi döndü. Parametreli sürümde aynı metin yalnız bir değer; eşleşen başlık yok. Tagged template biçimi en ince tuzağa sahip olanı: query`... ${x}` ile yazılınca her ${} bir parametreye (@param1) dönüşüyor, ama aynı satırı parantezle query(`... ${x}`) yazarsan sıradan bir template literal olur ve değer SQL metnine yapışır. Görüntü neredeyse aynı, davranış tam tersi. Kod incelemesinde query(` gördüğünde içindeki her ${}'yi sorgula.
Son satır ayrı bir konu: parametre injection'ı önlüyor ama LIKE'ın joker karakterlerini değil. Kullanıcı %% yazınca bütün katalog döner; büyük bir tabloda bu, index kullanamayan pahalı bir tarama demek. Repository'de %, _ ve ['yi escape ediyoruz:
// LIKE, % ve _'yi parametrenin içinde de joker karakter sayar; escape et
function likePattern(text: string): string {
return `%${text.replace(/[\\%_[]/g, (ch) => `\\${ch}`)}%`;
}
Parametre olamayan tek şey kolon ve tablo adları. 12'de sort'u bir enum yapmıştık; SQL tarafında bu enum sabit SQL parçalarına eşleniyor, kullanıcı girdisi hiçbir zaman ORDER BY'a yazılmıyor:
// Kullanıcı girdisinden asla kolon adı üretilmez: API'nin sort değerleri sabit SQL parçalarına eşlenir
const ORDER_BY: Record<BookSort, string> = {
title: "b.Title",
price: "b.Price, b.Title",
"-price": "b.Price DESC, b.Title",
};
Repository: Dapper Gibi Elle Mapping
SqlBookRepository, 12'deki BookRepository interface'ini SQL ile uyguluyor. Dapper kullandıysan yapı tanıdık: SQL'i sen yazıyorsun, satırları bir fonksiyon object'e çeviriyor.
// Decimal'dan tam sayıya dönüşüm SQL'de yapılır; decimal aritmetiği orada kesin
const BOOK_COLUMNS = "b.Isbn, b.Title, a.Name AS Author, CAST(b.Price * 100 AS int) AS PriceMinor, b.Currency";
const FROM_BOOKS = "FROM dbo.Books AS b JOIN dbo.Authors AS a ON a.Id = b.AuthorId";
// Dapper'daki gibi elle mapping: kolon adlarını bilen tek yer
function toBook(row: BookRow): Book {
return { isbn: row.Isbn, title: row.Title, author: row.Author, priceMinor: row.PriceMinor, currency: row.Currency };
}
async list({ search, author, sort, offset, limit }: BookQuery): Promise<{ items: Book[]; totalCount: number }> {
const result = await this.#pool
.request()
.input("search", sql.NVarChar(102), search ? likePattern(search) : null)
.input("author", sql.NVarChar(200), author ?? null)
.input("offset", sql.Int, offset)
.input("limit", sql.Int, limit)
.query<BookRow & { TotalCount: number }>(`SELECT ${BOOK_COLUMNS}, COUNT(*) OVER () AS TotalCount ${FROM_BOOKS}
WHERE (@search IS NULL OR b.Title LIKE @search ESCAPE '\\')
AND (@author IS NULL OR a.Name = @author)
ORDER BY ${ORDER_BY[sort]}
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`);
const rows = result.recordset;
// COUNT(*) OVER () toplamı her satırda tekrarlar; boş bir sayfa için ayrı bir sayım gerekir
const totalCount = rows.length > 0 ? rows[0].TotalCount : offset > 0 ? await this.#count(search, author) : 0;
return { items: rows.map(toBook), totalCount };
}
Burada template literal kullanmak güvenli, çünkü ${}'lerin hepsi kodun kendi sabitleri; kullanıcıdan gelen her değer input() ile parametre oluyor. Parametre tiplerini açık yazmak (sql.NVarChar(102)) iki iş görüyor: SQL Server'ın tipi tahmin edip index'i kullanamadığı dönüşümleri önlüyor ve aynı sorgunun plan cache'te tek plan olarak kalmasını sağlıyor. COUNT(*) OVER () sayfa ile toplam sayıyı tek sorguda getiriyor. Siparişteki ISBN (kitapların uluslararası standart numarası) listesi için de tek parametre yetiyor: listeyi JSON olarak gönderip SQL Server'da OPENJSON ile açıyoruz (WHERE b.Isbn IN (SELECT value FROM OPENJSON(@isbns))). API'nin dışarıdan görünüşü değişmedi:
curl -s "http://127.0.0.1:3000/api/books?author=Jane%20Austen"
{"items":[{"isbn":"9780141439587","title":"Emma","price":{"amount":245,"currency":"TRY"}},{"isbn":"9780141439686","title":"Persuasion","price":{"amount":199,"currency":"TRY"}},{"isbn":"9780141439518","title":"Pride and Prejudice","price":{"amount":12.99,"currency":"EUR"}}],"page":1,"pageSize":10,"totalCount":3,"totalPages":1,"hasNextPage":false}
Tip Dönüşümleri: decimal, datetime2 ve UTC
Önce Tahmin Et'teki sorun tek değil. Aynı lab'ın büyük hâli, Türkiye saatiyle çalışan bir process'te:
TZ=Europe/Istanbul node --env-file=.env labs/13/types.ts
{
Price: 19.99,
PriceMinor: 1999,
Huge: 12345678901234568,
HugeText: '12345678901234567.89'
}
{
AsDatetime: 2026-11-14T10:00:00.007Z,
AsDatetime2: 2026-11-14T10:00:00.005Z
}
useUTC=true: 2026-11-14T10:00:00.000Z
useUTC=false: 2026-11-14T07:00:00.000Z
Satır satır: SQL'de CAST(Price * 100 AS int) doğru sonucu (1999) veriyor, çünkü çarpma decimal aritmetiğiyle yapılıyor. decimal(19,2) gibi geniş bir kolonda değer Number.MAX_SAFE_INTEGER'ı (yaklaşık 9 katrilyon) geçince number son haneleri kaybediyor; böyle değerleri metin olarak (varchar'a cast ederek) oku. Eski datetime tipi saniyenin 1/300'üne yuvarlıyor: 005 milisaniye 007 olarak dönüyor; datetime2(3) değeri olduğu gibi tutuyor. Son iki satır en sinsi olanı: mssql'ın useUTC seçeneği varsayılan olarak true ve offset'siz tarihleri UTC (Coordinated Universal Time; saat dilimi farkı sıfır olan referans saat) kabul ediyor. false yaparsan aynı değer process'in yerel saati sayılıyor ve İstanbul'da üç saat kayıyor. Bookstore'da kural basit: kolon adı CreatedAtUtc, tipi datetime2(3), useUTC varsayılanında kalıyor; yerel saate yalnız ekranda çevriliyor. Saat dilimi bilgisinin kendisini saklaman gerekiyorsa datetimeoffset kullan.
Yazarken de dikkat: sql.Decimal(10, 2) parametresine toAmount(34990), yani 349.9 veriyoruz; tedious değeri ölçeğe göre yuvarlayarak gönderdiği için 349.90 olarak yazılıyor. Okurken ise çevirme işini her zaman SQL'e bırakıyoruz.
Transaction: Sipariş ve Stok Birlikte
08'de stok güncellemesinin bir transaction gerektirdiğini söylemiştik. Sipariş satırları yazılırken stok düşmeli; bir satır için stok yetmiyorsa hiçbir şey yazılmamalı. SqlOrderRepository.add:
// Sipariş, satırları ve stok değişikliği birlikte commit edilir ya da hiçbiri
async add(order: Order): Promise<void> {
const tx = new sql.Transaction(this.#pool);
await tx.begin(sql.ISOLATION_LEVEL.READ_COMMITTED);
let rolledBack = false;
// SQL Server transaction'ı kendisi de geri alabilir (deadlock kurbanı, XACT_ABORT)
tx.on("rollback", () => {
rolledBack = true;
});
try {
await new sql.Request(tx)
.input("id", sql.Int, fromOrderId(order.id))
.input("customerId", sql.NVarChar(64), order.customerId)
.input("status", sql.VarChar(20), order.status)
.input("currency", sql.Char(3), order.currency)
.input("createdAtUtc", sql.DateTime2(3), new Date(order.createdAtUtc))
.query("INSERT dbo.Orders (Id, CustomerId, Status, Currency, CreatedAtUtc) VALUES (@id, @customerId, @status, @currency, @createdAtUtc)");
const missing: string[] = [];
// Sırayla, tek tek: bir transaction tek bir bağlantıdır
for (const [index, item] of order.items.entries()) {
const result = await new sql.Request(tx)
.input("orderId", sql.Int, fromOrderId(order.id))
.input("lineNumber", sql.Int, index + 1)
.input("isbn", sql.Char(13), item.isbn)
.input("quantity", sql.Int, item.quantity)
.input("unitPrice", sql.Decimal(10, 2), toAmount(item.unitPriceMinor))
.query(`UPDATE dbo.StockItems SET Quantity = Quantity - @quantity WHERE Isbn = @isbn AND Quantity >= @quantity;
INSERT dbo.OrderItems (OrderId, LineNumber, Isbn, Quantity, UnitPrice) VALUES (@orderId, @lineNumber, @isbn, @quantity, @unitPrice);`);
// rowsAffected her statement için bir değer taşır: [UPDATE, INSERT]
if (result.rowsAffected[0] === 0) {
missing.push(item.isbn);
}
}
if (missing.length > 0) {
throw new OutOfStockError(missing);
}
await tx.commit();
} catch (err) {
if (!rolledBack) {
await tx.rollback();
}
throw err;
}
}
Önemli olan UPDATE'in koşulu: Quantity >= @quantity. Stok önce okunup sonra düşülseydi ("check-then-act"), iki eşzamanlı sipariş aynı son kopyayı görüp ikisi de geçerdi. Koşullu UPDATE kontrolü ve yazmayı tek atomik adımda yapıyor; etkilenen satır sayısı 0 ise stok yetmemiş demektir. CHECK (Quantity >= 0) constraint'i de son savunma hattı olarak duruyor. OutOfStockError yeni bir domain hatası; hata middleware'i onu 409 Conflict'e eşliyor. Deneyelim: Ada'nın siparişi, ardından Dune'un kalan stoğundan fazlasını isteyen Alan:
HTTP/1.1 201 Created
x-request-id: req-13a
Location: /api/orders/ORD-1001
Content-Type: application/json; charset=utf-8
{"id":"ORD-1001","customerId":"ada","status":"Pending","createdAtUtc":"2026-11-14T19:39:04.807Z","items":[{"isbn":"9780441172719","quantity":3,"unitPrice":{"amount":349.9,"currency":"TRY"},"lineTotal":{"amount":1049.7,"currency":"TRY"}},{"isbn":"9780547928227","quantity":1,"unitPrice":{"amount":289.5,"currency":"TRY"},"lineTotal":{"amount":289.5,"currency":"TRY"}}],"total":{"amount":1339.2,"currency":"TRY"}}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"not enough stock for ISBN: 9780441172719","instance":"/api/orders","traceId":"req-13b"}
Alan'ın siparişindeki The Hobbit satırı stokta vardı, ama veritabanında hiçbir iz yok: Hobbit stoğu 19'da kaldı, Orders'da tek kayıt var. Rollback çalıştı. Bir yan etki: Alan için ayrılan 1002 numarası geri gelmiyor, çünkü SEQUENCE (tıpkı IDENTITY gibi) transaction'a dahil değil. Sipariş numaralarında boşluk normaldir; muhasebe ardışık numara istiyorsa fatura numarası ayrı üretilir. Şimdi Çalıkuşu'nun stoğunu 1'e indirip Grace ve Linus'un aynı anda son kopyayı almasını deniyoruz:
linus 201
grace 409
Biri kazandı, diğeri 409 aldı; stok 0'da kaldı, eksiye düşmedi. Node tarafında hiçbir kilit yok; işi SQL Server'ın satır kilidi yapıyor.
Koddaki for döngüsü bilinçli olarak sıralı. Bir transaction tek bir bağlantıya bağlı ve o bağlantıda aynı anda tek bir request çalışabiliyor. Promise.all ile paralelleştirmek bu yüzden hata veriyor:
{ Books: 13 }
EREQINPROG: Can't acquire connection for the request. There is another request in progress.
İlk denemede bu lab'ı Promise.all ve hemen ardından rollback() ile yazmıştım: Promise.all ilk hatada reddedildiği için diğer request hâlâ çalışıyordu ve rollback de Can't rollback transaction. There is a request in progress. hatası verdi; process transaction açık kaldığı için kapanmadı. C#'ta bağımsız sorguları Task.WhenAll ile paralel çalıştırma alışkanlığı transaction içinde geçerli değil; bu, Entity Framework Core'da aynı DbContext üzerinde eşzamanlı işlem yapılamamasının Node'daki karşılığı.
Export'u SQL Server'dan Stream Etmek
09'da 5,3 milyon satırlık export'u sahte satır üreten bir generator'dan besledik ve "veritabanına 13'te geçeceğiz" dedik. Test için tabloya set-based bir INSERT ile 500 bin sipariş ve 1 milyon sipariş satırı ekledim:
node --env-file=.env labs/13/seed-orders.ts 500000
{ Orders: 500002, OrderItems: 1000004 } 12887 ms
mssql'ın toReadableStream()'i bir request'i object mode'da bir Readable'a çeviriyor ve backpressure'ı destekliyor: stream'in buffer'ı dolunca request duraklıyor, SQL Server'dan yeni satır çekilmiyor. Önce farkı ölçelim; aynı sorgu, bütün recordset ile ve stream ile:
node --env-file=.env labs/13/export-memory.ts all
node --env-file=.env labs/13/export-memory.ts stream
all: 1000004 rows in 2005 ms, peak RSS 650 MB
stream: 1000004 rows in 1992 ms, peak RSS 139 MB
Süre aynı, ama RSS (Resident Set Size; process'in fiziksel bellekte kapladığı alan) 650 MB'a karşı 139 MB. Recordset'i bir kerede almak bir milyon JavaScript object'ini aynı anda heap'te tutmak demek; tablo büyüdükçe bu sayı da büyür. Stream'de bellek satır sayısından bağımsız. Repository'nin export method'u:
// Satırlar SQL Server'dan backpressure ile akar: stream'in buffer'ı doluyken request duraklar
exportRows(): Readable {
const request = new sql.Request(this.#pool);
const rows = request.toReadableStream({ highWaterMark: 500 });
request.query(`
SELECT o.Id, o.CreatedAtUtc, o.CustomerId, i.Isbn, b.Title, i.Quantity,
CAST(i.UnitPrice * 100 AS int) AS UnitPriceMinor, o.Currency, o.Status
FROM dbo.Orders AS o
JOIN dbo.OrderItems AS i ON i.OrderId = o.Id
JOIN dbo.Books AS b ON b.Isbn = i.Isbn
ORDER BY o.Id, i.LineNumber`);
// Yok edilen bir stream sorguyu kendiliğinden durdurmaz (client gitti, gzip hata verdi...)
rows.on("close", () => {
if (!rows.readableEnded) {
request.cancel();
}
});
const toExportRow = new Transform({
objectMode: true,
transform(row, encoding, callback) {
const exportRow: OrderExportRow = {
orderId: toOrderId(row.Id),
createdAtUtc: (row.CreatedAtUtc as Date).toISOString(),
customerId: row.CustomerId,
isbn: row.Isbn,
title: row.Title,
quantity: row.Quantity,
unitPriceMinor: row.UnitPriceMinor,
currency: row.Currency,
status: row.Status,
};
callback(null, exportRow);
},
});
// pipe değil pipeline: dönen stream yok edilince SQL stream'i de yok edilir
pipeline(rows, toExportRow, () => {});
return toExportRow;
}
request.cancel() satırı süs değil. mssql'ın dokümanı açıkça söylüyor: stream yok edilse de request sonuna kadar çalışır ve kalan satırlar atılır. Bir milyon satırlık tabloda ilk 1000 satırdan sonra okumayı bırakan iki deneme:
keep: stopped reading at 259 ms, request ended with "done" at 743 ms
cancel: stopped reading at 96 ms, request ended with "ECANCEL" at 110 ms
cancel() olmadan sorgu bir milyon satırın hepsini gönderip bitene kadar bağlantıyı tuttu; pool'da 10 bağlantı varken birkaç vazgeçilmiş indirme pool'u kilitleyebilir. cancel() ile SQL Server'a iptal sinyali gidiyor ve bağlantı 14 ms içinde pool'a döndü. Handler değişmedi; 09'daki pipeline(orders.exportRows(), csvTransform(), createGzip(), res) hattı artık SQL Server'dan besleniyor:
curl -s -o orders.csv -w "plain: %{http_code} %{size_download} bytes %{time_total}s\n" http://127.0.0.1:3000/api/orders/export
curl -s -H "accept-encoding: gzip" -o orders.csv.gz -w "gzip: %{http_code} %{size_download} bytes %{time_total}s\n" http://127.0.0.1:3000/api/orders/export
plain: 200 97734419 bytes 3.200417s
gzip: 200 7029159 bytes 2.900884s
1.000.004 satır (artı başlık), 98 MB düz ve 7 MB gzip'li CSV. API process'inin RSS'i boşta 141 MB'tı, iki indirme sırasında en fazla 210 MB oldu. Boştaki değer 09'dakinden (85 MB) yüksek, çünkü mssql ve tedious yüklü ve pool açık; önemli olan, tepe değerin export'un boyutuyla birlikte büyümemesi.
ORM mi, Elle SQL mi?
Bu seri Dapper serisine paralel ilerliyor: SQL'i biz yazıyoruz, mapping'i elle yapıyoruz. Node'da ORM (Object-Relational Mapping; tabloları ve satırları class'lara ve object'lere eşleyen katman) seçenekleri de var ve SQL Server desteği her birinde farklı olgunlukta. Bu yazının tarihinde npm registry'ye ve projelerin kendi dokümanlarına göre: Prisma 7, SQL Server'a mssql paketini kullanan @prisma/adapter-mssql ile bağlanıyor (Prisma 8 henüz release candidate). Drizzle'ın kararlı 0.45 sürümünde SQL Server yok; destek 1.0 release candidate hattında geliyor. Kysely bir query builder; SQL Server dialect'i çekirdekte var. Hiçbirini bu yazıda çalıştırmadım. Karar için kısa bir kural: domain modeli büyük, ilişkiler çok ve migration'ları kodla yönetmek istiyorsan Prisma ya da Drizzle; sorguların çoğu raporlama, toplu işlem ve stored procedure ise mssql ile elle SQL daha az sürpriz çıkarır. Tip güvenliği isteyen ama SQL'i bırakmak istemeyen için Kysely arada duruyor. PostgreSQL kullanıyorsan Node'daki karşılık pg paketi; parametre sözdizimi $1, pool ve transaction mantığı aynı. Ayrıntısı PostgreSQL serisinde.
Üretimde Dikkat
Pool'u bir kez aç, kapanışta kapat. Her request'te yeni pool açmak 100 sorguda 16 saniye ile 0,16 saniye arasındaki farktı. Kapanış sırası (önce HTTP server, sonra pool) bir sonraki yazının konusu.
Pool boyutunu SQL Server'a göre seç. Her API instance'ı en fazla 10 bağlantı açıyor; 8 replika 80 bağlantı demek. Uzun süren export'lar ve iptal edilmeyen stream'ler bağlantıları tutar; request.cancel()'ı unutma.
Uygulama login'inin yetkisini daralt. sa yerine yalnız Bookstore tablolarında okuma/yazma ve procedure çalıştırma yetkisi olan bir login kullan; şemayı değiştiren db:setup ayrı, yetkili bir kimlikle çalışsın.
Timeout'ları bil. mssql'ın varsayılanı bağlantı için 15 saniye, sorgu için 15 saniye. Toplu işlemler (seed script'indeki gibi) daha uzun bir requestTimeout ister; API sorguları ise bu süreden çok önce bitmeli.
Parayı SQL'de çevir. CAST(Price * 100 AS int) ile kuruş olarak oku; number'a düşmüş bir decimal'ı JavaScript'te çarpma.
Sık Düşülen Tuzaklar
Her request'te yeni bağlantı. sql.connect() global bir pool döner; onu her handler'da çağırmak ya da her seferinde new ConnectionPool açmak pool'u anlamsız kılar.
String birleştirerek SQL. Parantezli query(`...${x}`) ile tagged query`...${x}` arasındaki fark tek karakter ama biri injection açığı. Değerler her zaman input() ya da tagged template ile gitsin.
Tagged template'e SQL parçası koymak. Tersi de bir tuzak: query`SELECT ... ${ORDER_BY[sort]}` yazarsan sıralama ifadesi de bir parametreye dönüşür ve SQL Server sözdizimi hatası verir. Sabit SQL parçalarını birleştirmek için normal template literal, değerler için parametre.
Transaction içinde Promise.all. Tek bağlantı, tek request: EREQINPROG.
pool.on("error")'u dinlememek. Boştaki bir bağlantı koptuğunda pool bir error event'i yayar; 07'de gördüğümüz gibi dinlenmeyen error event'i process'i çökertir.
datetime ve useUTC: false. Yeni tablolarda datetime2, saat UTC; dönüşüm yalnız ekranda.
Küçük Bir Deneme
★☆☆ labs/13/guess.ts'teki Math.trunc(price * 100)'ü Math.round ile değiştir. 19,99 için düzeliyor mu? decimal(10,4) gibi dört haneli bir kolon için Math.round neden yine yetmez?
★★☆ GET /api/orders/:id iki result set'i tek round trip'te alıyor (result.recordsets). Aynı şeyi iki ayrı sorguyla Promise.all içinde yap ve pool üzerinden çalıştığı için hata vermediğini gör. Transaction içindeki durumdan farkı ne?
★★★ Sipariş iptali ekle: POST /api/orders/:id/cancel, siparişin durumunu Cancelled yapar ve stoğu geri ekler. Durum Pending değilse 409 dönsün. İki eşzamanlı iptal isteğinin stoğu iki kez geri eklemediğini, koşullu bir UPDATE ... WHERE Status = 'Pending' ile kanıtla.
Veri artık SQL Server'da, ama API hâlâ geliştirme makinesindeki bir script gibi çalışıyor: log'lar console.log, configuration dağınık, SIGTERM gelince açık request'ler yarıda kesiliyor. Bir sonraki yazıda API'yi production'a hazırlıyoruz: tipli configuration, pino ile structured logging, health check'ler ve graceful shutdown.