সরাসরি পর্বে যান
পর্ব ৫২ · ৫৯-এর মধ্যে · মডিউল ৭

Node.js আর ডেটাবেস — SQL, parameterized query, transaction আর ORM

Node.js and Databases — Relational vs Document, SQL Essentials, Schema Design, Connection Pooling, SQL Injection Prevention, Transactions, Indexes, N+1, Migrations, ORMs and MongoDB

This Bangla lesson connects Node.js to a real database: relational versus document stores, SQL essentials with keys, CRUD, JOIN and GROUP BY, an e-shop schema, drivers and connection pooling, parameterized queries against SQL injection, transactions for orders, indexes, EXPLAIN and the N+1 problem, migrations, ORMs and MongoDB, and wiring it into an Express app.

এই পর্বে যা শিখবেন

  • ডেটাবেস কী সমস্যা সমাধান করে; relational আর document ডেটাবেসের পার্থক্য, আর কোনটি কখন
  • দশ মিনিটে দরকারি SQL, আর একটি ই-শপের schema — কেন order_items, কেন দাম পয়সায়, কেন সময় UTC-তে
  • Node থেকে pg দিয়ে সংযোগ: Pool, connection pooling, await, parameterized query আর SQL injection ঠেকানো
  • Transaction দিয়ে নিরাপদে অর্ডার নেওয়া, isolation level, index আর EXPLAIN, N+1 সমস্যা ও সমাধান
  • Migration, ORM ও query builder (Prisma, Drizzle, Knex), MongoDB, Express-এ repository layer, 409 error, secret আর টেস্ট — আর AI-এর লেখা ডেটাবেস-কোড যাচাই

১ · মেমোরির array কেন যথেষ্ট নয় — ডেটাবেস কী দেয়

পর্ব ৫০-এ আপনি Express দিয়ে একটি দোকানের API বানিয়েছেন। পণ্য আর অর্ডার রাখা ছিল একটি সাধারণ array-তে: const products = [...]. ডেমোতে চমৎকার চলে। এবার বাস্তবের তিনটি ঘটনা ভাবুন। এক: রাত দুটোয় সার্ভার restart হলো (নতুন কোড deploy, বা বিদ্যুৎ গেল) — array-র সব অর্ডার মুছে গেল, কারণ সেগুলো ছিল শুধু RAM-এ। দুই: ঈদের ভিড়ে আপনি দুটি সার্ভার চালালেন — প্রত্যেকের নিজের array, তাই একজন ক্রেতার অর্ডার এক সার্ভারে আছে, অন্যটিতে নেই। তিন: পণ্য দশ লাখ হলো — প্রতিটি খোঁজায় পুরো array ঘুরে দেখা অনেক ধীর।

ডেটাবেস এই তিন সমস্যারই উত্তর। এটি একটি আলাদা প্রোগ্রাম (প্রায়ই আলাদা সার্ভারে), যার কাজ ডেটা স্থায়ীভাবে রাখা (disk-এ লেখা নিশ্চিত হলে তবেই “হয়েছে” বলে), অনেক অনুরোধকে একসাথে নিরাপদে পড়তে-লিখতে দেওয়া (transaction), আর দ্রুত খুঁজে দেওয়া (index). Node ডেটাবেসের সাথে কথা বলে একটি driver দিয়ে, নেটওয়ার্কের ওপর। কথা বলার দুই ধরন: নিজে SQL লেখা, অথবা ORM বা query builder-কে দিয়ে লেখানো। আর যে পথই নিন, একটি নিয়ম বদলায় না: ব্যবহারকারীর ইনপুট যায় parameter হিসেবে, query-র লেখায় জোড়া লেগে নয়।

মুদি দোকানের মালিক প্রথমে সব হিসাব মাথায় রাখেন — দোকান ছোট, চলে যায়। কিন্তু তিনি অসুস্থ হলে, বা ছেলে পাশের শাখা খুললে, মাথার হিসাব কাজে আসে না। তখন আসে বাঁধানো খাতা (স্থায়ী), যে খাতা দুই শাখার লোক একই নিয়মে লেখেন (ভাগ করা), পাতার মাথায় বর্ণানুক্রমিক সূচি (index), আর একটি কড়া নিয়ম: “একটি বিক্রির সব লাইন একসাথে লিখবেন, অর্ধেক লিখে উঠে যাবেন না” (transaction). RAM-এর array হলো মাথার হিসাব; ডেটাবেস হলো সেই খাতা আর তার নিয়মকানুন।

দুই পরিবার — relational আর document

🗃️ Relational (SQL)

ডেটা থাকে টেবিলে — সারি আর কলাম, প্রতিটি কলামের নির্দিষ্ট ধরন। টেবিলগুলো key দিয়ে সম্পর্কিত। প্রশ্ন করা হয় SQL দিয়ে। শক্তি: কড়া নিয়ম (constraint), JOIN, শক্তিশালী transaction. PostgreSQL, MySQL/MariaDB, SQLite.

উদাহরণ: অর্ডার, পেমেন্ট, হিসাব

📄 Document (NoSQL)

ডেটা থাকে JSON-এর মতো document-এ; একটি document-এর ভেতরে nested object আর array থাকতে পারে, আর একই collection-এর document-এর আকার আলাদা হতে পারে। NoSQL পরিবারের সবচেয়ে প্রচলিত সদস্য। MongoDB, Firestore.

উদাহরণ: প্রোফাইল, কনটেন্ট, ইভেন্ট লগ

⚡ অন্যান্য

Key-value store (Redis — মেমোরিতে, cache আর session-এর জন্য), search engine (Elasticsearch, Meilisearch), time-series আর graph ডেটাবেস। সাধারণত মূল ডেটাবেসের পাশে বসে, বদলি হয়ে নয়।

উদাহরণ: cache, পণ্য-খোঁজা

কোনটি বেছে নেবেন? প্রশ্নটি “কোনটি আধুনিক” নয় — “আমার ডেটার আকার আর নিয়ম কেমন”. ডেটায় যদি অনেক সম্পর্ক থাকে (ক্রেতা → অর্ডার → আইটেম → পণ্য), টাকার হিসাবে ভুল চলবে না, আর বিভিন্ন দিক থেকে রিপোর্ট লাগবে — relational. ডেটা যদি স্বাভাবিকভাবে একটি document-এ বসে (একটি ব্লগ পোস্ট তার ট্যাগসহ), আকার ঘন ঘন বদলায়, আর প্রায় সব প্রশ্ন “এই id-র পুরো জিনিসটি দাও” — document ভালো মানায়। Firebase-এর Firestore একটি document ডেটাবেস, যা ব্রাউজার থেকে সরাসরি পড়া যায় আর real-time আপডেট দেয়। এই পর্বের বেশিরভাগ উদাহরণ PostgreSQL দিয়ে, কারণ এর ধারণাগুলো প্রায় সব ডেটাবেসে কাজে লাগে; MongoDB আসবে অংশ ৮-এ।

মূল ধারণা

ডেটাবেস তিনটি প্রতিশ্রুতি দেয় — টিকে থাকা, একসাথে নিরাপদ থাকা, দ্রুত খুঁজে পাওয়া। এই পর্বের প্রতিটি অংশ এই তিনটির একটিকে কাজে লাগানোর কৌশল: schema আর constraint ডেটাকে সঠিক রাখে, transaction একসাথে নিরাপদ রাখে, index দ্রুত রাখে, আর parameterized query ডেটাবেসকে বাইরের আক্রমণ থেকে বাঁচায়।

২ · দশ মিনিটে SQL — টেবিল, key, CRUD, JOIN আর GROUP BY

SQL (Structured Query Language) ডেটাবেসকে “কীভাবে” নয়, “কী” চাই তা বলে: “ঢাকার ক্রেতাদের নাম দাও, নাম অনুযায়ী সাজিয়ে” — ডেটাবেস নিজে ঠিক করে কোন index ধরে, কোন ক্রমে খুঁজবে। পর্ব ১৬-এর filter/map/reduce মনে আছে? SQL-এর WHERE, SELECT আর GROUP BY প্রায় একই ভাবনা — শুধু কাজটি হয় ডেটাবেসের ভেতরে, ডেটা নেটওয়ার্ক দিয়ে Node-এ টেনে না এনে।

প্রতিটি টেবিলে একটি primary key থাকে — প্রতিটি সারির অনন্য পরিচয়, যেমন রোল নম্বর। অন্য টেবিলের সারিকে নির্দেশ করে foreign key: orders.customer_id বলে “এই অর্ডারটি কোন ক্রেতার”, আর ডেটাবেস নিশ্চিত করে যে এমন id-র ক্রেতা সত্যিই আছে। ডেটার চারটি মৌলিক কাজকে বলে CRUD — Create, Read, Update, Delete — SQL-এ INSERT, SELECT, UPDATE, DELETE:

-- Create: নতুন সারি
INSERT INTO customers (name, phone, district) VALUES ('রহিমা', '01711000000', 'ঢাকা');

-- Read: শর্ত, ক্রম আর সীমা
SELECT id, name FROM customers
WHERE district = 'ঢাকা'
ORDER BY name
LIMIT 20 OFFSET 0;          -- প্রথম পাতার ২০ জন

-- Update আর Delete: WHERE ভুলে গেলে সব সারি বদলাবে বা মুছবে!
UPDATE products SET stock = stock - 1 WHERE id = 7;
DELETE FROM carts WHERE updated_at < now() - interval '30 days';

-- JOIN: দুই টেবিল key ধরে মেলানো
SELECT o.id, c.name, o.total_paisa
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= '2026-10-01';

-- GROUP BY: জেলাভিত্তিক বিক্রি, বেশি থেকে কম
SELECT c.district, COUNT(*) AS orders, SUM(o.total_paisa) AS revenue_paisa
FROM orders o JOIN customers c ON c.id = o.customer_id
GROUP BY c.district
HAVING COUNT(*) >= 10
ORDER BY revenue_paisa DESC;

তিনটি জিনিস খেয়াল করুন। (১) JOIN-এর সাধারণ রূপ (INNER JOIN) শুধু মিল-থাকা সারি দেয়; যে ক্রেতার কোনো অর্ডার নেই, তাকেও তালিকায় চাইলে LEFT JOIN. (২) WHERE চলে দল বাঁধার আগে, HAVING চলে দল বাঁধার পরে। (৩) LIMIT … OFFSET ছোট তালিকায় ঠিক আছে, কিন্তু OFFSET ১০ লাখ হলে ডেটাবেসকে আগের ১০ লাখ সারি গুনে ফেলে দিতে হয়; বড় তালিকায় “শেষ দেখা id-র পর থেকে” (WHERE id < $1 ORDER BY id DESC LIMIT 20) — একে বলে keyset বা cursor pagination.

কিছু ইনস্টল না করেই SQL চালিয়ে দেখা

Node 22.5-এ পরীক্ষামূলক (experimental) মডিউল node:sqlite যুক্ত হয়েছে — Node-এর ভেতরেই SQLite, কোনো npm package লাগে না। শুরুর version-গুলোতে --experimental-sqlite flag দিয়ে চালাতে হতো; পরের version-গুলোতে flag ছাড়াই চলে, তবে মডিউলটির অবস্থা আপনার Node version-এর ডকুমেন্টেশনে দেখে নিন। এই কোর্সের অনলাইন runner Node 20-এ চলে, তাই নিচের কোডটি এখানে চলবে না — নিজের কম্পিউটারে Node 22.5 বা নতুন version-এ চালান।

import { DatabaseSync } from "node:sqlite";      // Node 22.5+ (experimental)

const db = new DatabaseSync(":memory:");           // ফাইল চাইলে "shop.db"
db.exec(`
  CREATE TABLE products (id INTEGER PRIMARY KEY, title TEXT NOT NULL,
                         category TEXT, price_paisa INTEGER NOT NULL);
  INSERT INTO products (title, category, price_paisa) VALUES
    ('চাল ৫ কেজি', 'grocery', 42000), ('ডাল ১ কেজি', 'grocery', 14000),
    ('ছাতা', 'rain', 55000);
`);
const byCategory = db.prepare(
  "SELECT title, price_paisa FROM products WHERE category = ? ORDER BY price_paisa DESC");
