Skip to content

Commit d44c51b

Browse files
Merge pull request #188 from renganathc/feature/db-export-pipeline
feat: Implement queue-based async DB export pipeline
2 parents 75c1e5e + 91c0983 commit d44c51b

15 files changed

Lines changed: 910 additions & 866 deletions

File tree

apps/consumer/Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /app
4+
5+
# Copy root package files
6+
COPY package.json package-lock.json ./
7+
8+
# Copy workspace package.json files
9+
COPY packages/common/package.json ./packages/common/
10+
COPY apps/consumer/package.json ./apps/consumer/
11+
12+
RUN npm ci
13+
14+
# Copy actual source code
15+
COPY packages/common ./packages/common
16+
COPY apps/consumer ./apps/consumer
17+
18+
WORKDIR /app/apps/consumer
19+
20+
CMD ["npm", "run", "start"]

apps/consumer/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "consumer",
3+
"version": "1.0.0",
4+
"description": "",
5+
"main": "src/app.js",
6+
"scripts": {
7+
"dev": "node src/index.js",
8+
"start": "node src/index.js",
9+
"test": "jest --testPathPatterns=src/"
10+
},
11+
"keywords": [],
12+
"author": "",
13+
"license": "ISC",
14+
"type": "commonjs"
15+
}

apps/consumer/src/index.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const dotenv = require('dotenv');
2+
dotenv.config({ path: require('path').join(__dirname, '../../../.env') });
3+
4+
const { validateEnv } = require('@urbackend/common');
5+
6+
if (process.env.NODE_ENV !== 'test') {
7+
validateEnv();
8+
}
9+
10+
const { initExportWorker } = require('./workers/export.worker');
11+
12+
const { connectDB } = require('@urbackend/common');
13+
14+
(async () => {
15+
try {
16+
await connectDB();
17+
18+
const worker = initExportWorker();
19+
20+
console.log('[CONSUMER] Export worker started and listening for jobs...');
21+
22+
const shutdown = async () => {
23+
console.log('Shutting down worker...');
24+
await worker.close();
25+
process.exit(0);
26+
};
27+
28+
process.on('SIGINT', shutdown);
29+
process.on('SIGTERM', shutdown);
30+
31+
} catch (err) {
32+
console.error('Failed to start worker:', err);
33+
process.exit(1);
34+
}
35+
})();
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
const { Worker } = require('bullmq');
2+
const { PassThrough } = require('stream');
3+
const fs = require('fs');
4+
const os = require('os');
5+
const path = require('path');
6+
const { GetObjectCommand } = require('@aws-sdk/client-s3');
7+
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
8+
9+
const {
10+
redis,
11+
exportQueue,
12+
emailQueue,
13+
Project,
14+
getConnection,
15+
getCompiledModel,
16+
getS3CompatibleStorage,
17+
getStorage,
18+
decrypt,
19+
getBucket
20+
} = require('@urbackend/common');
21+
22+
const initExportWorker = () => {
23+
const worker = new Worker(exportQueue.name, async (job) => {
24+
const { projectId, collectionName, userId, email } = job.data;
25+
console.log(`[ExportWorker] Starting export for collection ${collectionName} in project ${projectId} requested by ${email}`);
26+
27+
const project = await Project.findById(projectId);
28+
if (!project) throw new Error('Project not found');
29+
30+
const col = project.collections.find(c => c.name === collectionName);
31+
if (!col) throw new Error(`Collection ${collectionName} not found`);
32+
33+
const connection = await getConnection(projectId);
34+
const bucket = getBucket(project);
35+
const storagePath = `${projectId}/exports/${collectionName}_export_${Date.now()}.json`;
36+
37+
let provider = 'supabase';
38+
if (project.resources?.storage?.isExternal) {
39+
try {
40+
const decrypted = decrypt(project.resources.storage.config);
41+
const config = JSON.parse(decrypted);
42+
provider = config.storageProvider || 'supabase';
43+
} catch (err) {
44+
console.error("[ExportWorker] Error decrypting storage config:", err);
45+
}
46+
}
47+
48+
const client = await getStorage(project);
49+
50+
console.log(`[ExportWorker] Preparing upload to storage (Provider: ${provider})...`);
51+
52+
if (provider === 'supabase') {
53+
const tempFilePath = path.join(os.tmpdir(), `export_${projectId}_${collectionName}_${Date.now()}.json`);
54+
const writeStream = fs.createWriteStream(tempFilePath);
55+
56+
try {
57+
writeStream.write('{\n');
58+
const Model = getCompiledModel(connection, col, projectId, project.resources.db.isExternal);
59+
60+
writeStream.write(` "${col.name}": [\n`);
61+
62+
const cursor = Model.find().lean().cursor();
63+
let first = true;
64+
65+
for await (const doc of cursor) {
66+
if (!first) writeStream.write(',\n');
67+
writeStream.write(` ${JSON.stringify(doc)}`);
68+
first = false;
69+
}
70+
71+
writeStream.write('\n ]\n');
72+
writeStream.write('}\n');
73+
writeStream.end();
74+
75+
await new Promise((resolve, reject) => {
76+
writeStream.on('finish', resolve);
77+
writeStream.on('error', reject);
78+
});
79+
80+
console.log(`[ExportWorker] Temp file created, uploading...`);
81+
const fileBuffer = fs.readFileSync(tempFilePath);
82+
83+
const { error } = await client.storage.from(bucket).upload(storagePath, fileBuffer, {
84+
contentType: 'application/json'
85+
});
86+
87+
if (error) throw error;
88+
} finally {
89+
if (fs.existsSync(tempFilePath)) {
90+
fs.unlinkSync(tempFilePath);
91+
}
92+
}
93+
94+
} else if (provider === 's3' || provider === 'cloudflare_r2') {
95+
const passThrough = new PassThrough();
96+
97+
// Start the upload promise in parallel using the getStorage client
98+
const uploadPromise = client.storage.from(bucket).upload(storagePath, passThrough, {
99+
contentType: 'application/json'
100+
});
101+
102+
try {
103+
passThrough.write('{\n');
104+
105+
const Model = getCompiledModel(connection, col, projectId, project.resources.db.isExternal);
106+
107+
passThrough.write(` "${col.name}": [\n`);
108+
109+
const cursor = Model.find().lean().cursor();
110+
let first = true;
111+
112+
for await (const doc of cursor) {
113+
if (!first) passThrough.write(',\n');
114+
passThrough.write(` ${JSON.stringify(doc)}`);
115+
first = false;
116+
}
117+
118+
passThrough.write('\n ]\n');
119+
120+
passThrough.write('}\n');
121+
passThrough.end();
122+
123+
console.log(`[ExportWorker] Database stream ended. Awaiting final storage upload...`);
124+
const { error } = await uploadPromise;
125+
if (error) throw error;
126+
} catch (error) {
127+
passThrough.destroy(error);
128+
throw error;
129+
}
130+
} else {
131+
throw new Error(`Unknown storage provider: ${provider}`);
132+
}
133+
134+
let downloadUrl;
135+
if (provider === 'supabase') {
136+
const { data, error } = await client.storage.from(bucket).createSignedUrl(storagePath, 86400);
137+
if (error) throw error;
138+
downloadUrl = data?.signedUrl;
139+
} else {
140+
const { s3Client } = await getS3CompatibleStorage(project);
141+
const command = new GetObjectCommand({ Bucket: bucket, Key: storagePath });
142+
downloadUrl = await getSignedUrl(s3Client, command, { expiresIn: 86400 });
143+
}
144+
145+
// queue the email to be sent to the user
146+
await emailQueue.add('send-export-email', { email, downloadUrl, projectName: project.name });
147+
console.log(`[ExportWorker] Export completed! Email queued for ${email}`);
148+
}, { connection: redis, concurrency: 2 });
149+
150+
worker.on('completed', (job) => {
151+
console.log(`[ExportWorker] Job ${job.id} for project ${job.data.projectId} completed.`);
152+
});
153+
154+
worker.on('failed', (job, err) => {
155+
console.error(`[ExportWorker] Job ${job?.id} for project ${job?.data?.projectId} failed:`, err.message);
156+
});
157+
158+
return worker;
159+
};
160+
161+
module.exports = { initExportWorker };

