11. modulAdatbázis és bindings a server/-ben
Nuxt for Devs · 11. modul

Adatbázis és bindings a server/-ben

A szerveroldali blokk záró modulja: hogyan éred el a Cloudflare-erőforrásokat Nuxtból, hogyan néz ki egy teljes Drizzle-setup D1-gyel és Hyperdrive-val — sémától a prepared statementekig —, mi a kapcsolat életciklusa kérésenként, és hogyan kényszeríted ki, hogy a tenant-szűrés soha ne maradjon le egy lekérdezésről. Külön szakaszt kap a kérdés, amit a legtöbben későn tesznek fel: át kell-e írni az ORM-et — a TypeORM működő receptjével együtt.

11.1A bindingok elérése

A 6. modulban láttuk, hogy Workers-en a bindingok nem érhetők el modul-szinten — csak a kérés kontextusából. Nitróban ez az event.context.cloudflare alatt van:

event.context.cloudflare.env.DB            // D1Database
event.context.cloudflare.env.BUCKET        // R2Bucket
event.context.cloudflare.env.CACHE         // KVNamespace
event.context.cloudflare.env.HYPERDRIVE    // Hyperdrive (connectionString)
event.context.cloudflare.context           // ExecutionContext → waitUntil()
event.context.cf                           // CfProperties: ország, colo, bot score…
Ez a „kényelmetlenség” valójában védelem. Ha modul-szinten építhetnél adatbázis-klienst, akkor a kliens (és a benne tartott állapot) osztott lenne a kérések között — pontosan az a hiba, amit a 6. modulban tárgyaltunk. Így viszont a platform kényszerít arra, hogy kérés-hatókörben dolgozz.

Típusok

npx wrangler types      # generálja a worker-configuration.d.ts-t a wrangler.jsonc alapján
// server/types/h3.d.ts — hogy az event.context is típusos legyen
declare module 'h3' {
  interface H3EventContext {
    cf: CfProperties
    cloudflare: {
      request: Request
      env: Env                     // ← a wrangler types által generált típus
      context: ExecutionContext
    }
    tenant?: Tenant                // a 4. modul middleware-e
    requestId?: string
  }
}
export {}

11.2Drizzle a gyakorlatban

Ez lesz az alapértelmezett eszközöd Workers-en, ezért végigmegyünk rajta rendesen: a wrapperen, a sémán, a kétféle lekérdezési API-n, a típusokon, és azon a két dolgon, ami kifejezetten Workers-en hoz sokat.

① A wrapper — hogy a végpont ne tudja, hova megy

A cél, hogy mindenhol useDb(event)-et hívj, és a wrapper eldöntse a többit.

D1

// server/utils/db.ts
import { drizzle } from 'drizzle-orm/d1'
import * as schema from '../database/schema'

export function useDb(event: H3Event) {
  // kérésenként egyszer építjük fel, és az event.context-en tartjuk
  if (!event.context.db) {
    event.context.db = drizzle(event.context.cloudflare.env.DB, { schema })
  }
  return event.context.db
}

D1-nél nincs „kapcsolat”: a binding maga egy RPC-csatorna, nincs mit megnyitni vagy lezárni.

Hyperdrive + Postgres

// server/utils/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from '../database/schema'

export function useDb(event: H3Event) {
  if (event.context.db) return event.context.db

  const cf = event.context.cloudflare

  // kérésenként ÚJ kliens — a poolt a Hyperdrive tartja az adatbázis közelében,
  // ezért a kliens létrehozása olcsó
  const sql = postgres(cf.env.HYPERDRIVE.connectionString, {
    max: 5,
    fetch_types: false,     // egy körúttal kevesebb induláskor
  })

  // az end() megvárja a folyamatban lévő lekérdezéseket, ezért biztonságos
  // már most ütemezni: a válasz után lezárul
  cf.context.waitUntil(sql.end())

  event.context.db = drizzle(sql, { schema })
  return event.context.db
}
// wrangler.jsonc — a driverekhez kell a Node-kompatibilitás
{
  "compatibility_flags": ["nodejs_compat"],
  "hyperdrive": [
    {
      "binding": "HYPERDRIVE",
      "id": "…",
      // lokális fejlesztéshez: közvetlen kapcsolat, Hyperdrive nélkül
      "localConnectionString": "postgres://user:pass@localhost:5432/app"
    }
  ]
}
Miért olcsó kérésenként új klienst építeni? Mert a Hyperdrive az adatbázis közelében tartja a valódi kapcsolat-poolt — a Worker csak egy már felépített kapcsolatot vesz kölcsön. Ez a szolgáltatás egész lényege: a klasszikus „connection pool a szerveren” minta nem működik olyan környezetben, ahol több ezer izolátum indul és áll le. A Cloudflare-anyag 4. modulja részletezi.