console.log(byCategory.all("grocery"));
console.log(byCategory.all("x' OR '1'='1").length);
console.log(db.prepare("SELECT category, COUNT(*) AS n FROM products GROUP BY category").all());
// প্রত্যাশিত আউটপুট (Node 24-এ চালানো):
// [
//   [Object: null prototype] { title: 'চাল ৫ কেজি', price_paisa: 42000 },
//   [Object: null prototype] { title: 'ডাল ১ কেজি', price_paisa: 14000 }
// ]
// 0
// [
//   [Object: null prototype] { category: 'grocery', n: 2 },
//   [Object: null prototype] { category: 'rain', n: 1 }
// ]

? হলো SQLite-এর placeholder; দ্বিতীয় লাইনের অদ্ভুত লেখাটি একটি সাধারণ মান হিসেবেই তুলনা হলো, তাই 0 সারি — অংশ ৫-এ এর কারণ বিস্তারিত। সারিগুলো null prototype-এর object (পর্ব ২৫) — ডেটাবেসের কলামের নাম যেন Object.prototype-এর কোনো property-র সাথে না মেশে।

৩ · একটি ই-শপের schema — customers, products, orders আর order_items

কোড লেখার আগে টেবিলের নকশা — একে বলে schema. ভালো schema অর্ধেক বাগ শুরুতেই থামায়, কারণ ভুল ডেটা ডেটাবেস ঢুকতেই দেয় না। আমাদের দোকানের চারটি টেবিল:

CREATE TABLE customers (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name        text NOT NULL,
  phone       text NOT NULL UNIQUE,                  -- একই নম্বরে দুই অ্যাকাউন্ট নয়
  district    text,
  created_at  timestamptz NOT NULL DEFAULT now()     -- UTC-তে জমা থাকে
);

CREATE TABLE products (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title        text NOT NULL,
  category     text NOT NULL,
  price_paisa  integer NOT NULL CHECK (price_paisa >= 0),   -- টাকা নয়, পয়সা
  stock        integer NOT NULL CHECK (stock >= 0)
);

CREATE TABLE orders (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id  bigint NOT NULL REFERENCES customers(id),
  status       text NOT NULL DEFAULT 'pending',
  total_paisa  integer NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  order_id     bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id   bigint NOT NULL REFERENCES products(id),
  qty          integer NOT NULL CHECK (qty > 0),
  unit_paisa   integer NOT NULL,                     -- কেনার মুহূর্তের দাম
  PRIMARY KEY (order_id, product_id)
);

কেন order_items আলাদা টেবিল? একটি অর্ডারে অনেক পণ্য, আর একটি পণ্য অনেক অর্ডারে — এটি many-to-many সম্পর্ক। orders-এ product1, product2, product3 কলাম বানালে চতুর্থ পণ্যে আটকে যাবেন, আর “চাল কতবার বিক্রি হলো” প্রশ্নটি তিন কলামে খুঁজতে হবে। মাঝের টেবিলে প্রতিটি পণ্য-লাইন একটি সারি — যত খুশি পণ্য, আর রিপোর্ট সহজ। একই তথ্য এক জায়গায় রাখার এই নিয়মকে বলে normalization. কিন্তু unit_paisa ইচ্ছা করেই দ্বিতীয়বার রাখা: পণ্যের দাম কাল বদলাবে, কিন্তু গত মাসের রসিদে পুরোনো দামই থাকতে হবে।

কেন পয়সায়? পর্ব ১০-এ দেখেছেন, 0.1 + 0.2 হয় 0.30000000000000004 — floating-point দশমিককে হুবহু রাখতে পারে না। টাকার কলাম float বা real হলে হাজার অর্ডারের যোগফলে পয়সার গরমিল জমবে। তাই দুই পথ: পূর্ণসংখ্যা পয়সা (৪২০ টাকা = 42000), অথবা ডেটাবেসের নির্ভুল দশমিক ধরন numeric(12,2). একটি সূক্ষ্ম ফাঁদ: pg driver numeric আর bigint কলাম JavaScript-এ string হিসেবে দেয় (যেমন COUNT(*)-এর ফল '42', এমনকি bigint id-ও '7'), কারণ Number-এ সেগুলো নির্ভুল না-ও ধরতে পারে। '42' + 1 হয় '421' — পর্ব ৬-এর coercion-এর ফাঁদ এখানে আবার।

কেন timestamptz? পর্ব ২০-এর নিয়ম: সময় জমা রাখুন UTC-তে, দেখান ব্যবহারকারীর সময় অঞ্চলে। PostgreSQL-এর timestamptz ঢোকানো সময়কে UTC-তে রূপান্তর করে রাখে, আর pg সেটি JavaScript-এর Date হিসেবে দেয়। শুধু timestamp (সময় অঞ্চল ছাড়া) দিলে সার্ভারকে UTC-তে চলা cloud-এ সরালেই সময় ছয় ঘণ্টা সরে যাওয়ার ঝুঁকি।

আর constraint-গুলো — NOT NULL, UNIQUE, CHECK (stock >= 0), REFERENCES — হলো শেষ পাহারাদার। Node-এর যাচাই (পর্ব ৩৯-এর server-side validation) সুন্দর error message দেয়, কিন্তু কোনো বাগ, কোনো পুরোনো স্ক্রিপ্ট বা দুটি একসাথে চলা অনুরোধ সেটি পেরিয়ে গেলে ডেটাবেসের constraint তবু ভুল ডেটা ঢুকতে দেবে না।

৪ · Node থেকে সংযোগ — driver, Pool আর connection pooling

PostgreSQL-এর জন্য Node-এর সবচেয়ে প্রচলিত driver হলো pg (node-postgres): npm install pg. এটি দুটি জিনিস দেয়। Client = একটি সংযোগ — নিজে খুলবেন, নিজে বন্ধ করবেন; script বা migration-এর জন্য ঠিক আছে। Pool = কয়েকটি সংযোগের একটি দল, যেগুলো অনুরোধে অনুরোধে ভাগ করে ব্যবহার হয় — সার্ভারের জন্য এটিই সঠিক।

কেন pool? একটি নতুন সংযোগ খোলা সস্তা নয়: TCP সংযোগ, প্রায়ই TLS handshake, পাসওয়ার্ড যাচাই — আর PostgreSQL প্রতিটি সংযোগের জন্য সার্ভারে একটি আলাদা process চালায়, যা মেমোরি খায়। ডিফল্টে PostgreSQL সর্বোচ্চ ১০০টি সংযোগ নেয় (max_connections). প্রতি অনুরোধে নতুন সংযোগ খুললে প্রতিটি অনুরোধ কয়েক মিলিসেকেন্ড বাড়তি অপেক্ষা করে, আর ভিড়ের সময় ডেটাবেস “too many clients” বলে নতুন সংযোগ ফিরিয়ে দেয়। Pool সংযোগগুলো খোলা রেখে বারবার ব্যবহার করে, আর সর্বোচ্চ সংখ্যা বেঁধে দেয় — ডেটাবেসকে রক্ষা করে।

ধাপ ১Pool তৈরি — max: 5
const pool = new Pool({ connectionString, max: 5 });
সংযোগ:  [ ] [ ] [ ] [ ] [ ]     ← এখনো একটিও খোলা নয় (pg দরকার হলে খোলে)
অপেক্ষার সারি: খালি
ধাপ ২প্রথম ৫টি অনুরোধ
অনুরোধ ১–৫ এল → প্রত্যেকের জন্য নতুন সংযোগ খোলা (handshake + login)
সংযোগ:  [১] [২] [৩] [৪] [৫]   ← সব ব্যস্ত
অপেক্ষার সারি: খালি
ধাপ ৩আরও ৫টি অনুরোধ — সংযোগ নেই
অনুরোধ ৬–১০ এল → নতুন সংযোগ খোলা হবে না (max 5)
সংযোগ:  [১] [২] [৩] [৪] [৫]
অপেক্ষার সারি: ৬ → ৭ → ৮ → ৯ → ১০
// Node block হয়নি: এরা শুধু await করছে, অন্য কাজ চলছে
ধাপ ৪একটি query শেষ — সংযোগ ফেরত
অনুরোধ ২-এর query শেষ → সংযোগ pool-এ ফেরত (release)
→ সারির প্রথম জন অনুরোধ ৬ সেটি সাথে সাথে পেল — নতুন handshake নেই
সংযোগ:  [১] [৬] [৩] [৪] [৫]
অপেক্ষার সারি: ৭ → ৮ → ৯ → ১০
ধাপ ৫সব শেষ — হিসাব
১০টি অনুরোধ, খোলা হলো মাত্র ৫টি সংযোগ; সেগুলো এখন idle, পরের অনুরোধের অপেক্ষায়
pool ছাড়া: ১০টি সংযোগ খোলা-বন্ধ, ভিড়ে ১০০ ছাড়ালে "too many clients"
// ৪টি সার্ভার × max 5 = ডেটাবেসে সর্বোচ্চ ২০টি সংযোগ — হিসাব আপনার হাতে
  1. Pool তৈরি। অ্যাপ শুরুর সময় একবার একটি pool বানানো হয়, পুরো অ্যাপ জুড়ে সেটিই ব্যবহার হয়। pg-র ডিফল্ট max ১০; এখানে বোঝার সুবিধার জন্য ৫। সংযোগ খোলা হয় প্রথম দরকারের সময়।
  2. প্রথম পাঁচজন। প্রতিটি অনুরোধ একটি সংযোগ নিল। প্রথমবার বলে প্রতিটির জন্য পুরো handshake আর login খরচ হলো — এই খরচটুকুই pool বাঁচাতে চায়।
  3. সারিতে অপেক্ষা। ষষ্ঠ অনুরোধ এসে দেখে সব সংযোগ ব্যস্ত। Pool নতুন সংযোগ খোলে না; অনুরোধটি একটি অপেক্ষার সারিতে বসে। এটি একটি Promise-এর অপেক্ষা — event loop (পর্ব ৩১) অন্য কাজ চালিয়ে যায়। চাইলে connectionTimeoutMillis দিয়ে অপেক্ষার সীমা বাঁধা যায়।
  4. পুনর্ব্যবহার। একটি query শেষ হতেই সংযোগ ফেরত গেল, আর সারির প্রথম জন সেটি সঙ্গে সঙ্গে পেল। Query সাধারণত কয়েক মিলিসেকেন্ডের, তাই ৫টি সংযোগও সেকেন্ডে শত শত অনুরোধ সামলায়।
  5. হিসাব আপনার হাতে। সার্ভার যতগুলো, pool-ও ততগুলো — মোট সংযোগ = সার্ভার × max. Serverless function-এ (প্রতিটি নিজের pool নিয়ে হাজারটি একসাথে) এই হিসাব ভেঙে পড়ে; তখন PgBouncer-এর মতো আলাদা pooler বা সেবাদাতার নিজস্ব pooling লাগে।

এবার আসল কোড। Pool একবার বানিয়ে একটি মডিউল থেকে export করুন (পর্ব ৪৫), আর সংযোগের ঠিকানা নিন environment variable থেকে:

// db.js — পুরো অ্যাপে একটিই pool (npm install pg)
import pg from "pg";

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,   // postgres://shop_app:…@db.example:5432/shop
  max: 10,                                      // এই সার্ভার থেকে সর্বোচ্চ ১০টি সংযোগ
  idleTimeoutMillis: 30_000,                    // ৩০ সেকেন্ড অলস থাকলে বন্ধ
  connectionTimeoutMillis: 5_000,               // ৫ সেকেন্ডে সংযোগ না পেলে error
});

pool.on("error", (err) => console.error("idle client error", err));   // পটভূমির error

// ব্যবহার: await pool.query(text, values) → { rows, rowCount, … }
const { rows } = await pool.query("SELECT now() AS t");
console.log(rows[0].t instanceof Date);
// প্রত্যাশিত আউটপুট: true

প্রতিটি query একটি network round-trip, তাই pool.query একটি Promise ফেরত দেয় — await করুন (পর্ব ৩৪)। DATABASE_URL-এর মতো connection string-এ পাসওয়ার্ড থাকে, তাই এটি কোডে নয়, environment-এ: ডেভেলপমেন্টে .env ফাইলে (যা .gitignore-এ থাকবেই), আর Node 20.6 থেকে node --env-file=.env server.js দিয়ে আলাদা package ছাড়াই পড়া যায়; প্রোডাকশনে hosting-এর secret সেটিংসে। MySQL-এ mysql2 (placeholder ?), SQLite-এ better-sqlite3 (synchronous API) — ধারণা একই।

