server/-benA 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.
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…
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 {}
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 cél, hogy mindenhol useDb(event)-et hívj, és a wrapper eldöntse a többit.
// 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.
// 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"
}
]
}
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] }),
}))
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 Drizzle-ben kétféleképpen kérdezhetsz, és ez elsőre zavaró. Nem verseng egymással a kettő, más a dolguk:
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)
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 ezt | Amikor |
|---|---|
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. |
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.// 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ó.
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)
})
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 })
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 | |
|---|---|---|
| Import | drizzle-orm/d1, sqlite-core | drizzle-orm/postgres-js, pg-core |
| Tábla | sqliteTable | pgTable |
| 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 }) |
| Enum | text(…, { enum: [...] }) — csak típusszinten | pgEnum — valódi DB-típus |
| JSON | text(…, { mode: 'json' }) | jsonb() — indexelhető is |
| Atomicitás | db.batch([...]) | db.transaction(...) (11.6) |
| Teljes szöveges keresés | FTS5 virtuális tábla | tsvector + GIN index |
where hiánya nem hiba. Egy db.delete(projects) where nélkül lefordul és lefut — kitörli a táblát. Ezért van a 11.7-es tenant-hatókörű wrapper: ott nincs olyan hívás, amiben ne lenne feltétel.db.query.* csak akkor működik, ha a sémát átadtad. A drizzle(binding, { schema }) második paramétere nem opcionális kényelem — enélkül a db.query objektum üres, és a hibaüzenet nem lesz beszédes.with alapból mindent hoz. Ha nem adsz columns fehérlistát a kapcsolt entitáshoz, a teljes sor jön — jelszó-hash-sel együtt (5. modul). A with: { owner: true } kényelmes és veszélyes.shared/ vagy kliensoldali fájl értékként importálja a sémát (nem import type-pal), a teljes drizzle-orm bekerül a kliens-bundle-be. Ez csendben történik; a 15. modul bundle-elemzése az, ami észreveszi.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.
| ORM | Workers-en | Amit tudni kell |
|---|---|---|
| Drizzle | Natív | D1-hez és Hyperdrive-hoz is; SQL-közeli, kicsi bundle. Ez az alapértelmezett választás új kódra. |
| Kysely | Natív | Tiszta query builder, ha nem akarsz ORM-réteget. Szintén kicsi. |
| Prisma | Driver-adapterrel | Megy (D1, pg adapter), de nagyobb bundle és nehezebb indulás. Az Accelerate-út egy plusz hálózati ugrás. |
| TypeORM | Nem 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, MikroORM | Gyakorlatilag nem | Ugyanazok a szerkezeti akadályok, kidolgozott megkerülő út nélkül. |
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 },
})
}
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'),
},
})
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.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'],
},
DataSource — nem singletonEz 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)
})
})
A recept működik, de nem ingyenes. Három tétele van, és mindhárom mérhető:
| Ár | Miért | Mit 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 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
.cpuprofile elemzésével együtt.initialize() nélkül ad ugyanannyit.pg-re épül; MySQL-nél a mysql2 további megkötéseket hoz (például a disableEval kényszert).server/
database/
schema.ts ← Drizzle-séma. NE tedd a shared/-be!
migrations/
0000_init.sql
0001_add_projects.sql
utils/
db.ts
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
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álsz | Mi történik | Helyette |
|---|---|---|
fs, path fájlműveletre | Nincs fájlrendszer | useStorage() KV/R2 driverrel, vagy R2 közvetlenül |
Natív addon (sharp, bcrypt, canvas) | Nem fordul le / nem fut | Cloudflare 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-k | Almodulok célzottan, vagy natív binding (R2 az S3 helyett) |
Séma vagy ORM a shared/-ből | Bekerül a kliens-bundle-be | Csak típusokat ossz meg |
| Ritkán használt nagy könyvtár | Minden kérésnél betöltendő kód | await 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))
})
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 ágakkal | Nincs |
| Atomi több-utasításos írás | Tranzakcióban | db.batch([...]) — előre összeállított lista |
| „Olvasok, döntök, írok” | Egy tranzakcióban | Alkalmazás-szinten: feltételes UPDATE … WHERE, verziómező |
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))
})
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)),
])
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.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.
// 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
})
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);
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.| Amit akarsz | D1 | Hyperdrive + Postgres |
|---|---|---|
| Lokális adatbázis | wrangler d1 … --local (a .wrangler/ alatt) | localConnectionString a wrangler-configban |
| Seed-adat | wrangler d1 execute APP_DB --local --file=seed.sql | Sima SQL-fájl a lokális Postgresbe |
| Éles adat megnézése | wrangler d1 execute APP_DB --remote --command="…" | Bármilyen SQL-eszköz, közvetlenül |
| Fejlesztés éles erőforrással | Remote bindings (12. és 18. modul) | Távoli kapcsolat is megadható wrangler dev-hez |
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.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.
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.
shared/-be?
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.
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ó.
① 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.
① 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.
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.
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.