② A séma

A Drizzle-sémában nincs dekorátor és nincs osztály — sima objektumok, amikből a típusok is levezethetők. Egy multitenant tábla így néz ki:

// server/database/schema.ts — SQLite / D1 dialektus
import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { relations, sql } from 'drizzle-orm'

export const tenants = sqliteTable('tenants', {
  id:   text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  slug: text('slug').notNull().unique(),
  name: text('name').notNull(),
})

export const projects = sqliteTable('projects', {
  id:       text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  tenantId: text('tenant_id').notNull().references(() => tenants.id, { onDelete: 'cascade' }),
  ownerId:  text('owner_id').notNull().references(() => users.id),
  name:     text('name').notNull(),
  status:   text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
  costPrice: integer('cost_price'),               // ← belső mező: DTO-ból kihagyni! (5. modul)
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull().default(sql`(unixepoch())`),
}, (t) => [
  // a tenant MINDIG az összetett index ELSŐ oszlopa — minden lekérdezésed így szűr
  index('projects_tenant_status_idx').on(t.tenantId, t.status),
  uniqueIndex('projects_tenant_name_uq').on(t.tenantId, t.name),
])

// a relációk kellenek a db.query.* API-hoz
export const projectsRelations = relations(projects, ({ one }) => ({
  tenant: one(tenants, { fields: [projects.tenantId], references: [tenants.id] }),
  owner:  one(users,   { fields: [projects.ownerId],  references: [users.id] }),
}))
Az indexelési szabály multitenantnál: a tenant_id legyen minden összetett index első oszlopa. Nem esztétika: minden lekérdezésed tartalmazza a tenant-feltételt (11.7), tehát az index csak így használható. Ha a status van elöl, az adatbázis végigolvassa az összes tenant aktív projektjét, mielőtt kiszűrné a tiédet — és ez pont akkor fáj, amikor sok tenanted lesz.

③ A két lekérdezési API — és mikor melyik

A Drizzle-ben kétféleképpen kérdezhetsz, és ez elsőre zavaró. Nem verseng egymással a kettő, más a dolguk:

select — SQL-közeli, teljes kontroll
const rows = await db
  .select({
    id:     projects.id,          // ← csak amit tényleg kérsz: ez már fél DTO
    name:   projects.name,
    status: projects.status,
    owner:  users.name,
  })
  .from(projects)
  .innerJoin(users, eq(users.id, projects.ownerId))
  .where(and(eq(projects.tenantId, tenantId), eq(projects.status, 'active')))
  .orderBy(desc(projects.createdAt))
  .limit(20)
db.query — relációs, beágyazott eredménnyel
const rows = await db.query.projects.findMany({
  where: and(eq(projects.tenantId, tenantId), eq(projects.status, 'active')),
  columns: { id: true, name: true, status: true },       // ← fehérlista, nem feketelista
  with: {
    owner: { columns: { id: true, name: true, avatarUrl: true } },
  },
  orderBy: (p, { desc }) => [desc(p.createdAt)],
  limit: 20,
})
Használd eztAmikor
db.select()Aggregáció, összetett join, GROUP BY, ablakfüggvény — bármi, ahol a SQL alakja számít. Több sorban, de pontosan tudod, mi fut le.
db.query.*Egy entitás a kapcsolataival, beágyazott objektumként. Rövidebb, és a columns fehérlistával alapból a DTO felé terel.
A columns fehérlista a legjobb szokás, amit felvehetsz. Ha alapból felsorolod, mit kérsz, akkor egy új, bizalmas oszlop hozzáadása a táblához nem szivárog ki automatikusan a payloadba (5. modul). Ha viszont mindig a teljes sort kéred, akkor minden séma-bővítés potenciális szivárgás — és senki nem fogja észrevenni a code review-ban.

④ A típusok ingyen jönnek

// shared/types/project.ts — a séma NEM megy ide, de a belőle levezetett típus igen
import type { projects } from '~~/server/database/schema'

export type ProjectRow = typeof projects.$inferSelect   // amit olvasol
export type NewProject = typeof projects.$inferInsert   // amit beszúrsz