৫ · Parameterized query — SQL injection ঠেকানোর একমাত্র নির্ভরযোগ্য পথ

ধরুন GET /api/products?category=grocery. সবচেয়ে “স্বাভাবিক” মনে হওয়া কোডটি হলো template literal দিয়ে SQL বানানো: `SELECT … WHERE category = '${req.query.category}'`. সমস্যা হলো, তখন ব্যবহারকারীর পাঠানো লেখা আর আপনার লেখা SQL একই string-এ মিশে যায় — আর ডেটাবেস জানে না কোন অংশ আপনার, কোন অংশ বাইরের। ইনপুটে একটি ' থাকলেই string শেষ হয়ে যায়, আর তার পরের অক্ষরগুলো SQL-এর নির্দেশ হিসেবে পড়া হয়। পর্ব ৪৩-এর XSS-এর মতোই রোগ: ডেটা কোড হয়ে গেছে। নিচের ধাপগুলোতে শুধু তৈরি হওয়া SQL-এর লেখা দেখুন — কোনো ডেটাবেস নেই, কোনো আক্রমণ নেই, পাঠ্যবইয়ের চিরচেনা উদাহরণটি মাত্র।

ধাপ ১লেখা জোড়া দেওয়া কোড
const sql = "SELECT id, name FROM customers WHERE phone = '" + phone + "'";
// phone আসছে ব্যবহারকারীর কাছ থেকে — req.body.phone
ধাপ ২সাধারণ ইনপুট
phone = 01711000000
SELECT id, name FROM customers WHERE phone = '01711000000'
// ঠিকঠাক — একটি string, একটি শর্ত
ধাপ ৩উদ্ধৃতি-চিহ্নসহ ইনপুট — শুধু লেখাটি দেখুন
phone = x' OR '1'='1
SELECT id, name FROM customers WHERE phone = 'x' OR '1'='1'
// প্রথম ' string বন্ধ করল; OR এখন SQL-এর শব্দ
// '1'='1' সবসময় সত্য → শর্তটি সব সারির জন্য সত্য
ধাপ ৪Parameterized query
pool.query("SELECT id, name FROM customers WHERE phone = $1", [phone])
→ ডেটাবেসে যায় দুটি আলাদা জিনিস:
  লেখা:  SELECT … WHERE phone = $1      ← আগে parse হয়, কাঠামো স্থির
  মান:   [ "x' OR '1'='1" ]             ← পরে শুধু মান হিসেবে বসে
ধাপ ৫ফলাফল
ডেটাবেস খোঁজে: phone কলামের মান হুবহু "x' OR '1'='1" — এমন নম্বর কারও নেই
→ 0টি সারি; WHERE-এর কাঠামো বদলায়নি
// ইনপুট যা-ই হোক, তা কেবলই একটি "ফোন নম্বর"
  1. জোড়া দেওয়া কোড। SQL-এর লেখা আর ইনপুট একসাথে একটি string. Template literal দিলেও (`…'${phone}'`) একই কথা — template literal শুধু জোড়া দেওয়ার সুন্দর রূপ, নিরাপত্তা নয়।
  2. সাধারণ ইনপুটে সব ঠিক। এজন্যই বাগটি ডেমো আর সাধারণ টেস্টে ধরা পড়ে না — কেউ তো নিজের ফোন নম্বরে উদ্ধৃতি-চিহ্ন লেখেন না।
  3. লেখার কাঠামোই বদলে গেল। একটি ' string-কে আগেই বন্ধ করল; পরের OR '1'='1' এখন আর ডেটা নয়, শর্তের অংশ। শর্তটি সব সারির জন্য সত্য। বাস্তবে এর ফল হয়েছে অন্যের তথ্য দেখা থেকে পুরো টেবিল ফাঁস পর্যন্ত (ভাবনার প্রশ্ন ৩)।
  4. লেখা আর মান আলাদা। Parameterized query-তে driver SQL-এর লেখা (placeholder-সহ) আর মানের তালিকা আলাদাভাবে পাঠায়। PostgreSQL-এর protocol-এ আগে লেখাটি parse হয়, তারপর মান “bind” হয় — মান কখনো SQL parser-এর কাছে যায়ই না।
  5. মান মানই থাকে। ইনপুটের প্রতিটি অক্ষর — উদ্ধৃতি-চিহ্নসহ — শুধু তুলনার মান। হাতে escape করার চেয়ে এটি অনেক নির্ভরযোগ্য — escape-এর নিয়ম ডেটাবেসভেদে বদলায়, আর এক জায়গায় ভুললেই যথেষ্ট।

নিচের প্রোগ্রামটি একই জিনিস কোডে দেখায় — কোনো ডেটাবেস নয়, শুধু তৈরি হওয়া SQL-এর লেখা আর parameterized query-র object (pg ঠিক এই { text, values } আকারের object নেয়):

// ❌ লেখা জোড়া দিয়ে query — শুধু SQL লেখাটি দেখছি, কোনো ডেটাবেস নেই
const unsafeQuery = (phone) =>
  "SELECT id, name FROM customers WHERE phone = '" + phone + "'";

// ✅ parameterized query — SQL-এর লেখা আর মান আলাদা থাকে
const safeQuery = (phone) => ({
  text: "SELECT id, name FROM customers WHERE phone = $1",
  values: [phone],
});

for (const input of ["01711000000", "x' OR '1'='1"]) {
  console.log("ইনপুট:", input);
  console.log("  জোড়া:", unsafeQuery(input));
  console.log("  আলাদা:", JSON.stringify(safeQuery(input)));
}

// সার্ভার $1-কে শুধু একটি মান হিসেবে তুলনা করে — পুরো লেখাটিই একটি "ফোন নম্বর"
const rows = [{ id: 1, phone: "01711000000" }, { id: 2, phone: "01811000000" }];
const q = safeQuery("x' OR '1'='1");
console.log("parameterized-এ মিলল:", rows.filter((r) => r.phone === q.values[0]).length);
const where = unsafeQuery("x' OR '1'='1").split("WHERE ")[1];
console.log("জোড়া দেওয়া WHERE এখন:", where, "→ শর্ত", where.split(" OR ").length, "টি");

প্রথম ইনপুটে দুই পথের ফল একই অর্থের: WHERE phone = '01711000000'. দ্বিতীয় ইনপুটে জোড়া দেওয়া লেখা হলো SELECT id, name FROM customers WHERE phone = 'x' OR '1'='1' — উদ্ধৃতি-চিহ্নগুলো এখন SQL-এর অংশ, আর শেষ লাইন দেখাচ্ছে জোড়া দেওয়া WHERE এখন: phone = 'x' OR '1'='1' → শর্ত 2 টি: আপনি লিখেছিলেন একটি শর্ত, ইনপুট বানিয়ে দিল দুটি। Parameterized রূপে text দুই ক্ষেত্রেই হুবহু এক — {"text":"SELECT id, name FROM customers WHERE phone = $1","values":["x' OR '1'='1"]} — বদলেছে শুধু values. ছোট্ট “টেবিলে” সেই মান খুঁজে পাওয়া গেল parameterized-এ মিলল: 0টি সারিতে, ঠিক যেমন হওয়া উচিত।

এবার আসল route. pg-তে placeholder $1, $2 …; একটি জিনিস placeholder দিয়ে হয় না — টেবিল বা কলামের নাম (যেমন ORDER BY-এর কলাম)। সেখানে একটি অনুমোদিত তালিকা (allowlist) থেকে বেছে নিন, ইনপুট সরাসরি নয়:

// GET /api/products?category=grocery&sort=price
const SORTS = { price: "price_paisa", title: "title", newest: "id DESC" };   // allowlist

app.get("/api/products", async (req, res, next) => {
  try {
    const category = String(req.query.category ?? "");
    const orderBy = SORTS[req.query.sort] ?? "id";          // অজানা মান → ডিফল্ট
    const { rows } = await pool.query(
      `SELECT id, title, price_paisa, stock FROM products
       WHERE category = $1 ORDER BY ${orderBy} LIMIT 50`,     // ${} শুধু allowlist-এর মান
      [category]                                             // ইনপুট parameter হিসেবে
    );
    res.json(rows);
  } catch (err) { next(err); }
});
// প্রত্যাশিত আউটপুট (GET /api/products?category=grocery&sort=price):
// [{"id":"2","title":"ডাল ১ কেজি","price_paisa":14000,"stock":1}, {"id":"1","title":"চাল ৫ কেজি",…}]
// id কেন string? bigint কলাম — pg সেটি string হিসেবে দেয় (অংশ ৩)

Parameterized query-ও ভুলভাবে লেখা যায়: pool.query(`… WHERE phone = $1 AND name = '${name}'`, [phone]) — একটি parameter, একটি জোড়া। কোড রিভিউতে নিয়মটি সরল: SQL-এর লেখায় ${…} বা + দেখলেই থামুন; সেখানে কি শুধুই আপনার নিজের স্থির allowlist-এর মান বসছে? সাথে ডেটাবেসের ব্যবহারকারীকে ন্যূনতম অনুমতি দিন (অংশ ৯), যাতে কোনো ভুল থেকে গেলেও ক্ষতি সীমিত থাকে।

৬ · Transaction — অর্ডার হয় পুরোটা, নয়তো একটুও না

একটি অর্ডার নেওয়া মানে কয়েকটি লেখা: orders-এ একটি সারি, order_items-এ প্রতিটি পণ্যের সারি, আর products-এ স্টক কমানো। মাঝপথে কিছু ব্যর্থ হলে — দ্বিতীয় পণ্যের স্টক শেষ, সার্ভার crash, নেটওয়ার্ক বিচ্ছিন্ন — কী থাকবে? Transaction ছাড়া: একটি অর্ডার যার অর্ধেক আইটেম আছে, আর একটি পণ্যের স্টক কমেছে অথচ বিক্রি হয়নি। Transaction-এর প্রতিশ্রুতি চারটি, একসাথে ACID: Atomicity (সব নয়তো কিছুই না), Consistency (constraint কখনো ভাঙে না), Isolation (একসাথে চলা transaction একে অপরের অর্ধেক কাজ দেখে না), Durability (COMMIT সফল মানে বিদ্যুৎ গেলেও ডেটা থাকবে)।

1await client.query("BEGIN");2const o = await client.query("INSERT INTO orders … RETURNING id");3for (const it of cart) {4  await client.query("INSERT INTO order_items …");5  const r = await client.query("UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1", …);6  if (r.rowCount === 0) throw new OutOfStockError(it.productId);7}8await client.query("COMMIT");9} catch (e) { await client.query("ROLLBACK"); throw e; }
ডেটাবেসের অবস্থা (এই transaction-এর চোখে)
orders
order_items
চালের stock
ডালের stock
অন্যরা দেখে
  1. BEGIN. Pool থেকে একটি নির্দিষ্ট সংযোগ (client) নিয়ে transaction শুরু। কার্টে চাল ১টি আর ডাল ১টি; স্টকে চাল ১, ডাল ০ — কিন্তু কোড এখনো তা জানে না।
  2. অর্ডারের সারি। orders-এ সারি ঢুকল, RETURNING id নতুন id ফেরত দিল। এই সারি এখনো “অস্থায়ী” — শুধু এই transaction দেখে, অন্য কোনো সংযোগ দেখে না।
  3. প্রথম আইটেম। চালের order_items সারি ঢুকল।
  4. চালের স্টক কমল। WHERE … AND stock >= $1 শর্তসহ UPDATE — স্টক যাচাই আর কমানো একটি বাক্যে, তাই দুটি একসাথে আসা অনুরোধ শেষ চালটি দুজনকে বিক্রি করতে পারে না। একটি সারি বদলেছে (rowCount ১), স্টক ১ → ০।
  5. দ্বিতীয় আইটেম। ডালের order_items সারি ঢুকল। এখন ডেটাবেসে একটি অর্ডার, দুটি আইটেম — কিন্তু সবই অস্থায়ী।
  6. ডালের স্টক কমানো ব্যর্থ। ডালের স্টক ০, তাই stock >= 1 মেলেনি — কোনো সারি বদলায়নি, rowCount ০। এটিই সেই “মাঝপথের ব্যর্থতা”.
  7. throw. কোড OutOfStockError ছুড়ল (পর্ব ২৯-এর custom error). লুপ থেমে গেল; COMMIT-এ পৌঁছানো হলো না।
  8. ROLLBACK. catch-এ ROLLBACK — অর্ডার, দুটি আইটেম, চালের স্টক কমানো, সব একসাথে বাতিল। ডেটাবেস ঠিক আগের মতো; বাইরের কেউ কখনো অর্ধেক লেখা দেখেনি। Express-এর error handler error-টিকে 409 উত্তরে রূপ দিল।