apps/dashboard-api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"transform": {}
1515
},
1616
"dependencies": {
17+
"@bull-board/api": "^7.1.5",
18+
"@bull-board/express": "^7.1.5",
1719
"@kiroo/sdk": "^0.1.2",
1820
"@supabase/supabase-js": "^2.84.0",
1921
"@urbackend/common": "*",

apps/dashboard-api/src/app.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ app.use('/api/admin/metrics', dashboardLimiter, adminMetricsRoute);
119119

120120

121121

122-
123122
app.get('/api/server-ip', async (req, res) => {
124123
const ip = await getPublicIp();
125124
res.json({ ip });
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const { AppError } = require('@urbackend/common');
2+
const { Developer } = require('@urbackend/common');
3+
const { Project } = require('@urbackend/common');
4+
const { exportQueue } = require('@urbackend/common');
5+
const { redis } = require('@urbackend/common');
6+
const { getProjectById, setProjectById } = require('@urbackend/common');
7+
8+
module.exports.dbExportHandler = async (req, res, next) => {
9+
try {
10+
const { projectId, collectionName } = req.params;
11+
const { _id: userId } = req.user;
12+
13+
let project = await getProjectById(projectId);
14+
if (!project) {
15+
project = await Project.findById(projectId).lean();
16+
if (!project) {
17+
return next(new AppError(404, "Project not found."));
18+
}
19+
await setProjectById(projectId, project);
20+
}
21+
22+
if (project.owner.toString() !== userId.toString()) {
23+
return next(new AppError(403, "Access denied. You are not the owner of this project."));
24+
}
25+
26+
if (!project.collections.some(c => c.name === collectionName)) {
27+
return next(new AppError(404, "Collection not found in project."));
28+
}
29+
30+
31+
const developer = await Developer.findById(userId).select('email plan').lean();
32+
if (!developer) {
33+
return next(new AppError(404, "Authenticated developer not found."));
34+
}
35+
const { email, plan = 'free' } = developer;
36+
37+
console.log(`[Dashboard API] Received export request for collection ${collectionName} in project ${projectId} from user ${userId} (${email})`);
38+
39+
40+
const maxExports = plan === 'pro' ? 5 : 1;
41+
const today = new Date().toISOString().split('T')[0];
42+
const key = `project:${projectId}:export_limit:${today}`;
43+
44+
const currentCount = await redis.get(key);
45+
if (currentCount && Number(currentCount) >= maxExports) {
46+
return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
47+
}
48+
49+
const newCount = await redis.incr(key);
50+
if (newCount === 1) {
51+
await redis.expire(key, 86400); // Set expiry to 24 hours
52+
}
53+
54+
await exportQueue.add('export-database', { projectId, collectionName, userId, email });
55+
56+
return res.status(202).json({
57+
success: true,
58+
data: {},
59+
message: `Collection export request received. You will receive an email with a download link shortly. Usage today: ${newCount}/${maxExports}.`,
60+
});
61+
62+
} catch (err) {
63+
console.error("[Dashboard API] Error handling export request for project - ", req.params.projectId, ": ", err);
64+
return next(new AppError(500, err.message || "Failed to initiate database export."));
65+
}
66+
};

apps/dashboard-api/src/routes/projects.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const {
5050

5151
const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');
5252

53+
const exportController = require('../controllers/dbExport.controller');
5354

5455
// POST REQ FOR CREATE PROJECT
5556
router.post('/', authMiddleware, verifyEmail, planEnforcement.checkProjectLimit, createProject);
@@ -152,4 +153,7 @@ router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmi
152153
router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions);
153154
router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, revokeUserSession);
154155

156+
// POST req for DB EXPORT
157+
router.post('/:projectId/collections/:collectionName/export', authMiddleware, exportController.dbExportHandler);
158+
155159
module.exports = router;

0 commit comments

Comments
 (0)