// és a DTO explicit — ez az, ami kimegy a kliensre
export type ProjectDto = Pick<ProjectRow, 'id' | 'name' | 'status'> & {
  owner: { id: string; name: string }
}
// server/utils/dto.ts
export function toProjectDto(r: ProjectRow & { owner: UserRow }): ProjectDto {
  return {
    id: r.id, name: r.name, status: r.status,
    owner: { id: r.owner.id, name: r.owner.name },
  }
}

Ez a lánc az, ami miatt a full-stack Nuxt egyáltalán vonzó: a séma egy helyen van, a DTO-típus belőle vezetve, és a useFetch a komponensben már tudja, mit kapott (5. modul). A type-only import miatt a séma futásidejű kódja nem kerül a kliens-bundle-be — erről a 11.5-ben még lesz szó.

⑤ Dinamikus szűrők — a lista-végpont mintája

Szinte minden listázó végponton kell néhány opcionális szűrő. A naiv megoldás egy elágazás-erdő; a Drizzle-ben ez sokkal tisztább:

export default defineEventHandler(async (event) => {
  const s = await requireTenant(event)
  const q = await getValidatedQuery(event, listQuerySchema.parse)
  const db = useDb(event)

  // a tenant-feltétel NEM opcionális — mindig az első
  const filters = [eq(projects.tenantId, s.tenantId)]

  if (q.status) filters.push(eq(projects.status, q.status))
  if (q.search) filters.push(like(projects.name, `%${q.search}%`))
  if (q.since)  filters.push(gte(projects.createdAt, q.since))

  let query = db.select(PROJECT_COLUMNS).from(projects)
    .where(and(...filters))
    .$dynamic()                       // ← innentől feltételesen bővíthető

  if (q.sort === 'name') query = query.orderBy(asc(projects.name))
  else                    query = query.orderBy(desc(projects.createdAt))

  return (await query.limit(q.limit ?? 20).offset(q.offset ?? 0)).map(toProjectDto)
})

⑥ Prepared statements — ez konkrét Workers-nyereség

Minden kérésnél újra felépíted ugyanazt az SQL-t stringből. Egy Node-szerveren ez elhanyagolható; Workers-en CPU-idő, amit számláznak. A prepared statement egyszer állítja össze a lekérdezést, és utána csak a paraméterek változnak:

// server/database/queries.ts
import { sql } from 'drizzle-orm'

export function activeProjectsQuery(db: Db) {
  return db
    .select(PROJECT_COLUMNS)
    .from(projects)
    .where(and(
      eq(projects.tenantId, sql.placeholder('tenantId')),
      eq(projects.status,   'active'),
    ))
    .orderBy(desc(projects.createdAt))
    .limit(sql.placeholder('limit'))
    .prepare('active_projects')
}

// használat a végponton
const rows = await activeProjectsQuery(useDb(event))
  .execute({ tenantId: s.tenantId, limit: 20 })
Miért éri meg pont itt? Mert a Drizzle a stringösszefűzést egyszer végzi el, a driver pedig újra tudja használni az előfordított lekérdezést ahelyett, hogy minden alkalommal parse-olná. A dokumentáció ezt kifejezetten a serverless és edge környezetekre emeli ki. A gyakorlati szabály: a forró útvonalakon (listák, dashboard, auth-lookup) érdemes, a ritkán futó adminisztratív lekérdezéseknél fölösleges bonyolítás.

⑦ D1 vagy Postgres — mi tér el a kódban

Ugyanaz a Drizzle, de a dialektus más. Ez a tábla azért van itt, mert ha később váltasz, ezek a sorok fognak fájni:

D1 (SQLite)Hyperdrive + Postgres
Importdrizzle-orm/d1, sqlite-coredrizzle-orm/postgres-js, pg-core
TáblasqliteTablepgTable
Egy sor lekérése.get().then(r => r[0])
Beszúrás visszatérése.returning() (támogatott).returning()
Időinteger(…, { mode: 'timestamp' })timestamp(…, { withTimezone: true })
Enumtext(…, { enum: [...] }) — csak típusszintenpgEnum — valódi DB-típus
JSONtext(…, { mode: 'json' })jsonb() — indexelhető is
Atomicitásdb.batch([...])db.transaction(...) (11.6)
Teljes szöveges keresésFTS5 virtuális táblatsvector + GIN index

⑧ Négy buktató, ami időt visz el

11.3Melyik ORM? — és a TypeORM esete