pg-তে transaction-এর সবচেয়ে জরুরি নিয়ম: সব query একই client-এ. pool.query প্রতিবার pool থেকে যেকোনো একটি সংযোগ নেয় — BEGIN এক সংযোগে, INSERT আরেকটিতে গেলে transaction-এর কোনো মানেই থাকে না। আর finally-তে release() ভুললে সংযোগটি আর pool-এ ফেরে না; কয়েকবার ভুল হলেই pool ফাঁকা, আর পুরো অ্যাপ অপেক্ষার সারিতে আটকে যায় (connection leak). পুরো ধাঁচটি:

// POST /api/orders  body: { customerId, items: [{ productId, qty }] }
export async function placeOrder(customerId, items) {
  const client = await pool.connect();               // pool থেকে একটি নির্দিষ্ট সংযোগ
  try {
    await client.query("BEGIN");
    const { rows: [order] } = await client.query(
      "INSERT INTO orders (customer_id, total_paisa) VALUES ($1, 0) RETURNING id", [customerId]);
    let total = 0;
    for (const { productId, qty } of items) {
      const r = await client.query(
        `UPDATE products SET stock = stock - $1
         WHERE id = $2 AND stock >= $1 RETURNING price_paisa`, [qty, productId]);
      if (r.rowCount === 0) throw new OutOfStockError(productId);   // → ROLLBACK
      const price = r.rows[0].price_paisa;
      await client.query(
        "INSERT INTO order_items (order_id, product_id, qty, unit_paisa) VALUES ($1, $2, $3, $4)",
        [order.id, productId, qty, price]);
      total += price * qty;
    }
    await client.query("UPDATE orders SET total_paisa = $1 WHERE id = $2", [total, order.id]);
    await client.query("COMMIT");
    return { id: order.id, totalPaisa: total };
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;                                         // route-এর error handler 409/500 ঠিক করবে
  } finally {
    client.release();                                  // সংযোগ pool-এ ফেরত — সবসময়
  }
}
// প্রত্যাশিত আউটপুট: সফল হলে { id: '1', totalPaisa: 98000 }; স্টক কম হলে OutOfStockError, ডেটাবেস অপরিবর্তিত

খেয়াল করুন দাম নেওয়া হচ্ছে ডেটাবেস থেকে (RETURNING price_paisa), ক্রেতার পাঠানো body থেকে নয় — ব্রাউজারের পাঠানো দাম বিশ্বাস করা পর্ব ৩৯-এর parameter tampering-এর দরজা খুলে দেয়। এবার একই ধারণা মেমোরিতে চালিয়ে দেখুন: নিচে একটি ছোট্ট “ডেটাবেস”, যার begin একটি কপি রাখে, rollback সেই কপি ফিরিয়ে আনে:

const db = {                                   // মেমোরিতে তিনটি "টেবিল"; দাম পয়সায়
  products: new Map([[1, { title: "চাল ৫ কেজি", price: 42000, stock: 3 }],
                     [2, { title: "ডাল ১ কেজি", price: 14000, stock: 1 }]]), orders: [], orderItems: [] };
let backup = null;                             // আসল ডেটাবেস undo log বা MVCC দিয়ে এই কাজ করে
const begin = () => { backup = structuredClone(db); };
const commit = () => { backup = null; };
const rollback = () => { Object.assign(db, backup); backup = null; };

function placeOrder(customerId, cart) {
  begin();
  try {
    const order = { id: db.orders.length + 1, customerId, totalPaisa: 0 }; db.orders.push(order);
    for (const { productId, qty } of cart) {
      const p = db.products.get(productId);
      db.orderItems.push({ orderId: order.id, productId, qty, unitPaisa: p.price });
      if (p.stock < qty) throw new Error(`স্টক কম: ${p.title} (আছে ${p.stock}, চাই ${qty})`);
      p.stock -= qty; order.totalPaisa += p.price * qty;
    }
    commit();
    return `COMMIT — অর্ডার #${order.id}, মোট ${order.totalPaisa / 100} টাকা`;
  } catch (e) { rollback(); return `ROLLBACK — ${e.message}`; }
}
const state = () => `স্টক=[${[...db.products.values()].map((p) => p.stock)}] orders=${db.orders.length} items=${db.orderItems.length}`;
console.log(placeOrder(7, [{ productId: 1, qty: 2 }, { productId: 2, qty: 1 }]), "|", state());
console.log(placeOrder(8, [{ productId: 1, qty: 1 }, { productId: 2, qty: 1 }]), "|", state());

প্রথম অর্ডার: COMMIT — অর্ডার #1, মোট 980 টাকা | স্টক=[1,0] orders=1 items=2 — দুটি চাল (৮৪০ টাকা) আর একটি ডাল (১৪০ টাকা), হিসাব পয়সায় হয়েছে বলে কোনো দশমিকের গরমিল নেই। দ্বিতীয় অর্ডারে চালের স্টক ১ → ০ হয়ে গিয়েছিল, দ্বিতীয় অর্ডারের সারি আর দুটি আইটেমও ঢুকেছিল; তারপর ডালে এসে ব্যর্থ: ROLLBACK — স্টক কম: ডাল ১ কেজি (আছে 0, চাই 1) | স্টক=[1,0] orders=1 items=2. অবস্থা হুবহু প্রথম অর্ডারের পরের মতো — চালের স্টক আবার ১, অর্ধেক লেখা অর্ডার নেই। আসল ডেটাবেস পুরো কপি রাখে না; PostgreSQL প্রতিটি সারির নতুন version লেখে আর commit না হওয়া version অন্যদের দেখায় না (MVCC), তাই rollback প্রায় বিনা খরচে হয়।

Isolation level — এক অনুচ্ছেদে

একসাথে চলা transaction কতটা আলাদা থাকবে, তা ঠিক করে isolation level. SQL মানদণ্ডে চারটি: Read Uncommitted, Read Committed (PostgreSQL-এর ডিফল্ট — প্রতিটি query শুধু commit হওয়া ডেটা দেখে), Repeatable Read (MySQL-এর InnoDB-র ডিফল্ট — পুরো transaction একই snapshot দেখে), আর Serializable (যেন transaction-গুলো একটির পর একটি চলেছে; ডেটাবেস কখনো একটিকে serialization error দিয়ে বাতিল করে, তখন আবার চেষ্টা)। যত কড়া, তত নিরাপদ, কিন্তু তত বেশি অপেক্ষা বা retry. বেশিরভাগ অ্যাপে Read Committed আর ওপরের মতো শর্তসহ UPDATE … WHERE stock >= $1 (বা দরকারে SELECT … FOR UPDATE দিয়ে সারি তালাবদ্ধ করা) যথেষ্ট। পর্ব ৩৬-এর race condition এখানে ডেটাবেসের স্তরে: “আগে পড়ি, তারপর Node-এ যাচাই করে লিখি” — এই দুই ধাপের ফাঁকে অন্য অনুরোধ ঢুকে পড়তে পারে।

৭ · গতি — index, EXPLAIN আর N+1 সমস্যা

Index — বইয়ের পেছনের সূচি

Index ছাড়া WHERE phone = '01711000000' খুঁজতে ডেটাবেসকে প্রতিটি সারি দেখতে হয় (sequential scan) — দশ লাখ সারিতে দশ লাখবার তুলনা। Index হলো আলাদা একটি সাজানো কাঠামো, সাধারণত B-tree: একটি চওড়া, কম উচ্চতার গাছ, যার প্রতিটি নোডে অনেকগুলো key সাজানো থাকে। দশ লাখ সারিতেও গাছের উচ্চতা মাত্র তিন-চার স্তর, তাই কয়েকটি disk page পড়েই ঠিক সারিতে পৌঁছানো যায় — সাজানো তালিকায় binary search-এর ভাবনা (প্রতি ধাপে বাকি অংশের বেশিরভাগ বাদ), disk-এর জন্য সাজানো। পর্ব ৪০-এ IndexedDB-র index একই কাজ করেছিল।

CREATE INDEX products_category_idx ON products (category);
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at DESC);

EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 10;
-- প্রত্যাশিত আউটপুট (সংক্ষেপে; সংখ্যা ডেটাভেদে বদলাবে):
-- Limit  (actual time=0.03..0.05 rows=10)
--   ->  Index Scan using orders_customer_created_idx on orders  (rows=10)
--         Index Cond: (customer_id = 42)
-- index না থাকলে এখানে দেখা যেত: Seq Scan on orders … Filter: (customer_id = 42)

কখন index দেবেন? যে কলাম প্রায়ই WHERE, JOIN বা ORDER BY-এ আসে। Primary key আর UNIQUE কলামে PostgreSQL নিজেই index বানায়, কিন্তু foreign key কলামে নিজে থেকে বানায় না — orders.customer_id-এর index আপনাকে দিতে হবে (MySQL-এর InnoDB foreign key-তে index বাধ্যতামূলক করে)। কিন্তু index বিনামূল্যে নয়: প্রতিটি INSERT/UPDATE-এ সব index-ও আপডেট হয়, আর জায়গা লাগে। তাই অনুমানে নয়, প্রমাণে: ধীর query-তে EXPLAIN চালান (EXPLAIN ANALYZE query আসলেই চালিয়ে সময় দেখায়), “Seq Scan” কোথায় তা দেখুন, তারপর index দিন। কয়েকটি কলামের যৌথ index-এ কলামের ক্রম জরুরি: (customer_id, created_at) “এই ক্রেতার সাম্প্রতিক অর্ডার”-এ কাজে লাগে, শুধু created_at দিয়ে খোঁজায় তেমন নয়।

N+1 — লুপের ভেতরে লুকানো query

অ্যাডমিন পাতায় “সাম্প্রতিক ২০টি অর্ডার, ক্রেতার নামসহ” দেখাতে হবে। প্রথম যে কোডটি মাথায় আসে:

// ❌ N+1: ১টি query অর্ডারের জন্য, তারপর প্রতিটি অর্ডারের জন্য আরেকটি
const { rows: orders } = await pool.query(
  "SELECT id, customer_id, total_paisa FROM orders ORDER BY id DESC LIMIT 20");
for (const o of orders) {
  const { rows: [c] } = await pool.query("SELECT name FROM customers WHERE id = $1", [o.customer_id]);
  o.customerName = c.name;                    // ২০টি অর্ডার → মোট ২১টি round-trip
}

// ✅ সমাধান ১ — JOIN: ১টি query
const { rows } = await pool.query(
  `SELECT o.id, o.total_paisa, c.name AS customer_name
   FROM orders o JOIN customers c ON c.id = o.customer_id
   ORDER BY o.id DESC LIMIT 20`);

// ✅ সমাধান ২ — IN/ANY: ২টি query, যত অর্ডারই হোক
const ids = [...new Set(orders.map((o) => o.customer_id))];
const { rows: customers } = await pool.query(
  "SELECT id, name FROM customers WHERE id = ANY($1)", [ids]);   // pg array → $1
const byId = new Map(customers.map((c) => [c.id, c.name]));
Query ১২০টি অর্ডার আনো
Query ২: ক্রেতা #42~১ ms round-trip
Query ৩: ক্রেতা #17
… Query ২১: ক্রেতা #42 (আবার!)মোট ২১টি
JOIN: ১টি queryডেটাবেস নিজে মেলায়
ANY($1): ২টি queryORM-এর include, DataLoader
  1. “১”. একটি query-তে ২০টি অর্ডার এল। এখানে কোনো সমস্যা নেই।
  2. “+N”. লুপের প্রতিটি ধাপে আরেকটি query — ২০টি অর্ডারে মোট ২১টি round-trip, একই ক্রেতার জন্য বারবারও। নিজের ল্যাপটপে টের পান না; প্রোডাকশনে প্রতিটি round-trip হয়তো ১–২ ms, ৫০০টি সারিতে আধা সেকেন্ডের বেশি।
  3. JOIN. ডেটাবেসকে বলুন দুই টেবিল মিলিয়ে দিতে — একটি query, একটি round-trip, আর ডেটাবেস index ধরে মেলায়। ডেটার কাছে কাজ নিয়ে যাওয়া, কাজের কাছে ডেটা টেনে আনা নয়।
  4. IN / ANY. সব আলাদা customer id একসাথে পাঠিয়ে একটি query (WHERE id = ANY($1), অন্য ডেটাবেসে IN (…)) — অর্ডার যত বেশিই হোক, মোট দুটি query. ORM-এর include সাধারণত এভাবেই কাজ করে। GraphQL-এর জগতে DataLoader একই event loop tick-এ আসা আলাদা আলাদা “ক্রেতা #42 দাও” অনুরোধ জড়ো করে একটি batch query বানায় (পর্ব ৩৬-এর batching-এর ভাবনা)।

