You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Database Integration — Step by Step Implementation
Each step has: what to do → the code → how to test before moving on.
Data Flow
There are three separate flows. Each is triggered by a different user action.
Flow 1 — Connect (save credentials)
User fills the form and clicks Connect.
[Browser]
│
│ POST /api/integrations/database/connect
│ { host, port, database, user, password, ssl, orgName }
│
▼
[database-router.ts — /connect]
│
├─ 1. Validate body with Zod schema
│ missing field → 400 immediately, nothing written to DB
│
├─ 2. Lookup org by lowercaseName
│ not found → 404
│
├─ 3. Open a pg.Client with the provided credentials
│ run SELECT 1
│ ┌─ throws → return { ok: false, error: "Cannot connect: ..." } 400
│ └─ passes → close client
│
├─ 4. encryptConfig({ host, port, database, user, password, ssl })
│ AES-256-GCM → { iv, data, tag, __encrypted: "true" }
│ [server/src/utils/crypto.ts]
│
├─ 5. prisma.integration.upsert
│ where: { orgId, type: "DATABASE" }
│ config = encrypted blob
│ [Integration table in our DB]
│
└─ return { ok: true }
[Browser]
toast.success("Database connected!")
drawer closes
useFindManyIntegration refetches → card shows "Disconnect"
Flow 2 — List Tables
User opens the drawer after connecting, or the frontend calls this before showing the Sync UI.
[Browser]
│
│ GET /api/integrations/database/tables?orgName=xxx
│
▼
[database-router.ts — /tables]
│
├─ 1. Lookup org
│
├─ 2. prisma.integration.findUnique({ orgId, type: "DATABASE" })
│ not found → 404 "Not connected"
│
├─ 3. decryptConfig(integration.config)
│ AES-256-GCM decrypt → { host, port, database, user, password, ssl }
│ [server/src/utils/crypto.ts]
│
├─ 4. Open pg.Client with decrypted credentials
│
├─ 5. Query information_schema.tables + pg_class for row estimates
│ SELECT table_name, reltuples AS row_estimate
│ WHERE table_schema = 'public'
│
└─ return { ok: true, tables: [{ table_name, row_estimate }, ...] }
[Browser]
renders table list with checkboxes
user selects which tables to sync
Flow 3 — Sync (import rows)
User selects tables and clicks Sync Now.
[Browser]
│
│ POST /api/integrations/database/sync
│ { orgName, tables: ["users", "orders"] }
│
▼
[database-router.ts — /sync]
│
├─ 1. Lookup org + integration (same as /tables)
│
├─ 2. decryptConfig → open pg.Client
│
├─ 3. For each table in tables[]:
│ │
│ ├─ a. Discover primary key
│ │ SELECT column_name FROM information_schema WHERE constraint_type = 'PRIMARY KEY'
│ │ fallback: "id"
│ │
│ └─ b. Batch loop (500 rows at a time):
│ SELECT * FROM "tableName" ORDER BY pk LIMIT 500 OFFSET N
│ │
│ ▼
│ [DatabaseAdapter.normalize()] database.ts
│ converts each row →
│ NormalizedMessage {
│ sourceId: "users:42" ← dedup key
│ conversationId: "users" ← table name
│ sender: "database"
│ content: '{"id":42,"name":"Alice"}'
│ metadata: { tableName, primaryKey, primaryKeyValue, syncedAt }
│ rawData: { id: 42, name: "Alice" }
│ }
│ │
│ ▼
│ [IntegrationMessageService.ingest()] message-ingestion.ts
│ ├─ pre-filter: findMany existing sourceIds
│ ├─ remove already-seen rows
│ └─ createMany({ skipDuplicates: true })
│ → IntegrationMessage table in our DB
│
├─ 4. Close pg.Client
│
├─ 5. Update Integration.config with lastSyncAt timestamp
│
└─ return { ok: true, summary: { users: { inserted: 38, deduped: 4 } } }
[Browser]
toast.success("Sync complete")
shows summary per table
Where data lives after sync
External DB (org's PostgreSQL) Our DB (Coyax)
───────────────────────────── ──────────────────────────────────────
users table IntegrationMessage table
id | name | email sourceId = "users:1"
1 | Alice | alice@x.com ──────► conversationId = "users"
2 | Bob | bob@x.com content = '{"id":1,...}'
metadata = { tableName, pk }
orders table rawData = { id:1, name:... }
id | amount | status integrationId = Integration.id
1 | 500 | paid ──────► orgId = org.clerkId
2 | 200 | pending sourceType = DATABASE
status = RECEIVED
How this connects to the existing Gmail/Slack pipeline
Gmail ──► GmailAdapter.normalize() ──►┐
Slack ──► SlackAdapter.normalize() ──►├──► IntegrationMessageService.ingest()
Database ► DatabaseAdapter.normalize() ──►┘ └──► IntegrationMessage table
All three sources write to the same table.
All three use the same deduplication (sourceId unique constraint).
Database sync does NOT go through BullMQ — it is synchronous in the HTTP request.
Gmail/Slack go through BullMQ → Worker → TriggerResolver → WorkflowEngine (future for DB too).
cd server
bunx prisma migrate dev --name add_database_integration_type
bunx zenstack generate
Test — open a bun shell and verify Prisma knows about it:
cd server
bun run -e "import { IntegrationType } from '@prisma/client'; console.log(IntegrationType.DATABASE)"# Expected output: DATABASE
If it prints DATABASE, the enum is live. If it throws, the migration did not run.
Step 2 — Create the crypto helper
Passwords cannot be stored as plaintext. Before writing the router, we need an encryptConfig / decryptConfig utility.
File:server/src/utils/crypto.ts (new file)
import{createCipheriv,createDecipheriv,randomBytes}from"crypto";constALGORITHM="aes-256-gcm";functiongetKey(): Buffer{constkey=process.env.INTEGRATION_ENCRYPTION_KEY;if(!key)thrownewError("INTEGRATION_ENCRYPTION_KEY env var is not set");constbuf=Buffer.from(key,"hex");if(buf.length!==32)thrownewError("INTEGRATION_ENCRYPTION_KEY must be 32 bytes (64 hex chars)");returnbuf;}exportfunctionencryptConfig(data: Record<string,unknown>): Record<string,string>{constiv=randomBytes(12);constcipher=createCipheriv(ALGORITHM,getKey(),iv);constjson=JSON.stringify(data);constencrypted=Buffer.concat([cipher.update(json,"utf8"),cipher.final()]);constauthTag=cipher.getAuthTag();return{iv: iv.toString("hex"),data: encrypted.toString("hex"),tag: authTag.toString("hex"),__encrypted: "true",};}exportfunctiondecryptConfig(config: Record<string,string>): Record<string,unknown>{if(config.__encrypted!=="true")returnconfig;// not encrypted — pass through (dev only)constdecipher=createDecipheriv(ALGORITHM,getKey(),Buffer.from(config.iv,"hex"));decipher.setAuthTag(Buffer.from(config.tag,"hex"));constdecrypted=Buffer.concat([decipher.update(Buffer.from(config.data,"hex")),decipher.final(),]);returnJSON.parse(decrypted.toString("utf8"));}
Add to your .env:
# Generate a key once: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATION_ENCRYPTION_KEY=your_64_hex_char_key_here
Test file:server/src/utils/crypto.test.ts
import{describe,expect,test}from"bun:test";import{encryptConfig,decryptConfig}from"./crypto";// Set a test key before importingprocess.env.INTEGRATION_ENCRYPTION_KEY="a".repeat(64);// 32 bytes as hexdescribe("crypto helpers",()=>{test("encryptConfig returns iv, data, tag, __encrypted",()=>{constresult=encryptConfig({host: "localhost",password: "secret"});expect(result.__encrypted).toBe("true");expect(result.iv).toBeDefined();expect(result.data).toBeDefined();expect(result.tag).toBeDefined();// raw password must not appear in any fieldexpect(JSON.stringify(result)).not.toContain("secret");});test("decryptConfig round-trips correctly",()=>{constoriginal={host: "localhost",port: 5432,password: "secret",ssl: false};constencrypted=encryptConfig(original);constdecrypted=decryptConfig(encrypted);expect(decrypted).toEqual(original);});test("decryptConfig passes through unencrypted config (dev mode)",()=>{constplain={host: "localhost"};expect(decryptConfig(plainasany)).toEqual(plain);});test("decryptConfig throws if tag is tampered",()=>{constencrypted=encryptConfig({host: "localhost"});encrypted.tag="00".repeat(16);// corrupt the auth tagexpect(()=>decryptConfig(encrypted)).toThrow();});});
Run:
cd server && bun test src/utils/crypto.test.ts
# All 4 tests should pass
Step 3 — Create the DatabaseAdapter
File:server/src/integrations/database/database.ts (new file)
importtype{IntegrationAdapter,IntegrationContext,NormalizedMessage}from"../base/types";interfaceDbRowPayload{tableName: string;primaryKey: string;row: Record<string,unknown>;}exportclassDatabaseAdapterimplementsIntegrationAdapter{type="DATABASE"asconst;asyncnormalize(payload: unknown,_ctx: IntegrationContext): Promise<NormalizedMessage[]>{const{ tableName, primaryKey, row }=payloadasDbRowPayload;constpkValue=row[primaryKey];return[{sourceId: `${tableName}:${pkValue}`,// dedup keyconversationId: tableName,// group all rows of same table togethersender: "database",content: JSON.stringify(row),// full row stored as JSON stringmetadata: {
tableName,
primaryKey,primaryKeyValue: pkValue,syncedAt: newDate().toISOString(),},rawData: row,sourceDate: newDate(),},];}}
Test file:server/src/integrations/database/database.test.ts
import{describe,expect,test}from"bun:test";import{DatabaseAdapter}from"./database";constadapter=newDatabaseAdapter();constctx={orgId: "org_test"};describe("DatabaseAdapter.normalize()",()=>{test("type is DATABASE",()=>{expect(adapter.type).toBe("DATABASE");});test("sourceId is tableName:pkValue",async()=>{constresult=awaitadapter.normalize({tableName: "users",primaryKey: "id",row: {id: 42,name: "Alice"}},ctx);expect(result[0].sourceId).toBe("users:42");});test("conversationId is the table name",async()=>{constresult=awaitadapter.normalize({tableName: "orders",primaryKey: "id",row: {id: 1}},ctx);expect(result[0].conversationId).toBe("orders");});test("content is the full row as JSON",async()=>{constrow={id: 1,name: "Alice",email: "alice@example.com"};constresult=awaitadapter.normalize({tableName: "users",primaryKey: "id", row },ctx);expect(JSON.parse(result[0].content)).toEqual(row);});test("metadata contains tableName, primaryKey, primaryKeyValue",async()=>{constresult=awaitadapter.normalize({tableName: "products",primaryKey: "sku",row: {sku: "ABC-1",price: 99}},ctx);expect(result[0].metadata?.tableName).toBe("products");expect(result[0].metadata?.primaryKey).toBe("sku");expect(result[0].metadata?.primaryKeyValue).toBe("ABC-1");});test("returns exactly one NormalizedMessage per row",async()=>{constresult=awaitadapter.normalize({tableName: "users",primaryKey: "id",row: {id: 1}},ctx);expect(result).toHaveLength(1);});test("sender is always 'database'",async()=>{constresult=awaitadapter.normalize({tableName: "users",primaryKey: "id",row: {id: 1}},ctx);expect(result[0].sender).toBe("database");});});
Run:
cd server && bun test src/integrations/database/database.test.ts
# All 6 tests should pass
// server/src/integrations/database/database-registry.test.tsimport{describe,expect,test}from"bun:test";// Import the index — this triggers all registrationsimport"~/server/integrations";import{IntegrationRegistry}from"../base/registry";describe("IntegrationRegistry — DATABASE",()=>{test("DATABASE adapter is registered",()=>{constadapter=IntegrationRegistry.get("DATABASE");expect(adapter).toBeDefined();expect(adapter.type).toBe("DATABASE");});test("get() throws for unregistered type",()=>{expect(()=>IntegrationRegistry.get("NOTION"asany)).toThrow();});});
Run:
cd server && bun test src/integrations/database/database-registry.test.ts
Step 5 — Create the router
File:server/src/integrations/database/database-router.ts (new file)
import{Hono}from"hono";import{z}from"zod";import{Client}from"pg";importtype{Bindings}from"~server/index";import{getPrisma}from"~server/utils/utils";import{IntegrationMessageService}from"../message-ingestion";import{DatabaseAdapter}from"./database";import{encryptConfig,decryptConfig}from"~server/utils/crypto";exportconstdatabaseIntegrationRouter=newHono<{Bindings: Bindings}>();constDbCredentialsSchema=z.object({host: z.string().min(1),port: z.coerce.number().int().min(1).max(65535).default(5432),database: z.string().min(1),user: z.string().min(1),password: z.string().min(1),ssl: z.boolean().default(false),orgName: z.string().min(1),});// ─── POST /api/integrations/database/connect ──────────────────────────────────databaseIntegrationRouter.post("/api/integrations/database/connect",async(c)=>{constprisma=getPrisma(c);constparsed=DbCredentialsSchema.safeParse(awaitc.req.json());if(!parsed.success)returnc.json({ok: false,error: parsed.error.flatten()},400);const{ host, port, database, user, password, ssl, orgName }=parsed.data;constorg=awaitprisma.org.findFirst({where: {lowercaseName: orgName.toLowerCase()},select: {clerkId: true},});if(!org)returnc.json({ok: false,error: "Org not found"},404);// Test the connection before saving anythingconstclient=newClient({
host, port, database, user, password,ssl: ssl ? {rejectUnauthorized: false} : false,connectionTimeoutMillis: 10_000,query_timeout: 10_000,});try{awaitclient.connect();awaitclient.query("SELECT 1");awaitclient.end();}catch(err){returnc.json({ok: false,error: `Cannot connect: ${(errasError).message}`},400);}constencryptedConfig=encryptConfig({
host, port, database, user, password, ssl,connectedAt: newDate().toISOString(),});awaitprisma.integration.upsert({where: {orgId_type: {orgId: org.clerkId,type: "DATABASE"}},create: {orgId: org.clerkId,type: "DATABASE",externalAccountId: `${host}:${port}/${database}`,config: encryptedConfig,isActive: true,},update: {externalAccountId: `${host}:${port}/${database}`,config: encryptedConfig,isActive: true,},});returnc.json({ok: true});});// ─── GET /api/integrations/database/tables?orgName=xxx ────────────────────────databaseIntegrationRouter.get("/api/integrations/database/tables",async(c)=>{constprisma=getPrisma(c);constorgName=c.req.query("orgName");if(!orgName)returnc.json({ok: false,error: "Missing orgName"},400);constorg=awaitprisma.org.findFirst({where: {lowercaseName: orgName.toLowerCase()},select: {clerkId: true},});if(!org)returnc.json({ok: false,error: "Org not found"},404);constintegration=awaitprisma.integration.findUnique({where: {orgId_type: {orgId: org.clerkId,type: "DATABASE"}},select: {config: true},});if(!integration)returnc.json({ok: false,error: "Not connected"},404);constconfig=decryptConfig(integration.configasRecord<string,string>);constclient=newClient({host: config.hostasstring,port: config.portasnumber,database: config.databaseasstring,user: config.userasstring,password: config.passwordasstring,ssl: config.ssl ? {rejectUnauthorized: false} : false,connectionTimeoutMillis: 10_000,});try{awaitclient.connect();constresult=awaitclient.query(`SELECT t.table_name, (c.reltuples)::bigint AS row_estimate FROM information_schema.tables t JOIN pg_class c ON c.relname = t.table_name WHERE t.table_schema = 'public' ORDER BY t.table_name`);awaitclient.end();returnc.json({ok: true,tables: result.rows});}catch(err){returnc.json({ok: false,error: (errasError).message},500);}});// ─── POST /api/integrations/database/sync ─────────────────────────────────────databaseIntegrationRouter.post("/api/integrations/database/sync",async(c)=>{constprisma=getPrisma(c);const{ orgName, tables }=awaitc.req.json()as{orgName: string;tables: string[]};if(!orgName||!tables?.length){returnc.json({ok: false,error: "orgName and tables are required"},400);}constorg=awaitprisma.org.findFirst({where: {lowercaseName: orgName.toLowerCase()},select: {clerkId: true},});if(!org)returnc.json({ok: false,error: "Org not found"},404);constintegration=awaitprisma.integration.findUnique({where: {orgId_type: {orgId: org.clerkId,type: "DATABASE"}},select: {id: true,config: true},});if(!integration)returnc.json({ok: false,error: "Not connected"},404);constconfig=decryptConfig(integration.configasRecord<string,string>);constclient=newClient({host: config.hostasstring,port: config.portasnumber,database: config.databaseasstring,user: config.userasstring,password: config.passwordasstring,ssl: config.ssl ? {rejectUnauthorized: false} : false,connectionTimeoutMillis: 10_000,});awaitclient.connect();constadapter=newDatabaseAdapter();constingestor=newIntegrationMessageService(prisma);constBATCH_SIZE=500;constsummary: Record<string,{inserted: number;deduped: number}>={};for(consttableNameoftables){// Discover primary keyconstpkResult=awaitclient.query<{column_name: string}>(`SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name WHERE tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY' LIMIT 1`,[tableName]);constprimaryKey=pkResult.rows[0]?.column_name??"id";letoffset=0;lettableInserted=0;lettableDeduped=0;while(true){constrows=awaitclient.query<Record<string,unknown>>(`SELECT * FROM "${tableName}" ORDER BY "${primaryKey}" LIMIT $1 OFFSET $2`,[BATCH_SIZE,offset]);if(rows.rows.length===0)break;constnormalized=(awaitPromise.all(rows.rows.map((row)=>adapter.normalize({ tableName, primaryKey, row },{orgId: org.clerkId})))).flat();constresult=awaitingestor.ingest({integrationId: integration.id,orgId: org.clerkId,sourceType: "DATABASE",messages: normalized,});tableInserted+=result.inserted;tableDeduped+=result.deduped;offset+=rows.rows.length;if(rows.rows.length<BATCH_SIZE)break;}summary[tableName]={inserted: tableInserted,deduped: tableDeduped};}awaitclient.end();awaitprisma.integration.update({where: {id: integration.id},data: {config: { ...(integration.configasany),lastSyncAt: newDate().toISOString()},},});returnc.json({ok: true, summary });});
Test file:server/src/integrations/database/database-router.test.ts
import{describe,expect,mock,test,beforeEach}from"bun:test";// Set encryption key before anything importsprocess.env.INTEGRATION_ENCRYPTION_KEY="a".repeat(64);// ─── Mock pg Client ────────────────────────────────────────────────────────────constmockQuery=mock(async(sql: string,params?: unknown[])=>({rows: []}));constmockConnect=mock(async()=>{});constmockEnd=mock(async()=>{});mock.module("pg",()=>({Client: class{connect=mockConnect;end=mockEnd;query=mockQuery;},}));// ─── Mock Prisma ───────────────────────────────────────────────────────────────constfakeOrg={clerkId: "org_abc"};constfakeIntegration={id: "int_1",config: {host: "localhost",port: 5432,database: "mydb",user: "u",password: "p",ssl: false},};mock.module("~server/utils/utils",()=>({getPrisma: ()=>({org: {findFirst: async()=>fakeOrg},integration: {findUnique: async()=>fakeIntegration,upsert: async()=>fakeIntegration,update: async()=>fakeIntegration,},integrationMessage: {findMany: async()=>[],createMany: async()=>({count: 1}),},}),getEnv: ()=>({}),}));const{ databaseIntegrationRouter }=awaitimport("./database-router");// ─── Tests ─────────────────────────────────────────────────────────────────────describe("POST /api/integrations/database/connect",()=>{test("returns 400 if body is missing required fields",async()=>{constreq=newRequest("http://localhost/api/integrations/database/connect",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({host: "localhost"}),// missing user, password, database, orgName});constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(400);constjson=awaitres.json()asany;expect(json.ok).toBe(false);});test("returns 200 when connection succeeds",async()=>{mockQuery.mockResolvedValueOnce({rows: [{"?column?": 1}]});// SELECT 1constreq=newRequest("http://localhost/api/integrations/database/connect",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({host: "localhost",port: 5432,database: "mydb",user: "u",password: "p",ssl: false,orgName: "test-org",}),});constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(200);constjson=awaitres.json()asany;expect(json.ok).toBe(true);});test("returns 400 if pg connection throws",async()=>{mockConnect.mockRejectedValueOnce(newError("Connection refused"));constreq=newRequest("http://localhost/api/integrations/database/connect",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({host: "bad-host",port: 5432,database: "mydb",user: "u",password: "p",ssl: false,orgName: "test-org",}),});constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(400);constjson=awaitres.json()asany;expect(json.ok).toBe(false);expect(json.error).toContain("Connection refused");});});describe("GET /api/integrations/database/tables",()=>{test("returns table list from connected DB",async()=>{mockQuery.mockResolvedValueOnce({rows: [{table_name: "users",row_estimate: 100},{table_name: "orders",row_estimate: 500},],});constreq=newRequest("http://localhost/api/integrations/database/tables?orgName=test-org");constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(200);constjson=awaitres.json()asany;expect(json.ok).toBe(true);expect(json.tables).toHaveLength(2);expect(json.tables[0].table_name).toBe("users");});test("returns 400 if orgName is missing",async()=>{constreq=newRequest("http://localhost/api/integrations/database/tables");constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(400);});});describe("POST /api/integrations/database/sync",()=>{test("returns summary with inserted count",async()=>{// Primary key discoverymockQuery.mockResolvedValueOnce({rows: [{column_name: "id"}]});// First batch of rowsmockQuery.mockResolvedValueOnce({rows: [{id: 1,name: "Alice"},{id: 2,name: "Bob"}]});// Second batch — empty → stopmockQuery.mockResolvedValueOnce({rows: []});constreq=newRequest("http://localhost/api/integrations/database/sync",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({orgName: "test-org",tables: ["users"]}),});constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(200);constjson=awaitres.json()asany;expect(json.ok).toBe(true);expect(json.summary.users).toBeDefined();expect(json.summary.users.inserted).toBeGreaterThanOrEqual(0);});test("returns 400 if tables is empty",async()=>{constreq=newRequest("http://localhost/api/integrations/database/sync",{method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({orgName: "test-org",tables: []}),});constres=awaitdatabaseIntegrationRouter.fetch(req);expect(res.status).toBe(400);});});
Run:
cd server && bun test src/integrations/database/database-router.test.ts
Step 6 — Mount the router in the server
File:server/src/index.ts
// Add to imports (around line 23)import{databaseIntegrationRouter}from'~server/integrations/database/database-router';// Add to route mounting (around line 210, after gmailIntegrationRouter)app.route('',slackIntegrationRouter);app.route('',gmailIntegrationRouter);app.route('',databaseIntegrationRouter);// ← add
Test — start the server and hit it with curl:
cd server && bun run dev &# Test: missing body → 400
curl -s -X POST http://localhost:3000/api/integrations/database/connect \
-H "Content-Type: application/json" \
-d '{}'| jq .# Expected: { "ok": false, "error": { ... } }# Test: route exists (don't need a real DB for this check)
curl -s http://localhost:3000/api/integrations/database/tables?orgName=test | jq .# Expected: { "ok": false, "error": "Org not found" } ← 404, route is live
Change 1 — add to CONNECTIONS array (after Whatsapp, around line 73):
{title: 'DataBase',description: 'Connect a PostgreSQL database to import your data directly into Coyax.',image: '/integrations/database.png',logoScale: 1.0,},
Change 2 — add DATABASE to the query (around line 83):
Change 6 — in IntegrationCard, open the drawer for Database instead of redirecting:
In the IntegrationCard function, add a const [dbDrawerOpen, { open: openDbDrawer, close: closeDbDrawer }] = useDisclosure(false); and in the Connect button:
And render the drawer below the IntegrationSettingsDrawer:
{connection.title==='DataBase'&&(<DatabaseConnectDraweropened={dbDrawerOpen}onClose={closeDbDrawer}onSuccess={()=>queryClient.invalidateQueries({ ... })}// same invalidation as handleDisconnect/>)}
Test — visual check:
Start the dev server: bun run dev in both /server and /client
Open the integrations page
You should see a DataBase card with "Connect" button
Clicking Connect should open the drawer with the form fields
Fill in wrong credentials → "Test connection" should show an error toast
Fill in correct credentials → "Connect" should close the drawer and show success toast
After connecting, the card should show "Disconnect" instead of "Connect"
Step 8 — Add a database logo
Drop a database.png into client/public/integrations/database.png.
You can use any PostgreSQL or database icon. Free options:
Once all steps are done, run all integration tests together:
cd server
bun test src/utils/crypto.test.ts \
src/integrations/database/database.test.ts \
src/integrations/database/database-registry.test.ts \
src/integrations/database/database-router.test.ts
Database Integration — Step by Step Implementation
Each step has: what to do → the code → how to test before moving on.
Data Flow
There are three separate flows. Each is triggered by a different user action.
Flow 1 — Connect (save credentials)
User fills the form and clicks Connect.
Flow 2 — List Tables
User opens the drawer after connecting, or the frontend calls this before showing the Sync UI.
Flow 3 — Sync (import rows)
User selects tables and clicks Sync Now.
Where data lives after sync
How this connects to the existing Gmail/Slack pipeline
Step 1 — Add
DATABASEto the enumFile:
server/schema.zmodelaround line 121What to change:
Run after the change:
cd server bunx prisma migrate dev --name add_database_integration_type bunx zenstack generateTest — open a bun shell and verify Prisma knows about it:
If it prints
DATABASE, the enum is live. If it throws, the migration did not run.Step 2 — Create the crypto helper
Passwords cannot be stored as plaintext. Before writing the router, we need an
encryptConfig/decryptConfigutility.File:
server/src/utils/crypto.ts(new file)Add to your
.env:# Generate a key once: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" INTEGRATION_ENCRYPTION_KEY=your_64_hex_char_key_hereTest file:
server/src/utils/crypto.test.tsRun:
Step 3 — Create the DatabaseAdapter
File:
server/src/integrations/database/database.ts(new file)Test file:
server/src/integrations/database/database.test.tsRun:
Step 4 — Register the adapter
File:
server/src/integrations/index.tsTest — registry lookup works:
Run:
Step 5 — Create the router
File:
server/src/integrations/database/database-router.ts(new file)Test file:
server/src/integrations/database/database-router.test.tsRun:
Step 6 — Mount the router in the server
File:
server/src/index.tsTest — start the server and hit it with curl:
Step 7 — Frontend: add the Database card
File:
client/src/routes/$orgName/integrations/-integrations.tsxChange 1 — add to
CONNECTIONSarray (after Whatsapp, around line 73):Change 2 — add
DATABASEto the query (around line 83):Change 3 — add connected state (around line 91):
Change 4 — add to the
connectionsWithStatemap (around line 114):Change 5 — add
DatabaseConnectDrawerat the bottom of the file (before the last}):Change 6 — in
IntegrationCard, open the drawer for Database instead of redirecting:In the
IntegrationCardfunction, add aconst [dbDrawerOpen, { open: openDbDrawer, close: closeDbDrawer }] = useDisclosure(false);and in the Connect button:And render the drawer below the
IntegrationSettingsDrawer:Test — visual check:
bun run devin both/serverand/clientStep 8 — Add a database logo
Drop a
database.pngintoclient/public/integrations/database.png.You can use any PostgreSQL or database icon. Free options:
postgresql.svg, convert to PNGTest:
Full test run
Once all steps are done, run all integration tests together:
Expected: all tests green, no failures.
Checklist
crypto.tscreated,INTEGRATION_ENCRYPTION_KEYin.env, 4 tests passdatabase.tsadapter created, 6 tests passindex.ts, registry test passesserver/src/index.ts, curl checks pass