Eddig Drizzle-lel dolgoztunk, mert az a legkisebb ellenállás iránya Workers-en. De ha a mostani appod más ORM-re épül, akkor a valódi kérdés nem az, hogy melyik a legszebb, hanem hogy át kell-e írni az adatréteget — mert az a migráció legdrágább tétele.

ORMWorkers-enAmit tudni kell
DrizzleNatívD1-hez és Hyperdrive-hoz is; SQL-közeli, kicsi bundle. Ez az alapértelmezett választás új kódra.
KyselyNatívTiszta query builder, ha nem akarsz ORM-réteget. Szintén kicsi.
PrismaDriver-adapterrelMegy (D1, pg adapter), de nagyobb bundle és nehezebb indulás. Az Accelerate-út egy plusz hálózati ugrás.
TypeORMNem hivatalosan — de működésre bírhatóNincs edge-támogatás, out of the box nem megy. Postgres + pg mellett viszont van rá bevált recept — lásd alább.
Sequelize, MikroORMGyakorlatilag nemUgyanazok a szerkezeti akadályok, kidolgozott megkerülő út nélkül.

A TypeORM-recept

Ez a szakasz nem elmélet. Egy éles, Workers-en futó Nuxt-alkalmazás konfigurációjából általánosítottam, ahol a TypeORM PlanetScale Postgresszel, Hyperdrive-on keresztül működik. A recept lényege, hogy hat különálló akadályt kell egyenként semlegesíteni — egyik sem nagy munka, de egyik sem hagyható ki.

① Add át a drivert explicit módon — ez a kulcs

A legnagyobb akadály az, hogy a TypeORM futásidőben require-eli a drivert, amit a statikus bundler nem lát. A megoldás: ne keresse, hanem kapja meg.

// server/runtime/database/cloudflare-data-source.ts
import 'reflect-metadata'
import { DataSource } from 'typeorm'
import pg from 'pg'
import { APPLICATION_ENTITIES } from '../../db/entities'

// a pg modul, de a `native` mezőre mindig null-t adunk vissza:
// így a TypeORM nem próbálja a natív gyorsítót betölteni
const workersPgDriver = new Proxy(pg, {
  get(target, prop, receiver) {
    if (prop === 'native') return null
    return Reflect.get(target, prop, receiver)
  },
})

export function createDataSource(connectionString: string): DataSource {
  return new DataSource({
    type: 'postgres',
    url: connectionString,
    driver: workersPgDriver,        // ← EZ a lényeg: nincs dinamikus require
    nativeDriver: {},
    entities: [...APPLICATION_ENTITIES],   // ② explicit lista, NEM glob
    synchronize: false,
    logging: false,
    poolSize: 1,                    // ⑥ a pooling a Hyperdrive dolga
    extra: { max: 1, connectionTimeoutMillis: 10_000, idleTimeoutMillis: 1_000 },
  })
}

③ Stubold ki, amit a bundler feloldana, de sosem használsz

A TypeORM az összes támogatott adatbázishoz tartalmaz import-ágakat. Ezeket a bundler feloldani próbálja, akkor is, ha te csak Postgrest használsz. Alias-szal egy dobó modulra irányítod őket:

// server/runtime/adapters/cloudflare/unsupported-driver.mjs
export default new Proxy({}, {
  get() { throw new Error('Ez a TypeORM-driver nem elérhető a Cloudflare-profilban.') },
})
// nuxt.config.ts
const UNSUPPORTED_DRIVERS = [
  'mysql', 'mysql2', 'sqlite3', 'better-sqlite3', 'sql.js', 'mssql',
  'oracledb', 'mongodb', 'redis', 'ioredis', 'pg-query-stream',
  '@sap/hana-client', '@google-cloud/spanner', 'react-native-sqlite-storage',
]

const stub = (f: string) => resolve(__dirname, `./server/runtime/adapters/cloudflare/${f}`)

export default defineNuxtConfig({
  alias: {
    ...Object.fromEntries(UNSUPPORTED_DRIVERS.map(n => [n, stub('unsupported-driver.mjs')])),
    'pg-native':     stub('pg-native-unavailable.mjs'),
    'app-root-path': stub('app-root-path.mjs'),   // fájlrendszer-felderítés helyett
    'debug':         stub('debug.mjs'),
  },
})
Az app-root-path a legárulkodóbb tétel a listán. A TypeORM ezzel keresi a fájlrendszeren az entitásokat és a migrációkat — pontosan az a viselkedés, ami Workers-en nem létezik. A stub egy olyan objektum, ami /-t ad vissza és dob, ha bárki tényleg require-elni akarna vele. Ez működik, mert a ②-es lépés miatt sosem hívódik meg.