N+1 ধরার উপায়: ডেভেলপমেন্টে প্রতিটি query log করুন (ORM-এ সাধারণত একটি setting), আর একটি পাতা খুললে কয়টি query হলো তা গুনুন। সংখ্যাটি ডেটার পরিমাণের সাথে বাড়লে — N+1. বারবার পড়া হয় কিন্তু কম বদলায় এমন ডেটার (যেমন ক্যাটাগরির তালিকা) জন্য এর ওপরে Redis-এর মতো cache বসানো যায়: আগে cache দেখো, না পেলে ডেটাবেস থেকে এনে কিছুক্ষণের TTL-সহ রাখো (cache-aside). তবে প্রথমে index আর ঠিক query, তারপর cache.

৮ · Migration, ORM আর MongoDB

Migration — schema-র version control

অ্যাপ বড় হয়, schema বদলায়: customers-এ email কলাম লাগবে, products-এ নতুন index. প্রোডাকশন ডেটাবেসে হাতে ALTER TABLE চালানো মানে কেউ জানে না কোন সার্ভারে কোন বদল হয়েছে। সমাধান migration: প্রতিটি বদল একটি সংখ্যা-দেওয়া ফাইল (0007_add_customer_email.sql), git-এ থাকে, আর একটি টুল ডেটাবেসের ভেতরে একটি টেবিলে লিখে রাখে কোনগুলো চলেছে — নতুনগুলো ক্রমানুসারে চালায়। পর্ব ৪০-এ IndexedDB-র version আর upgradeneeded-এ একই ভাবনা দেখেছেন। Node-এ প্রচলিত টুল: node-pg-migrate (SQL বা JavaScript-এ migration), Prisma Migrate (schema ফাইল থেকে SQL বানায়), Drizzle Kit. পেশাদার অভ্যাস: চলমান অ্যাপে বদল দুই ধাপে — আগে নতুন কলাম যোগ, কোড দুটোই সামলাক, তারপর পুরোনো কলাম সরান; হঠাৎ কলামের নাম বদল চলমান সার্ভার ভেঙে দেয়।

ORM আর query builder — সুবিধা আর তার দাম

ORM (Object-Relational Mapper) টেবিলকে class বা model হিসেবে দেখায়, আর query লেখে আপনার হয়ে; query builder SQL-এর কাছাকাছি থাকে, শুধু string-এর বদলে function-এর chain দিয়ে query বানায়। দুটোই ডিফল্টে parameterized query পাঠায়।

টুলধরনবৈশিষ্ট্য
PrismaORM (নিজস্ব schema ভাষা)schema.prisma ফাইল থেকে TypeScript টাইপসহ client তৈরি; Prisma Migrate; শেখা সহজ, জটিল SQL-এ মাঝে মাঝে raw query লাগে
DrizzleTypeScript-প্রথম ORM / query builderschema TypeScript-এ, query দেখতে প্রায় SQL; হালকা, serverless-বান্ধব; Drizzle Kit দিয়ে migration
Knexquery builderপুরোনো ও স্থিতিশীল; knex("products").where({ category }); নিজস্ব migration ব্যবস্থা
SequelizeORM (Active Record ধাঁচ)Node-এর পুরোনো জনপ্রিয় ORM; অনেক পুরোনো প্রকল্পে দেখবেন
TypeORMORM (decorator-ভিত্তিক)Java-র JPA/Hibernate-এর মতো ধাঁচ; NestJS-এ প্রচলিত
// schema.prisma (অংশ):
//   model Order { id Int @id @default(autoincrement())  customer Customer @relation(fields: [customerId], references: [id])
//                 customerId Int  totalPaisa Int  createdAt DateTime @default(now()) }
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();

// N+1 ছাড়াই অর্ডার + ক্রেতা (include → ORM নিজে batch query করে)
const orders = await prisma.order.findMany({
  take: 20, orderBy: { id: "desc" }, include: { customer: { select: { name: true } } },
});

// transaction: হয় দুটোই, নয়তো কোনোটিই না
await prisma.$transaction(async (tx) => {
  const { count } = await tx.product.updateMany({
    where: { id: 2, stock: { gte: 1 } }, data: { stock: { decrement: 1 } } });
  if (count === 0) throw new Error("স্টক নেই");       // throw → Prisma ROLLBACK করে
  const p = await tx.product.findUniqueOrThrow({ where: { id: 2 } });
  await tx.order.create({ data: { customerId: 7, totalPaisa: p.pricePaisa } });
});
// প্রত্যাশিত আউটপুট: orders[0] → { id: 20, customerId: 7, totalPaisa: 98000, …, customer: { name: 'রহিমা' } }

ORM-এর দাম কোথায়? ORM SQL লুকায়, কিন্তু ডেটাবেস তো SQL-ই চালায় — তাই সমস্যা হলে আপনাকে নিচের স্তরে নামতে হয়। একে বলে leaky abstraction. orders.forEach(o => o.customer)-এর মতো নিরীহ লাইন lazy loading-এ N+1 হয়ে যেতে পারে; জটিল রিপোর্ট ORM-এ লিখতে গেলে কোড SQL-এর চেয়ে কঠিন হয়। তাই ভারসাম্য: সাধারণ CRUD-এ ORM-এর উৎপাদনশীলতা আর টাইপ-নিরাপত্তা, জটিল রিপোর্টে parameterized raw SQL (prisma.$queryRaw`… ${x}`-এর মতো tagged template নিরাপদ, কারণ এটি মানগুলোকে parameter বানায়; কিন্তু $queryRawUnsafe-এ জোড়া দেওয়া লেখা আবার injection). আর ORM ব্যবহার করলেও development-এ তার তৈরি SQL log করে দেখুন।

MongoDB — document, embedding আর referencing

MongoDB-তে টেবিলের বদলে collection, সারির বদলে document (BSON — JSON-এর মতো, তবে তারিখ, decimal আর binary ধরনসহ)। নকশার মূল প্রশ্ন: সম্পর্কিত ডেটা document-এর ভেতরে রাখবেন (embedding), নাকি আলাদা collection-এ রেখে id দিয়ে নির্দেশ করবেন (referencing)? নিয়মটি “একসাথে যা পড়া হয়, তা একসাথে রাখুন”: অর্ডারের আইটেম আর ডেলিভারি ঠিকানা অর্ডারের ভেতরেই — অর্ডার দেখলে সবই লাগে, আর এক document-এর ভেতরের বদল সবসময় atomic. কিন্তু ক্রেতা আলাদা collection-এ — তিনি অনেক অর্ডারে আছেন, আর তাঁর নাম বদলালে হাজার document বদলাতে চাইবেন না। আর যা সীমাহীন বাড়ে (একটি পণ্যের সব রিভিউ), তা embed করবেন না — একটি document-এর সর্বোচ্চ আকার ১৬ MB.

import { MongoClient } from "mongodb";            // official driver (npm install mongodb)
const client = new MongoClient(process.env.MONGODB_URI);   // client নিজেই একটি pool
const orders = client.db("shop").collection("orders");

await orders.insertOne({
  customerId: 7,                                          // referencing: ক্রেতা আলাদা collection-এ
  items: [{ productId: 1, title: "চাল ৫ কেজি", qty: 2, unitPaisa: 42000 }],   // embedding
  totalPaisa: 84000, createdAt: new Date(),
});
const list = await orders.find({ customerId: 7 }).sort({ createdAt: -1 }).limit(10).toArray();
console.log(list.length, list[0].items[0].title);
// প্রত্যাশিত আউটপুট: 1 চাল ৫ কেজি

// ⚠️ NoSQL-এও injection আছে: find({ phone: req.body.phone }) — body-তে যদি
// { "phone": { "$ne": null } } আসে, শর্তটি "phone ফাঁকা নয়" হয়ে যায়। ধরন যাচাই করুন: String(…)

Mongoose একটি জনপ্রিয় ODM — MongoDB-র ওপরে schema, যাচাই আর model যোগ করে (mongoose.model("Order", orderSchema)). MongoDB 4.0 (২০১৮) থেকে replica set-এ একাধিক document জুড়ে transaction-ও চলে, তবে নকশা এমন রাখাই ভালো যাতে বেশিরভাগ কাজ এক document-এ সারা যায়।

৯ · Express অ্যাপে ডেটাবেস — repository, 409, secret, backup আর টেস্ট

পর্ব ৫০-এর API-তে ডেটাবেস বসানোর পরিচ্ছন্ন পথ হলো একটি repository স্তর: route handler HTTP বোঝে (request, status code), repository ডেটাবেস বোঝে (SQL), আর দুটি আলাদা ফাইলে। তাহলে route-এ SQL ছড়িয়ে থাকে না, টেস্টে repository-কে নকল দিয়ে বদলানো যায়, আর কাল PostgreSQL থেকে অন্য কিছুতে গেলে বদলাতে হয় এক জায়গায়।

// repositories/customers.js — শুধু ডেটাবেসের কাজ
export class DuplicatePhoneError extends Error {}

export async function createCustomer({ name, phone }) {
  try {
    const { rows: [c] } = await pool.query(
      "INSERT INTO customers (name, phone) VALUES ($1, $2) RETURNING id, name, phone", [name, phone]);
    return c;
  } catch (err) {
    if (err.code === "23505") throw new DuplicatePhoneError(phone, { cause: err });  // unique_violation
    throw err;
  }
}

// routes/customers.js — শুধু HTTP-র কাজ
app.post("/api/customers", async (req, res, next) => {
  try {
    res.status(201).json(await createCustomer(req.body));
  } catch (err) {
    if (err instanceof DuplicatePhoneError)
      return res.status(409).json({ error: "এই ফোন নম্বরে আগেই অ্যাকাউন্ট আছে" });
    next(err);                                     // বাকি সব → 500, বিস্তারিত শুধু log-এ
  }
});
// প্রত্যাশিত আউটপুট: প্রথমবার 201 {"id":"1","name":"রহিমা","phone":"01711000000"}
//                   একই নম্বরে আবার 409 {"error":"এই ফোন নম্বরে আগেই অ্যাকাউন্ট আছে"}

লক্ষ করুন, কোডটি আগে “এই নম্বর আছে কি?” খুঁজে তারপর INSERT করে না। সেভাবে করলে দুটি একসাথে আসা অনুরোধ দুজনেই “নেই” দেখে দুজনেই ঢুকিয়ে দিতে পারে — race condition. এখানে সিদ্ধান্ত নেয় ডেটাবেসের UNIQUE constraint, যা দুই অনুরোধ একসাথে এলেও একটিকেই ঢুকতে দেয়; অন্যটি PostgreSQL-এর error code 23505 (unique_violation) পায়, যাকে আমরা 409 Conflict-এ রূপ দিই। আর ডেটাবেসের আসল error message (টেবিল, constraint-এর নাম) ব্যবহারকারীকে পাঠাবেন না — শুধু log-এ; বাইরে যায় সংক্ষিপ্ত বার্তা।

