Browse Source

fix: change storageQuota/storageUsed/fileSize from Int to BigInt

Root cause: storageQuota was Int (32-bit, max 2.1 GB). Setting >2 GB
caused "Unable to fit integer value into INT4" errors. Migrated:
- User.storageQuota   Int → BigInt
- User.storageUsed    Int → BigInt
- Asset.fileSize      Int → BigInt

Also: added bigintToNumber() helper for JSON serialization in Express,
updated quota editor to auto-select GB/MB unit, added safeCopy() fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kingkong 1 month ago
parent
commit
644d5b229b

+ 3 - 3
packages/api/prisma/schema.prisma

@@ -18,8 +18,8 @@ model User {
   avatarUrl     String?
   globalRole    GlobalRole @default(MEMBER)
   active        Boolean   @default(true)
-  storageQuota  Int        @default(524288000) // 500 MB in bytes
-  storageUsed   Int        @default(0)         // bytes consumed
+  storageQuota  BigInt     @default(524288000) // 500 MB in bytes
+  storageUsed   BigInt     @default(0)         // bytes consumed
   createdAt     DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
 
@@ -83,7 +83,7 @@ model Asset {
   fps             Float           @default(30)
   codec           String?
   mimeType        String
-  fileSize        Int              @default(0)   // raw video file size in bytes
+  fileSize        BigInt           @default(0)   // raw video file size in bytes
   status          AssetStatus     @default(PENDING_REVIEW)
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)

+ 18 - 4
packages/api/src/lib/prisma.ts

@@ -10,9 +10,23 @@ export const prisma =
     log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
   });
 
-if (process.env.NODE_ENV !== 'production') {
-  globalForPrisma.prisma = prisma;
-}
-
 // Re-export for convenience
 export { TranscodeStatus, ResolveStatus };
+
+// ── BigInt serialization helper ────────────────────────────────────────────────
+// Recursively converts all BigInt values in an object/array to Number.
+// Safe to pass through Prisma results before sending JSON responses.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function bigintToNumber(val: unknown): any {
+  if (val === null || val === undefined) return val;
+  if (typeof val === 'bigint') return Number(val);
+  if (Array.isArray(val)) return val.map(bigintToNumber);
+  if (typeof val === 'object') {
+    const result: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(val)) {
+      result[k] = bigintToNumber(v);
+    }
+    return result;
+  }
+  return val;
+}

+ 5 - 5
packages/api/src/routes/assets.ts

@@ -247,12 +247,12 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
       return;
     }
 
-    const fileSize = req.file.size;
+    const fileSize = BigInt(req.file.size);
     if (uploader.storageUsed + fileSize > uploader.storageQuota) {
       fs.unlinkSync(req.file.path);
-      const usedMB   = (uploader.storageUsed / 1024 / 1024).toFixed(1);
-      const quotaMB   = (uploader.storageQuota / 1024 / 1024).toFixed(1);
-      const fileMB    = (fileSize / 1024 / 1024).toFixed(1);
+      const usedMB   = (Number(uploader.storageUsed) / 1024 / 1024).toFixed(1);
+      const quotaMB   = (Number(uploader.storageQuota) / 1024 / 1024).toFixed(1);
+      const fileMB    = (Number(fileSize) / 1024 / 1024).toFixed(1);
       res.status(507).json({
         error: `Storage quota exceeded. Used: ${usedMB} MB / ${quotaMB} MB. File size: ${fileMB} MB.`,
       });
@@ -487,7 +487,7 @@ router.delete('/:id', async (req: Request, res: Response) => {
     }
 
     // Decrement uploader's storageUsed
-    if (asset.fileSize > 0 && asset.uploaderId) {
+    if (BigInt(asset.fileSize) > BigInt(0) && asset.uploaderId) {
       await prisma.user.update({
         where: { id: asset.uploaderId },
         data: { storageUsed: { decrement: asset.fileSize } },

+ 4 - 4
packages/api/src/routes/auth.ts

@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
 import bcrypt from 'bcryptjs';
 import jwt from 'jsonwebtoken';
 import rateLimit from 'express-rate-limit';
-import { prisma } from '../lib/prisma';
+import { prisma, bigintToNumber } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
 const router = Router();
@@ -221,7 +221,7 @@ router.post('/login', authRateLimiter, async (req: Request, res: Response) => {
     });
 
     res.json({
-      user: {
+      user: bigintToNumber({
         id: user.id,
         email: user.email,
         name: user.name,
@@ -229,7 +229,7 @@ router.post('/login', authRateLimiter, async (req: Request, res: Response) => {
         avatarUrl: user.avatarUrl,
         storageQuota: user.storageQuota,
         storageUsed: user.storageUsed ?? 0,
-      },
+      }),
       token,
       acceptedProjects,
     });
@@ -261,7 +261,7 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
       return;
     }
 
-    res.json({ user: { ...user, storageUsed: user.storageUsed ?? 0 } });
+    res.json({ user: bigintToNumber({ ...user, storageUsed: user.storageUsed ?? 0 }) });
   } catch (err) {
     console.error('Me error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 4 - 8
packages/api/src/routes/users.ts

@@ -3,7 +3,7 @@ import bcrypt from 'bcryptjs';
 import multer from 'multer';
 import path from 'path';
 import { v4 as uuidv4 } from 'uuid';
-import { prisma } from '../lib/prisma';
+import { prisma, bigintToNumber } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
 const router = Router();
@@ -62,13 +62,13 @@ router.get('/', async (req: Request, res: Response) => {
     const usersWithStorage = users.map(u => ({
       ...u,
       storageUsed: u.projects.reduce(
-        (sum, p) => sum + p.assets.reduce((s, a) => s + a.fileSize, 0),
+        (sum, p) => sum + p.assets.reduce((s, a) => s + Number(a.fileSize), 0),
         0
       ),
       ownedProjects: u.projects.length,
     }));
 
-    res.json({ users: usersWithStorage });
+    res.json({ users: usersWithStorage.map(bigintToNumber) });
   } catch (err) {
     console.error('List users error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -146,11 +146,7 @@ router.put('/:id/quota', async (req: Request, res: Response) => {
     });
 
     res.json({
-      user: {
-        ...user,
-        storageQuota,
-        storageUsed: user.storageUsed ?? 0,
-      },
+      user: bigintToNumber({ ...user, storageQuota, storageUsed: user.storageUsed ?? BigInt(0) }),
     });
   } catch (err) {
     console.error('Update quota error:', err);

+ 3 - 3
prisma/schema.prisma

@@ -18,8 +18,8 @@ model User {
   avatarUrl     String?
   globalRole    GlobalRole @default(MEMBER)
   active        Boolean   @default(true)
-  storageQuota  Int        @default(524288000) // 500 MB in bytes
-  storageUsed   Int        @default(0)         // bytes consumed
+  storageQuota  BigInt     @default(524288000) // 500 MB in bytes
+  storageUsed   BigInt     @default(0)         // bytes consumed
   createdAt     DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
 
@@ -80,7 +80,7 @@ model Asset {
   fps             Float           @default(30)
   codec           String?
   mimeType        String
-  fileSize        Int              @default(0)   // raw video file size in bytes
+  fileSize        BigInt           @default(0)   // raw video file size in bytes
   status          AssetStatus     @default(PENDING_REVIEW)
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)