④ Dekorátorok és reflect-metadata

// nuxt.config.ts — nitro
nitro: {
  preset: 'cloudflare_module',
  cloudflare: { nodeCompat: true, wrangler: { compatibility_flags: ['nodejs_compat'] } },

  esbuild: {
    options: {
      target: 'esnext',
      tsconfigRaw: { compilerOptions: { experimentalDecorators: true } },
    },
  },

  // ⑤ a Workers-bundle-be BE kell fordítani őket, nem externalizálni
  externals: {
    inline: ['typeorm', 'reflect-metadata', 'pg'],
    external: [],
  },

  // hogy a csomagok a megfelelő export-ágat válasszák
  exportConditions: ['node', 'workerd'],
},

⑥ Kérésenkénti DataSource — nem singleton

Ez az a pont, ahol a legtöbb átültetés elbukik, és ahol a 6. modul tanulsága közvetlenül érvényes: a TypeORM alapértelmezett mintája egy hosszú életű, alkalmazás-indításkor felépített DataSource. Workers-en ez nemcsak felesleges, hanem hibás — megosztott mutálható állapot lenne. Helyette:

// server/runtime/database/event-data-source.ts
export async function withDataSource<T>(
  event: H3Event,
  operation: (ds: DataSource) => Promise<T>,
): Promise<T> {
  const connectionString = event.context.cloudflare?.env?.APP_DB?.connectionString
  if (!connectionString) {
    // Node-profil: a hagyományos, hosszú életű DataSource
    const { AppDataSource } = await import('../../db/data-source')
    return operation(AppDataSource)
  }

  const ds = createDataSource(connectionString)
  try {
    await ds.initialize()
    return await operation(ds)
  } finally {
    await ds.destroy().catch(() => undefined)
  }
}
// és a végpont így néz ki
export default defineEventHandler(async (event) => {
  const s = await requireTenant(event)
  return withDataSource(event, async (ds) => {
    const rows = await ds.getRepository(Project).find({ where: { tenantId: s.tenantId } })
    return rows.map(toProjectDto)
  })
})

Mibe kerül ez — és hogyan méred

A recept működik, de nem ingyenes. Három tétele van, és mindhárom mérhető:

ÁrMiértMit tegyél
Kérésenkénti initialize() A TypeORM ilyenkor építi fel az entitás-metaadatot a dekorátorokból. Ez CPU-idő — minden kérésen, és a Workers CPU-időt számláz. Mérd meg (lásd lentebb). Ha soknak bizonyul, vond össze a kéréseket, vagy csak a valóban adatot igénylő végpontokon hívd.
Bundle-méret A typeorm + reflect-metadata + pg hármas bekerül a Worker-bundle-be. CI-kapu a méretre — mindjárt jön a konkrét parancs.
Karbantartás A stub-lista és az alias-halmaz a TypeORM belső szerkezetére támaszkodik; egy major frissítés elmozdíthatja. Verziót rögzíts, és a frissítést mindig preview-deployjal ellenőrizd.

A CI-kapu, ami ezt a döntést mérhetővé teszi

A wrangler tud egy indulási profilt készíteni, amiből egyszerre kiolvasható a tömörített bundle-méret és a hidegindítási CPU-idő. Ezt érdemes CI-kapuvá tenni, a limitek 80%-ánál húzva a küszöböt — hogy legyen mozgástered, mielőtt a deploy elbukna:

npx wrangler check startup --config .output/server/wrangler.json \
  --outfile startup.cpuprofile

# a kimenetből:
#   Bundle: 4231.5 KiB / gzip: 1180.2 KiB
#   Profile window: 312.4 ms
#
# limitek:  gzip 10 MiB  ·  indulás 1000 ms
# kapu:     a limit 80%-a → 8 MiB, illetve 800 ms
Ez a kapu akkor is megéri, ha Drizzle-t használsz. A bundle-méret és a hidegindítás minden Workers-projektben kúszik felfelé, és a build-idejű ellenőrzés az egyetlen, ami ezt észreveszi, mielőtt a felhasználó. A 15. modulban visszatérünk rá, a .cpuprofile elemzésével együtt.

Mikor éri meg, és mikor ne