নিরাপত্তা আর রক্ষণাবেক্ষণের চেকলিস্ট

  • Secret কোডে নয়: DATABASE_URL environment-এ; .env কখনো git-এ নয় (ভুলে commit হলে পাসওয়ার্ড বদলান — git-এর ইতিহাসে সেটি থেকে যায়)। প্রোডাকশনে ডেটাবেস সংযোগে TLS চালু রাখুন।
  • ন্যূনতম অনুমতি (least privilege): অ্যাপ সংযোগ করে shop_app-এর মতো একটি ব্যবহারকারী দিয়ে, যার শুধু দরকারি টেবিলে SELECT/INSERT/UPDATE/DELETE আছে — superuser বা টেবিল-মোছার অনুমতি নয়। Migration চালায় আলাদা ব্যবহারকারী। ডেটাবেস ইন্টারনেটে খোলা নয়, শুধু অ্যাপ সার্ভার থেকে পৌঁছানো যায়।
  • Backup — আর restore-এর মহড়া: নিয়মিত স্বয়ংক্রিয় backup (pg_dump বা সেবাদাতার snapshot), অন্য জায়গায় কপি, আর মাঝে মাঝে সত্যিই restore করে দেখা — যে backup কখনো restore হয়নি, সেটি শুধু একটি আশা।
  • টেস্টে আসল ডেটাবেস: পর্ব ৪৮-এর ভাবনায় — repository-র টেস্ট চলুক একটি আলাদা টেস্ট ডেটাবেসে (Docker-এ PostgreSQL, বা Testcontainers-এর মতো টুল), প্রতিটি টেস্টের আগে migration আর পরিষ্কার ডেটা, দরকারে প্রতিটি টেস্ট একটি transaction-এ চালিয়ে শেষে rollback. নকল ডেটাবেস (mock) SQL-এর ভুল বা constraint ধরতে পারে না।
C/C++/Python/Java জানলে

ধারণাগুলো সব ভাষায় এক — বদলায় শুধু API আর একটি বড় পার্থক্য: Python-এর sqlite3 আর Java-র JDBC সাধারণত blocking, প্রতিটি query থ্রেড থামিয়ে অপেক্ষা করে; Node-এ query একটি Promise, আর একটিমাত্র থ্রেড অপেক্ষার সময় অন্য অনুরোধ সামলায় (better-sqlite3 এর ব্যতিক্রম — ইচ্ছা করেই synchronous). Python-এর ? placeholder আর with conn: transaction দেখুন Python-এর SQLite পর্বে, ORM আর N+1 SQLAlchemy পর্বে; Java-র PreparedStatement, setAutoCommit(false) আর connection pool JDBC পর্বে, আর JPA/Hibernate (TypeORM-এর পূর্বসূরি ধাঁচ) Spring Data JPA পর্বে. C-তে SQLite-এর নিজস্ব C API (sqlite3_prepare_v2, sqlite3_bind_text) — নাম দেখেই বুঝবেন parameter binding-এর ধারণা সেখান থেকেই এসেছে।

পরীক্ষা ও ইন্টারভিউতে
  • SQL injection কীভাবে ঠেকাবেন? — Parameterized query / prepared statement: SQL-এর লেখা আর মান আলাদা পাঠানো; নাম (কলাম, টেবিল) লাগলে allowlist; সাথে ন্যূনতম অনুমতির ডেটাবেস ব্যবহারকারী।
  • ACID কী? — Atomicity (সব নয়তো কিছুই না), Consistency (নিয়ম অক্ষুণ্ণ), Isolation (একসাথে চলা transaction একে অপরের অর্ধেক কাজ দেখে না), Durability (commit মানে স্থায়ী)।
  • N+1 সমস্যা কী, সমাধান? — একটি তালিকার জন্য ১টি query, তারপর প্রতিটি সারির জন্য আরেকটি; সমাধান JOIN, IN/ANY দিয়ে batch, ORM-এর eager loading বা DataLoader.
  • Connection pool কেন? — নতুন সংযোগ খোলা ধীর আর ডেটাবেসের সংযোগ-সংখ্যা সীমিত; pool সংযোগ পুনর্ব্যবহার করে আর সর্বোচ্চ সংখ্যা বেঁধে দেয়।

১০ · AI-এর যুগে — AI-এর লেখা ডেটাবেস-কোড যাচাই

“Express-এ একটি অর্ডার API বানাও, PostgreSQL দিয়ে” — AI কয়েক সেকেন্ডে চলমান কোড দেবে, আর প্রথম ডেমোতে সব ঠিক দেখাবে। ডেটাবেস-কোডের বাগ দেখা যায় ভিড়ে, অদ্ভুত ইনপুটে, মাঝপথে crash হলে। AI-এর কোডে যে ভুলগুলো বারবার দেখা যায়:

AI যা প্রায়ই লেখেসমস্যাযা চাইবেন
pool.query(`… WHERE id = ${req.params.id}`)SQL injection; template literal-কে নিরাপদ ভাবা$1 placeholder আর values array; নামের জন্য allowlist
প্রতিটি route-এ new Client() … connect()প্রতি অনুরোধে নতুন সংযোগ — ধীর, ভিড়ে “too many clients”, end ভুললে leakঅ্যাপে একটি Pool, মডিউল থেকে export
অর্ডার, আইটেম আর স্টকের আলাদা আলাদা pool.queryTransaction নেই — মাঝপথে ব্যর্থ হলে অর্ধেক লেখা ডেটাএকটি client-এ BEGIN/COMMIT/ROLLBACK, finally-তে release()
ORM-এ লুপের ভেতরে findUnique বা lazy relationN+1 — তালিকা বড় হলেই পাতা ধীরinclude/JOIN, query log করে গোনা
price REAL বা FLOAT, JavaScript-এ price * 1.15Floating-point-এর পয়সার গরমিলপূর্ণসংখ্যা পয়সা বা numeric; string হয়ে আসা মান সাবধানে রূপান্তর
পাসওয়ার্ডসহ connection string কোডে, .env repo-তেRepo যে দেখবে, ডেটাবেস তারEnvironment variable, .gitignore, ন্যূনতম অনুমতির ব্যবহারকারী
আগে SELECT করে “নেই” দেখে INSERTRace condition — একসাথে দুই অনুরোধে ডুপ্লিকেটUNIQUE constraint, 23505 → 409

AI-কে কাজ দেওয়ার সময়ই শর্তগুলো বলে দিন, আর শেষে তাকে দিয়েই নিজের কোড পর্যালোচনা করিয়ে নিন:

আমি Node.js 20 আর Express দিয়ে একটি অনলাইন দোকানের API বানাচ্ছি, ডেটাবেস PostgreSQL, driver "pg". টেবিল: customers(id, name, phone UNIQUE), products(id, title, category, price_paisa integer, stock integer CHECK stock >= 0), orders(id, customer_id, total_paisa, created_at timestamptz), order_items(order_id, product_id, qty, unit_paisa). নিচের শর্ত মেনে POST /api/orders আর GET /api/orders (সাম্প্রতিক ২০টি, ক্রেতার নামসহ) লিখে দাও: ১. পুরো অ্যাপে একটিই pg.Pool, DATABASE_URL আসবে process.env থেকে; কোডে কোনো পাসওয়ার্ড নয়। ২. সব ব্যবহারকারী-ইনপুট $1, $2 placeholder দিয়ে; SQL-এর লেখায় কোনো ${} বা + দিয়ে ইনপুট জোড়া নয়। ৩. অর্ডার নেওয়া একটি transaction-এ: একই client-এ BEGIN, স্টক কমানো "UPDATE … WHERE id = $2 AND stock >= $1" দিয়ে আর rowCount যাচাই, ব্যর্থ হলে ROLLBACK, finally-তে client.release()। দাম নেবে ডেটাবেস থেকে, request body থেকে নয়। ৪. টাকা পূর্ণসংখ্যা পয়সায়; কোথাও float নয়। ৫. GET-এ N+1 নয় — একটি JOIN query. ৬. স্টক কম হলে 409, ইনপুট ভুল হলে 400, বাকি error-এ 500; ডেটাবেসের আসল error message ব্যবহারকারীকে পাঠাবে না। শেষে নিজের কোড একজন রিভিউয়ারের চোখে দেখে বলো: কোন লাইনে SQL injection, connection leak, অর্ধেক লেখা ডেটা বা N+1-এর ঝুঁকি থাকতে পারত, আর কীভাবে এড়ানো হলো। কোনো API-র ব্যাপারে নিশ্চিত না হলে "নিশ্চিত নই" বলো।

AI-এর লেখা ডেটাবেস-কোড — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১১৯৭০ সালে IBM-এর এক গণিতবিদের একটি গবেষণাপত্র থেকে relational মডেলের জন্ম, আর সেই দশকেই IBM-এর গবেষণাগারে SQL. পঞ্চাশ বছর পরেও কেন টেবিল আর SQL টিকে আছে, যখন প্রোগ্রামিংয়ের প্রায় সবকিছু বদলে গেছে?

১৯৭০ সালে IBM-এর গবেষক Edgar F. Codd “A Relational Model of Data for Large Shared Data Banks” নামে একটি প্রবন্ধ প্রকাশ করেন (Communications of the ACM-এ)। তখনকার ডেটাবেস ছিল hierarchical বা network ধরনের — যেমন IBM-এর নিজের IMS. সেখানে ডেটা খুঁজতে প্রোগ্রামারকে জানতে হতো ডেটা disk-এ কীভাবে সাজানো, কোন pointer ধরে কোন রেকর্ডে যেতে হবে। সাজানোর পদ্ধতি বদলালে প্রোগ্রামও নতুন করে লিখতে হতো। Codd-এর মূল ধারণা ছিল data independence: ডেটা থাকবে গাণিতিক “relation”-এ (আমরা বলি টেবিল), প্রোগ্রাম বলবে কী চাই, আর কীভাবে খুঁজবে তা ঠিক করবে সিস্টেম নিজে।

IBM-এর San Jose গবেষণাগারে ১৯৭০-এর দশকে System R প্রকল্প এই ধারণাকে বাস্তবে প্রমাণ করে। সেখানে Donald Chamberlin আর Raymond Boyce একটি ইংরেজির মতো পড়া যায় এমন ভাষা বানান — নাম ছিল SEQUEL; পরে ট্রেডমার্কের কারণে নাম হয় SQL. System R দেখায় যে একটি query optimizer প্রায়ই মানুষের হাতে লেখা পথের মতোই দক্ষ পথ খুঁজে নিতে পারে। মজার ব্যাপার, প্রথম বাণিজ্যিক SQL ডেটাবেস বাজারে আনে IBM নয়, ছোট একটি কোম্পানি — যা পরে Oracle নামে পরিচিত হয় (১৯৭৯)। ১৯৮৬ সালে SQL ANSI মানদণ্ড হয়।

কেন টিকে আছে? কারণ মডেলটি একটি গাণিতিক ভিত্তির ওপর দাঁড়িয়ে, কোনো নির্দিষ্ট হার্ডওয়্যারের ওপর নয়। Disk থেকে SSD, এক মেশিন থেকে cluster — নিচের সব বদলেছে, কিন্তু “টেবিল, key আর ঘোষণামূলক প্রশ্ন” বদলানোর দরকার হয়নি; optimizer নতুন হার্ডওয়্যারে মানিয়ে নিয়েছে। দ্বিতীয় কারণ, ডেটা সাধারণত অ্যাপের চেয়ে বেশি দিন বাঁচে: একটি ব্যাংকের হিসাব হয়তো তিনটি ভিন্ন অ্যাপ-প্রজন্ম দেখেছে। যে মডেল কোনো একটি অ্যাপের আকারে নয়, ডেটার নিজের আকারে সাজানো, সেটি সব অ্যাপের কাজে লাগে। তৃতীয়ত, SQL একটি ভাগ করা ভাষা — ডেভেলপার, ডেটা বিশ্লেষক, রিপোর্ট-টুল সবাই একই ভাষা বোঝে।

মূল উপলব্ধি: যে বিমূর্ততা “কীভাবে” লুকিয়ে শুধু “কী” জিজ্ঞেস করতে দেয়, আর গণিতের ওপর দাঁড়িয়ে থাকে, সেটি হার্ডওয়্যারের প্রজন্ম পেরিয়ে টিকে যায় — আর নিচের প্রতিটি উন্নতি বিনা খরচে ওপরের কোডকে উপহার দেয়।

প্র ০২ACID-এর কড়া প্রতিশ্রুতি থাকা সত্ত্বেও ২০০৯ সালের দিকে “NoSQL” আন্দোলন কেন জোরালো হলো, BASE কী দিল — আর গত দশকে অনেক দল কেন আবার PostgreSQL-এর দিকে ফিরছে?

ACID শব্দটি ১৯৮৩ সালে Theo Härder আর Andreas Reuter-এর একটি প্রবন্ধে জনপ্রিয় হয়, যদিও এর ধারণাগুলো — বিশেষ করে Jim Gray-এর transaction নিয়ে কাজ — আগে থেকেই ছিল।