És amit ez a 9. modul döntéséről mond: ott azt írtam, hogy a meglévő ORM erős érv a BFF-irány mellett. Ez gyengül, ha a TypeORM velejöhet: akkor a full-stack irány nem jelent adatréteg-átírást, csak egy körülhatárolt konfigurációs munkát. Ha ez a te helyzeted, vedd elő a 9.8-as checklistet, és értékeld újra az „Express API végpontszáma” és a „hol lesz az adatbázis” sorokat — a mérleg elmozdulhat.

11.4Séma és migrációk

server/
  database/
    schema.ts          ← Drizzle-séma. NE tedd a shared/-be!
    migrations/
      0000_init.sql
      0001_add_projects.sql
  utils/
    db.ts
A séma ne kerüljön a shared/-be. A 2. modulban láttuk: a shared/ tartalma mindkét oldalra bekerül. A Drizzle-séma importálja a drizzle-orm-et, tehát az egész ORM bemenne a kliens-bundle-be — több száz kilobyte fölöslegesen, plusz a táblaszerkezeted kikerülne a böngészőbe. Amit megoszthatsz: a DTO-típusok és a validációs sémák, ezek tiszta típusdefiníciók.
// D1 esetén
npx drizzle-kit generate                       # SQL migráció a séma-változásból
npx wrangler d1 migrations apply APP_DB --local # lokálisan
npx wrangler d1 migrations apply APP_DB --remote# élesben (CI-ból, kapuzva!)

// Postgres esetén
npx drizzle-kit generate
npx drizzle-kit migrate                        # közvetlen kapcsolattal, nem a Workerből
Migráció és deploy sorrendje. A séma-migráció nem a Worker feladata — nem futtatod alkalmazás-indításkor, mert nincs is olyan pillanat. A CI-ban fut, a deploy előtt, és visszafelé kompatibilis lépésekben (előbb az új oszlop nullable-ként, aztán a kód, aztán a kitöltés, végül a szigorítás). A Cloudflare-anyag 8. modulja ezt teljes playbookkal tárgyalja, a hibás migráció forgatókönyvével együtt.

11.5A vasszabály: mit ne importálj a szerverkódba

A 2. modul build-ábrája szerint a szerverkód külön bundle-be fordul, és a hiba jellemzően csak deploy után derül ki. A leggyakoribb okok:

Amit importálszMi történikHelyette
fs, path fájlműveletreNincs fájlrendszeruseStorage() KV/R2 driverrel, vagy R2 közvetlenül
Natív addon (sharp, bcrypt, canvas)Nem fordul le / nem futCloudflare Images, WASM-változat, vagy külön szolgáltatás
Nehéz SDK (AWS SDK v3 teljes csomag)Bundle-méret, esetleg Node-API-kAlmodulok célzottan, vagy natív binding (R2 az S3 helyett)
Séma vagy ORM a shared/-bőlBekerül a kliens-bundle-beCsak típusokat ossz meg
Ritkán használt nagy könyvtárMinden kérésnél betöltendő kódawait import() a hívás helyén
// dinamikus import: csak akkor kerül be a hívási útba, amikor tényleg kell
export default defineEventHandler(async (event) => {
  const { generateCsv } = await import('~~/server/utils/heavy/csv')
  return generateCsv(await loadRows(event))
})

11.6Tranzakció vagy batch?

Ez a legnagyobb kódszerkezeti különbség a két adatbázis között:

Postgres (Hyperdrive)D1
Interaktív tranzakcióVan — BEGIN … COMMIT, feltételes ágakkalNincs
Atomi több-utasításos írásTranzakcióbandb.batch([...]) — előre összeállított lista
„Olvasok, döntök, írok”Egy tranzakcióbanAlkalmazás-szinten: feltételes UPDATE … WHERE, verziómező
Postgres
await db.transaction(async (tx) => {
  const [p] = await tx.insert(projects).values({ ...data, tenantId }).returning()
  await tx.insert(auditLog).values({ tenantId, action: 'project.create', refId: p.id })
  await tx.update(tenants).set({ projectCount: sql`project_count + 1` })
    .where(eq(tenants.id, tenantId))
})
D1 — batch
const id = crypto.randomUUID()   // az azonosítót előre generáljuk, mert nincs "returning" a láncban

await db.batch([
  db.insert(projects).values({ id, ...data, tenantId }),
  db.insert(auditLog).values({ tenantId, action: 'project.create', refId: id }),
  db.update(tenants).set({ projectCount: sql`project_count + 1` }).where(eq(tenants.id, tenantId)),
])
Amit a batch nem tud: nem tudsz a lista közepén döntést hozni a korábbi lépés eredménye alapján. Ha „olvasok, ellenőrzöm, aztán írok” logikád van, azt feltételes írássá kell alakítani — például UPDATE … WHERE version = ?, és ha nulla sor változott, akkor ütközés volt. Ez a minta amúgy is jobb, mint az olvas-dönt-ír, mert versenyhelyzetben is helyes.

11.7Hogyan kényszeríted ki a tenant-szűrést

Eddig minden modulban leírtam, hogy a tenant-feltétel a lekérdezésbe való. A gyakorlat viszont az, hogy egyszer valaki elfelejti — és abból adatszivárgás lesz. Két módszer arra, hogy ne lehessen elfelejteni.

① Tenant-hatókörű adatréteg

// server/utils/tenant-db.ts
export function tenantDb(event: H3Event, tenantId: string) {
  const db = useDb(event)
  const t = eq(projects.tenantId, tenantId)

  return {
    projects: {
      list: (extra?: SQL) =>
        db.select().from(projects).where(extra ? and(t, extra) : t),

      byId: (id: string) =>
        db.select().from(projects).where(and(t, eq(projects.id, id))),

      create: (data: NewProject) =>
        db.insert(projects).values({ ...data, tenantId }).returning(),

      update: (id: string, data: Partial<NewProject>) =>
        db.update(projects).set(data).where(and(t, eq(projects.id, id))).returning(),

      remove: (id: string) =>
        db.delete(projects).where(and(t, eq(projects.id, id))).returning({ id: projects.id }),
    },
    // …többi tábla ugyanígy
  }
}
// a végpont így néz ki — a tenant-feltételt nem lehet elfelejteni,
// mert nincs is olyan hívás, amiben ne lenne benne
export default defineEventHandler(async (event) => {
  const s = await requireTenant(event)              // 10. modul
  const rows = await tenantDb(event, s.tenantId).projects.list()
  return rows.map(toProjectDto)                    // 5. modul: DTO, nem nyers sor
})

② Postgresnél: Row Level Security

Ha marad a Postgres, van egy erősebb eszköz: az RLS az adatbázis szintjén érvényesíti a szabályt, tehát akkor is véd, ha a kódban elfelejtenéd. Kérésenként beállítod a munkamenet-változót, és a policy erre szűr:

await sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`   // true = tranzakció-szintű

-- és a táblán:
-- ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY tenant_isolation ON projects
--   USING (tenant_id = current_setting('app.tenant_id')::uuid);
RLS-nél két buktató Workers-en. ① A set_config a kapcsolathoz kötődik — ha connection poolt használsz (márpedig a Hyperdrive azt csinál), akkor gondoskodnod kell róla, hogy a beállítás és a lekérdezés ugyanabban a tranzakcióban legyen (a harmadik paraméter true-ra állítása ezt adja). ② A migrációkat futtató és az alkalmazás-felhasználó legyen különböző szerepkör, mert a táblatulajdonos alapból megkerüli az RLS-t.
Melyiket? A ①-es mindig érdemes, mert olcsó és a kódban látszik. A ②-es akkor, ha Postgres marad, és az izoláció üzletileg kritikus — a kettő együtt a legerősebb. D1-nél a ①-es az egyetlen út (nincs RLS), viszont ott felmerül a tenant-per-adatbázis modell is, ahol a fizikai szeparáció adja a garanciát — erről a Cloudflare-anyag 5b. modulja szól részletesen.

11.8Lokális fejlesztés adatokkal

Amit akarszD1Hyperdrive + Postgres
Lokális adatbáziswrangler d1 … --local (a .wrangler/ alatt)localConnectionString a wrangler-configban
Seed-adatwrangler d1 execute APP_DB --local --file=seed.sqlSima SQL-fájl a lokális Postgresbe
Éles adat megnézésewrangler d1 execute APP_DB --remote --command="…"Bármilyen SQL-eszköz, közvetlenül
Fejlesztés éles erőforrássalRemote bindings (12. és 18. modul)Távoli kapcsolat is megadható wrangler dev-hez
Seed-adat és tenant-tesztelés. A seed-adatodban legyen legalább két tenant, mindkettőben hasonló nevű rekordokkal. Ez az egyetlen módja annak, hogy az izolációs hibák fejlesztés közben kibukjanak — egy tenanttal minden hibás lekérdezés helyesnek látszik. A 16. modulban erre írunk automatizált tesztet is.
Mi jön ezután? A szerveroldali blokk ezzel kész. A IV. rész a napi fejlesztői gyakorlatról szól: a 12. modul a konfigurációt veszi végig — nuxt.config.ts, és a négy szint szétválasztása: build-time konstans vs. runtimeConfig vs. környezeti változó vs. Cloudflare binding és secret. Ez az a téma, ahol a legkönnyebb véletlenül titkot szivárogtatni a kliensbe.

11.9Ellenőrizd magad

  1. Miért nem építhetsz adatbázis-klienst modul-szinten Workers-en, és miért jó ez így?
    Válasz

    Mert a bindingok csak a kérés kontextusából érhetők el (event.context.cloudflare.env) — a platform fizikailag nem adja oda modul-szinten. Ez azért jó, mert így nem tudsz megosztott, kérések között élő állapotot építeni, ami a 6. modulban tárgyalt cross-request szivárgáshoz vezetne.

  2. Miért nem drága kérésenként új Postgres-klienst létrehozni Hyperdrive mögött?
    Válasz

    Mert a valódi kapcsolat-poolt a Hyperdrive tartja fenn az adatbázis közelében; a Worker csak kölcsönvesz egy már felépített kapcsolatot. Ez a szolgáltatás lényege: a klasszikus „pool a szerveren” minta nem működik olyan környezetben, ahol sok rövid életű izolátum fut.

  3. Miért nem teheted a Drizzle-sémát a shared/-be?
    Válasz

    Mert a shared/ tartalma mindkét oldalra bekerül, a séma pedig importálja a drizzle-orm-et — így az ORM a kliens-bundle-be kerülne (több száz kilobyte), és a táblaszerkezeted kikerülne a böngészőbe. Megosztani a DTO-típusokat és a validációs sémákat érdemes.

  4. Van egy „olvasom a készletet, ha van elég, csökkentem” logikád. Hogyan írod meg D1-en?
    Válasz

    Nem interaktív tranzakcióval (az nincs), hanem feltételes írással: UPDATE keszlet SET db = db - 1 WHERE id = ? AND db >= 1, és megnézed, hány sor változott. Ha nulla, nem volt elég készlet. Ez versenyhelyzetben is helyes — jobb, mint az olvas-dönt-ír, akkor is, ha lenne tranzakció.

  5. Két módszer arra, hogy a tenant-szűrés ne maradhasson le. Mi a kettő, és mikor melyik?
    Válasz

    ① Tenant-hatókörű adatréteg: olyan wrapper, ahol nincs is olyan hívás, amiben ne lenne benne a feltétel — ez mindig érdemes, olcsó és látszik a kódban. ② Postgres RLS: az adatbázis szintjén érvényesített policy, ami akkor is véd, ha a kódban elfelejtenéd — akkor, ha marad a Postgres, és az izoláció üzletileg kritikus. D1-nél csak az ① van, ott alternatíva a tenant-per-adatbázis.

  6. A TypeORM nem támogat edge runtime-ot. Mi az a két lépés, ami mégis működésre bírja Workers-en?
    Válasz

    ① A drivert explicit átadod a DataSource-nak (driver: pgModule), így nincs futásidejű require, amit a bundler ne látna. ② A DataSource-t kérésenként hozod létre és bontod le (initialize() → művelet → destroy()), nem singletonként — különben megosztott mutálható állapotod lenne. E kettő nélkül a többi (alias-stubok, dekorátor-beállítás, explicit entitáslista) sem segít.

  7. Mi a TypeORM-recept legnagyobb futásidejű ára, és hogyan méred?
    Válasz

    A kérésenkénti initialize(): a TypeORM ilyenkor építi fel az entitás-metaadatot a dekorátorokból, és ez CPU-idő minden kérésen — amit a Workers számláz. Mérni a wrangler check startup indulási profiljával lehet (bundle-méret + hidegindítási idő), és érdemes CI-kapuvá tenni a limitek 80%-ánál.

  8. Miért kell legalább két tenant a seed-adatba?
    Válasz

    Mert egyetlen tenanttal minden hibás lekérdezés helyesnek látszik — a hiányzó WHERE tenant_id = ? ugyanazt adja vissza. Két tenanttal, hasonló rekordokkal az izolációs hiba azonnal kibukik fejlesztés közben, nem élesben.

Előző10. modul — Auth és session Nuxt-módra Következő 12. modul — Konfiguráció és a négy szint