২০০০-এর দশকে Google, Amazon, Facebook-এর মতো কোম্পানির ডেটা আর অনুরোধ একটি মেশিনের সীমা ছাড়িয়ে যায়। হাজারো সস্তা মেশিনে ডেটা ছড়াতে হলে একটি কঠিন সত্যের মুখোমুখি হতে হয়: নেটওয়ার্ক কখনো কখনো ভাগ হয়ে যাবে, আর তখন হয় সব জায়গায় একই উত্তর (consistency), নয়তো সবসময় উত্তর দেওয়া (availability) — দুটো একসাথে পুরোপুরি পাওয়া যায় না। Eric Brewer-এর এই পর্যবেক্ষণ CAP theorem নামে পরিচিত (২০০০ সালের বক্তৃতা; ২০০২ সালে Gilbert আর Lynch গাণিতিকভাবে প্রমাণ করেন)। Google-এর Bigtable (২০০৬) আর Amazon-এর Dynamo (২০০৭) প্রবন্ধ দেখায় কীভাবে কিছু নিশ্চয়তা ছেড়ে বিশাল মাপে যাওয়া যায়। এই ধারার দর্শনকে বলা হয় BASE — Basically Available, Soft state, Eventually consistent: সবসময় উত্তর দাও, আর সব কপি “শেষ পর্যন্ত” মিলে যাবে। ২০০৯ সালে সান ফ্রান্সিসকোর একটি meetup-এর জন্য বেছে নেওয়া “NoSQL” নামটিই আন্দোলনের নাম হয়ে যায়; একই বছর MongoDB-র প্রথম প্রকাশ।

কিন্তু অনেক দল শিখল, তাদের সমস্যা Google-এর মাপের নয়। Eventual consistency মানে অ্যাপের কোডকে নিজে অসঙ্গতি সামলাতে হয় — “স্টক কি সত্যিই আছে?” প্রশ্নের উত্তর অস্পষ্ট। এদিকে relational জগৎ শিখে নিল: PostgreSQL-এ JSONB (৯.৪ version, ২০১৪) document রাখা আর তাতে index দেওয়া সম্ভব করল; Google-এর Spanner দেখাল বিশ্বজুড়ে ছড়ানো ডেটাবেসেও কড়া transaction সম্ভব; আর NoSQL ডেটাবেসগুলোও transaction যোগ করল (MongoDB 4.0, ২০১৮)। সাম্প্রতিক বছরগুলোর Stack Overflow ডেভেলপার জরিপে PostgreSQL সবচেয়ে বেশি ব্যবহৃত ডেটাবেসের শীর্ষে উঠে এসেছে।

মূল উপলব্ধি: প্রতিটি নিশ্চয়তা ছেড়ে দেওয়ার একটি দাম আছে, যা পরে অ্যাপের কোডকে দিতে হয়। আপনার আসল চাহিদা যতক্ষণ একটি শক্তিশালী সার্ভারের মধ্যে আঁটে, ACID-এর সরলতা প্রায় সবসময় জেতে।

প্র ০৩SQL injection প্রথম প্রকাশ্যে বর্ণিত হয় ১৯৯৮ সালে, আর প্রতিকার — parameterized query — বহু বছর ধরে সব ভাষায় আছে। তবু এটি কেন এখনো OWASP Top 10-এ থাকে?

১৯৯৮ সালের ডিসেম্বরে নিরাপত্তা-গবেষক Jeff Forristal (ছদ্মনাম “rain.forest.puppy”) হ্যাকার পত্রিকা Phrack-এর ৫৪তম সংখ্যায় Microsoft-এর ওয়েব প্রযুক্তির দুর্বলতা নিয়ে একটি লেখায় দেখান, কীভাবে ওয়েব ফর্মের ইনপুট SQL query-র লেখায় মিশে গিয়ে query-র অর্থই বদলে দিতে পারে। এটিকেই সাধারণত SQL injection-এর প্রথম প্রকাশ্য বর্ণনা ধরা হয়। এর পরের দশকে এটি বহু বড় তথ্য-ফাঁসের পেছনে ছিল — যেমন ২০০৮ সালে Heartland Payment Systems-এ কোটি কোটি কার্ডের তথ্য চুরির ঘটনার শুরু এই পথে, আর ২০১৫ সালে যুক্তরাজ্যের TalkTalk-এ লাখের বেশি গ্রাহকের তথ্য ফাঁস। ২০০৭ সালের একটি xkcd কমিকের “Little Bobby Tables” চরিত্রটি দিয়ে ডেভেলপাররা আজও বিষয়টি মনে রাখেন।

OWASP Top 10-এর ২০১০, ২০১৩ আর ২০১৭ সংস্করণে “Injection” ছিল এক নম্বরে; ২০২১ সংস্করণে তৃতীয়, আর সেখানে XSS-ও একই বিভাগে যুক্ত হয়েছে। প্রতিকার জানা, তবু কেন টিকে আছে? কয়েকটি কারণ। এক, সবচেয়ে সহজ কোডটিই ভুল কোড: string জোড়া দেওয়া প্রতিটি নতুন প্রোগ্রামারের প্রথম প্রবৃত্তি, আর template literal সেটিকে আরও সুন্দর দেখায়। দুই, বাগটি সাধারণ ব্যবহারে অদৃশ্য — কেউ ইচ্ছা করে খোঁজার আগে ধরা পড়ে না। তিন, পুরোনো কোড বহু বছর বাঁচে; একটি পুরোনো রিপোর্ট-পাতা বা অ্যাডমিন টুল কারও নজরে নেই। চার, placeholder সব জায়গায় চলে না — কলামের নাম, ORDER BY, ডাইনামিক ফিল্টার — আর সেখানে মানুষ আবার জোড়া দিতে শুরু করে। পাঁচ, এখন AI প্রচুর কোড লেখে, আর সে শিখেছে ইন্টারনেটের পুরোনো, অসাবধান উদাহরণ থেকেও।

দীর্ঘমেয়াদি সমাধান তাই ব্যক্তির সতর্কতা নয়, নকশা: এমন API যেখানে নিরাপদ পথটিই সহজ পথ। Prisma-র $queryRaw tagged template (পর্ব ৯-এর tagged template) মানগুলোকে নিজে parameter বানায়; নিরাপদ নয় এমন সংস্করণের নামেই লেখা “Unsafe”.

মূল উপলব্ধি: যে নিরাপত্তা ব্যক্তির মনে রাখার ওপর নির্ভর করে, তা ব্যর্থ হবেই। টেকসই নিরাপত্তা আসে API আর টুলের নকশা থেকে — যেখানে ভুল পথটি কঠিন, আর তার নামেই সতর্কবার্তা।

প্র ০৪২০০৬ সালে Ted Neward ORM-কে বলেছিলেন “কম্পিউটার বিজ্ঞানের ভিয়েতনাম”. এমন কঠোর তুলনা কেন? আর তবু আজ Prisma, Drizzle-এর মতো ORM এত জনপ্রিয় কেন?

২০০৬ সালে সফটওয়্যার-লেখক Ted Neward “The Vietnam of Computer Science” নামে একটি দীর্ঘ প্রবন্ধ লেখেন। তাঁর তুলনাটি ছিল এরকম: একটি প্রকল্প শুরু হয় ছোট, স্পষ্ট লক্ষ্য নিয়ে; প্রথম দিকে সাফল্য আসে; তারপর একের পর এক কঠিন সমস্যা দেখা দেয়, আর ততদিনে এত বিনিয়োগ হয়ে গেছে যে বেরিয়ে আসাও কঠিন — ফলে আরও গভীরে জড়িয়ে পড়া, স্পষ্ট বিজয় ছাড়াই। ORM-এর গল্পও তাঁর চোখে এমন: সাধারণ CRUD-এ চমৎকার, কিন্তু যত জটিল প্রশ্ন, তত বেশি বিশেষ নিয়ম, configuration আর ব্যতিক্রম।

মূল সমস্যার নাম object-relational impedance mismatch: object আর টেবিল দুটি আলাদা জগৎ। Object-এর আছে পরিচয় (একই ডেটার দুটি object কি এক?), inheritance, আর reference ধরে ধরে এগোনো (order.customer.address.city). টেবিলের আছে key, সেট-ভিত্তিক প্রশ্ন আর JOIN. Object-এর ভাষায় স্বাভাবিক “প্রতিটি অর্ডারের ক্রেতা” ঠিক সেটিই N+1 হয়ে যায়। Lazy loading সুবিধাজনক, কিন্তু কখন query হচ্ছে তা কোড পড়ে বোঝা যায় না। এগুলোই leaky abstraction — ORM যা লুকাতে চায়, তা বের হয়ে আসে, আর আপনাকে SQL আর ORM দুটোই জানতে হয়। Martin Fowler পরে মনে করিয়ে দেন: সমস্যাটি নিজেই কঠিন — ORM না থাকলেও কাজটি হাতে করতে হতো।

তবু আজকের ORM জনপ্রিয় কেন? কারণ তারা পুরোনো ভুল থেকে শিখেছে। Prisma আর Drizzle “সবকিছু object” হওয়ার ভান কম করে: query স্পষ্ট, lazy loading-এর জাদু কম, আর include লিখলে তবেই সম্পর্কিত ডেটা আসে। TypeScript-এর টাইপ এদের সবচেয়ে বড় শক্তি — schema বদলালে ভুল query compile-এর সময়ই ধরা পড়ে। আর migration, parameterized query, connection management একসাথে পাওয়া ছোট দলের জন্য বিশাল সময় বাঁচায়।

মূল উপলব্ধি: বিমূর্ততা তখনই নিরাপদ, যখন আপনি তার নিচের স্তরটি বোঝেন। ORM ব্যবহার করুন উৎপাদনশীলতার জন্য, কিন্তু তার তৈরি SQL পড়তে আর দরকারে নিজে লিখতে জানুন।

প্র ০৫সার্ভার নেই, পাসওয়ার্ড নেই, একটিমাত্র ফাইল — তবু SQLite সম্ভবত পৃথিবীর সবচেয়ে বেশি চলা ডেটাবেস, আপনার ফোনেও আছে। এমন একটি “ছোট” ডেটাবেস কীভাবে এত বড় হলো, আর এটি কখন সঠিক পছন্দ?

D. Richard Hipp ২০০০ সালে SQLite লেখা শুরু করেন। তিনি তখন মার্কিন নৌবাহিনীর একটি জাহাজের সফটওয়্যারের ঠিকাদারি কাজ করছিলেন; সেখানে একটি ডেটাবেস সার্ভার বন্ধ হয়ে গেলে প্রোগ্রাম চলত না — বিরক্ত হয়ে তিনি এমন একটি ডেটাবেস চাইলেন, যার আলাদা সার্ভারই নেই। SQLite তাই একটি library: আপনার প্রোগ্রামের ভেতরেই চলে, পুরো ডেটাবেস একটি সাধারণ ফাইল, কোনো সংযোগ, পাসওয়ার্ড বা প্রশাসক লাগে না। তবু এতে আছে পূর্ণ SQL আর ACID transaction. কোড public domain-এ — যে কেউ যেকোনো কাজে বিনা শর্তে ব্যবহার করতে পারে।

ফল: Android আর iPhone-এর প্রায় প্রতিটি অ্যাপ, বড় ব্রাউজারগুলো (ইতিহাস, bookmark), অনেক ডেস্কটপ সফটওয়্যার, গাড়ি আর টিভির ভেতরের সফটওয়্যার — সবখানে SQLite. এর ওয়েবসাইটের হিসাবে সক্রিয় SQLite ডেটাবেসের সংখ্যা এক trillion-এর বেশি, তাই একে “সবচেয়ে বেশি deploy করা ডেটাবেস” বলা হয়। এর নির্ভরযোগ্যতার পেছনে অস্বাভাবিক কঠোর পরীক্ষা: টেস্টের কোড মূল কোডের চেয়ে বহুগুণ বড়, আর রক্ষণাবেক্ষণকারীরা ২০৫০ সাল পর্যন্ত সমর্থনের লক্ষ্য ঘোষণা করেছেন।

কখন সঠিক? Hipp নিজে বলেন, SQLite প্রতিদ্বন্দ্বিতা করে PostgreSQL-এর সাথে নয়, fopen()-এর সাথে — অর্থাৎ নিজের ফাইল-ফরম্যাট বানানোর বদলে। মোবাইল ও ডেস্কটপ অ্যাপ, CLI টুল, টেস্ট, ছোট থেকে মাঝারি ওয়েবসাইট (একটি সার্ভার, পড়া বেশি, লেখা কম) — চমৎকার। WAL মোডে পড়া আর লেখা একসাথে চলে, তবে একসময়ে একটিই লেখক; তাই অনেক সার্ভার থেকে একসাথে প্রচুর লেখা, বা নেটওয়ার্ক ফাইল-সিস্টেমে রাখা — সেখানে PostgreSQL-এর মতো client-server ডেটাবেস। আর Node 22.5 থেকে node:sqlite তো Node-এর ভেতরেই।

মূল উপলব্ধি: সবচেয়ে প্রভাবশালী সফটওয়্যার সবসময় সবচেয়ে শক্তিশালী নয় — অনেক সময় সেটি, যা সবচেয়ে কম ঝামেলায় সবচেয়ে বেশি জায়গায় বসে যায়। সঠিক টুল বেছে নিতে আগে জানুন আপনার আসল চাপ কোথায়: পড়া, লেখা, নাকি অনেক সার্ভার।

অনুশীলন

বেশিরভাগ কাজ ভাবনার আর কোড পড়ার — উত্তর আগে নিজে লিখুন, তারপর মিলিয়ে নিন।

  1. Schema নকশা: দোকানে এখন পণ্যের রিভিউ যোগ হবে — একজন ক্রেতা একটি পণ্যে একটিই রিভিউ দিতে পারবেন, রেটিং ১ থেকে ৫, সাথে লেখা আর সময়। reviews টেবিলের CREATE TABLE লিখুন, আর বলুন কোন কলামে index লাগবে।

    CREATE TABLE reviews (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, product_id bigint NOT NULL REFERENCES products(id), customer_id bigint NOT NULL REFERENCES customers(id), rating smallint NOT NULL CHECK (rating BETWEEN 1 AND 5), body text, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (product_id, customer_id)); — “একজন একটিই” নিয়মটি রাখে UNIQUE (product_id, customer_id), Node-এর যাচাই নয় (দুবার চাপা বা একসাথে দুই অনুরোধেও ডুপ্লিকেট হবে না; আবার দিলে 409). যৌথ unique index-এর প্রথম কলাম product_id, তাই “এই পণ্যের রিভিউ” খোঁজাতেও এটি কাজে লাগে; “এই ক্রেতার রিভিউ” দেখাতে customer_id-এ আলাদা index দিন।

  2. বাগ খুঁজুন: app.delete("/api/carts/:id", async (req, res) => { const c = new pg.Client(); await c.connect(); await c.query(`DELETE FROM carts WHERE id = ${req.params.id}`); res.sendStatus(204); }); — কয়টি সমস্যা পাচ্ছেন?

    অন্তত চারটি। (১) ${req.params.id} — SQL injection; URL-এর অংশও ব্যবহারকারীর ইনপুট। ঠিক রূপ: pool.query("DELETE FROM carts WHERE id = $1", [req.params.id]). (২) প্রতি অনুরোধে নতুন Client — ধীর, আর c.end() কোথাও নেই, তাই প্রতিটি অনুরোধে একটি সংযোগ খোলা থেকে যায়; কিছুক্ষণ পরে ডেটাবেস “too many clients” বলবে। অ্যাপের একটি pool ব্যবহার করুন। (৩) কোনো try/catch বা next(err) নেই — Express 4 async handler-এর rejected Promise ধরে না: অনুরোধ ঝুলে থাকে, আর unhandled rejection Node 15 থেকে ডিফল্টে পুরো process বন্ধ করে দেয় (Express 5 rejected Promise নিজেই next(err)-এ পাঠায়)। (৪) অনুমতি-যাচাই নেই — যে কেউ যেকোনো id-র কার্ট মুছতে পারে; শর্তে AND customer_id = $2 (লগইন করা ক্রেতা) দিন। বাড়তি: সারি না পেলে (rowCount === 0) 404 দেওয়া ভালো।

  3. Query গুনুন: একটি পাতা ৫০টি পণ্য দেখায়, প্রতিটির ক্যাটাগরির নাম আর শেষ ৩টি রিভিউসহ। কোড প্রথমে পণ্য আনে, তারপর লুপে প্রতিটি পণ্যের জন্য ক্যাটাগরি আর রিভিউ আলাদা query-তে। মোট কয়টি query? কীভাবে ৩টিতে নামাবেন?

    ১ + ৫০ × ২ = ১০১টি query. তিনটিতে নামানোর পথ: (১) পণ্য আর ক্যাটাগরি একটি JOIN-এ: SELECT p.*, c.name AS category_name FROM products p JOIN categories c ON c.id = p.category_id … LIMIT 50. (২) সব ৫০টি পণ্যের id নিয়ে রিভিউ একটি query-তে: WHERE product_id = ANY($1), তারপর Node-এ Map দিয়ে পণ্য অনুযায়ী ভাগ করে প্রতিটির প্রথম ৩টি নেওয়া। এতে মোট ২টিই হয়; চাইলে প্রতি পণ্যে শুধু ৩টি রিভিউ ডেটাবেসেই বাছাই করা যায় window function (ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at DESC)) বা LATERAL join দিয়ে। ক্যাটাগরির তালিকা কম বদলায় বলে সেটি আলাদা query-তে এনে cache-এ রাখলেও মোট ৩টি।

  4. Transaction ভাবুন: বিকাশের মতো একটি ওয়ালেট অ্যাপে রহিমা করিমকে ৫০০ টাকা পাঠাবেন। কোন কোন লেখা লাগবে, কোন ক্রমে, আর দুজন একই সময়ে একে অপরকে টাকা পাঠালে কী বিপদ হতে পারে?

    একটি transaction-এ তিনটি লেখা: (১) UPDATE wallets SET balance_paisa = balance_paisa - 50000 WHERE id = $rahima AND balance_paisa >= 50000 — rowCount ০ হলে “পর্যাপ্ত টাকা নেই”, ROLLBACK; (২) করিমের ওয়ালেটে + 50000; (৩) transfers টেবিলে একটি রেকর্ড (কে, কাকে, কত, কখন — হিসাবের প্রমাণ)। সব সফল হলে COMMIT. দুজন একই সময়ে একে অপরকে পাঠালে transaction A রহিমার সারি তালাবদ্ধ করে করিমের জন্য অপেক্ষা করে, আর B করিমের সারি তালাবদ্ধ করে রহিমার জন্য — deadlock. PostgreSQL এটি ধরে একটিকে error দিয়ে বাতিল করে; প্রতিকার হলো সারিগুলো সবসময় একই ক্রমে (যেমন ছোট id আগে) বদলানো, আর deadlock বা serialization error-এ পুরো transaction আবার চেষ্টা করা।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমNode.js-এ pg দিয়ে একটি অর্ডার নিরাপদে নেওয়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. pool.connect() দিয়ে pool থেকে একটি নির্দিষ্ট client নেওয়া
  2. একই client-এ BEGIN পাঠানো
  3. orders আর order_items-এ সারি ঢোকানো, শর্তসহ UPDATE দিয়ে স্টক কমানো
  4. সব সফল হলে COMMIT, কোনো error হলে ROLLBACK
  5. finally-তে client.release() দিয়ে সংযোগ pool-এ ফেরত দেওয়া

Node.js-এর pg দিয়ে ব্যবহারকারীর দেওয়া ক্যাটাগরি অনুযায়ী পণ্য খোঁজার নিরাপদ উপায় কোনটি?

  • pool.query(`SELECT * FROM products WHERE category = '${category}'`)
  • pool.query("SELECT * FROM products WHERE category = $1", [category])
  • ইনপুট থেকে সব ' মুছে তারপর string জোড়া দেওয়া
  • শুধু ব্রাউজারের ফর্মে ক্যাটাগরি যাচাই করা

Node.js সার্ভারে প্রতিটি HTTP অনুরোধের জন্য নতুন ডেটাবেস সংযোগ খোলার বদলে connection pool ব্যবহারের মূল কারণ কী?

  • Pool SQL injection স্বয়ংক্রিয়ভাবে ঠেকায়
  • Pool সব query-কে স্বয়ংক্রিয়ভাবে একটি transaction-এ রাখে
  • সংযোগ খোলা ব্যয়বহুল আর ডেটাবেসের সংযোগ-সংখ্যা সীমিত — pool সংযোগ পুনর্ব্যবহার করে আর সংখ্যা বেঁধে রাখে
  • Pool আগের query-র ফল মেমোরিতে রেখে দেয়, তাই ডেটাবেসে যেতে হয় না

একটি transaction-এ অর্ডারের সারি আর একটি আইটেম ঢোকার পর দ্বিতীয় পণ্যের স্টক কমাতে গিয়ে error হলো, আর কোড ROLLBACK পাঠাল। ডেটাবেসের অবস্থা কী?

  • Transaction শুরুর আগের মতো — অর্ডার, আইটেম, স্টক কোনো বদলই থাকে না
  • শুধু শেষ ব্যর্থ বাক্যটি বাতিল হয়; অর্ডার আর আইটেম থেকে যায়
  • আইটেম মুছে যায়, কিন্তু অর্ডারের সারি থেকে যায়
  • ডেটাবেস বাকি ধাপগুলো নিজে থেকে শেষ করে

২০টি অর্ডার আনার পর লুপে প্রতিটি অর্ডারের ক্রেতার নাম আলাদা query-তে আনলে মোট কয়টি query হয়, আর এই সমস্যার নাম কী?

  • ২০টি — race condition
  • ২১টি — deadlock
  • ১টি — কারণ ডেটাবেস নিজে query জোড়া দেয়
  • ২১টি — N+1 query সমস্যা

অনলাইন দোকানের ডেটাবেসে পণ্যের দাম রাখার জন্য কোনটি সবচেয়ে উপযুক্ত?

  • float বা real কলামে টাকা হিসেবে
  • পূর্ণসংখ্যা integer কলামে পয়সা হিসেবে (৪২০ টাকা = 42000)
  • text কলামে "৪২০.০০ টাকা" হিসেবে
  • দাম ডেটাবেসে না রেখে front-end-এর কোডে লিখে রাখা

একই ফোন নম্বরে দুটি অ্যাকাউন্ট ঠেকানোর সবচেয়ে নির্ভরযোগ্য উপায় কোনটি?

  • INSERT-এর আগে SELECT দিয়ে দেখে নেওয়া নম্বরটি আছে কি না
  • নিবন্ধনের ফর্মে JavaScript দিয়ে যাচাই করা
  • কলামে UNIQUE constraint রাখা আর unique violation error পেলে 409 ফেরত দেওয়া
  • ফোন কলামে একটি সাধারণ index দেওয়া

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. ডেটাবেস দেয় স্থায়িত্ব, নিরাপদ একসাথে-কাজ আর দ্রুত খোঁজা; সম্পর্কযুক্ত ও টাকার ডেটায় relational (PostgreSQL), document-এর মতো ডেটায় MongoDB/Firestore.
  2. ভালো schema: primary/foreign key, NOT NULL/UNIQUE/CHECK, many-to-many-তে মাঝের টেবিল, টাকা পয়সায়, সময় timestamptz-এ।
  3. অ্যাপে একটি Pool, secret environment-এ; ইনপুট সবসময় $1 parameter — কখনো SQL-এর লেখায় জোড়া নয়; নামের জন্য allowlist.
  4. একাধিক লেখা = একটি client-এ transaction: BEGIN → কাজ → COMMIT, error-এ ROLLBACK, finally-তে release; স্টক যাচাই আর কমানো একটি শর্তসহ UPDATE-এ।
  5. গতির জন্য প্রমাণভিত্তিক index (EXPLAIN), N+1-এর বদলে JOIN বা ANY; schema বদলায় migration-এ; ORM সুবিধা দেয়, কিন্তু তার SQL পড়তে জানুন।

পরের পর্বে: ডেটাবেসে index আছে, query ঠিক — তবু সার্ভারের মেমোরি ঘণ্টায় ঘণ্টায় বাড়ছে, আর পাতা মাঝে মাঝে আটকে যাচ্ছে। কোথায় সময় যায়, কে মেমোরি ধরে রাখে — DevTools-এর Performance আর Memory প্যানেল, heap snapshot আর memory leak খোঁজার কৌশল।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান