diff --git a/prisma/migrations/0336_offhost_backup_ledger/migration.sql b/prisma/migrations/0336_offhost_backup_ledger/migration.sql index 9239d5eaf..dd0ad6f39 100644 --- a/prisma/migrations/0336_offhost_backup_ledger/migration.sql +++ b/prisma/migrations/0336_offhost_backup_ledger/migration.sql @@ -5,7 +5,10 @@ CREATE TABLE "offhost_backup_state" ( "user_id" TEXT NOT NULL, "last_success_at" TIMESTAMP(3) NOT NULL, - "size_bytes" INTEGER NOT NULL, + -- BIGINT, not INTEGER: a single object may occupy 8 MiB x 10 000 multipart + -- parts, so the uploader permits 80 GB and a 32-bit column would refuse + -- every account past 2.1 GB. + "size_bytes" BIGINT NOT NULL, CONSTRAINT "offhost_backup_state_pkey" PRIMARY KEY ("user_id") ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d38625d1..540af229c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4958,8 +4958,12 @@ model OffhostBackupState { lastSuccessAt DateTime @map("last_success_at") /// Size of that object in bytes — the same number the run reports as its - /// largest, per account. Consumer: the off-host card's size column. - sizeBytes Int @map("size_bytes") + /// largest, per account. `BigInt` because the uploader permits an object of + /// up to 8 MiB × 10 000 parts = 80 GB and a 32-bit column stops at 2.1 GB: + /// the biggest account on the host, the one an operator cares most about, + /// is exactly the one whose row would refuse to be written. + /// Consumer: the off-host card's size column. + sizeBytes BigInt @map("size_bytes") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts index e96b759e4..adbdd45e4 100644 --- a/src/app/api/admin/backups/__tests__/route.test.ts +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -64,7 +64,9 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-one", offhostBackupState: { lastSuccessAt: new Date(now - 3 * HOUR), - sizeBytes: 4096, + // The column is BigInt — an object may be 80 GB — and the wire type + // is a plain number. + sizeBytes: BigInt(4096), }, }, { @@ -72,7 +74,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-two", offhostBackupState: { lastSuccessAt: new Date(now - 30 * HOUR), - sizeBytes: 8192, + sizeBytes: BigInt(8192), }, }, { @@ -80,7 +82,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-three", offhostBackupState: { lastSuccessAt: new Date(now - 100 * HOUR), - sizeBytes: 128, + sizeBytes: BigInt(128), }, }, { id: "u4", username: "account-four", offhostBackupState: null }, @@ -99,6 +101,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { ["account-four", "never"], ]); expect(body.offhost.rows[0]?.sizeBytes).toBe(4096); + expect(typeof body.offhost.rows[0]?.sizeBytes).toBe("number"); expect(body.offhost.rows[0]?.ageHours).toBe(3); expect(body.offhost.rows[3]?.lastSuccessAt).toBeNull(); expect(body.offhost.rows[3]?.sizeBytes).toBeNull(); @@ -126,7 +129,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { username: "account-one", offhostBackupState: { lastSuccessAt: new Date(), - sizeBytes: 1, + sizeBytes: BigInt(1), }, }, ]); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index b1885683f..bebfffa5b 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -112,7 +112,10 @@ export const GET = apiHandler(async () => { userId: account.id, username: account.username, lastSuccessAt: state?.lastSuccessAt.toISOString() ?? null, - sizeBytes: state?.sizeBytes ?? null, + // BigInt column -> number on the wire. The value is bytes of one + // object, capped by the uploader at 80 GB, so it is nowhere near + // Number.MAX_SAFE_INTEGER and JSON has no BigInt. + sizeBytes: state ? Number(state.sizeBytes) : null, ageHours: verdict.ageHours, freshness: verdict.freshness, }; diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 118b0f1ea..508e28bab 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -240,9 +240,11 @@ describe("runOffhostBackup", () => { ); expect(ledgered).toEqual(["u1", "u2"]); const first = prisma.offhostBackupState.upsert.mock.calls[0]?.[0] as { - create: { sizeBytes: number; lastSuccessAt: Date }; + create: { sizeBytes: bigint; lastSuccessAt: Date }; }; - expect(first.create.sizeBytes).toBe(ct.byteLength); + // BigInt, because the uploader permits an 80 GB object and a 32-bit + // column would refuse the biggest account on the host. + expect(first.create.sizeBytes).toBe(BigInt(ct.byteLength)); expect(first.create.lastSuccessAt).toBeInstanceOf(Date); }); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 863c22c65..e46e5cc96 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -623,7 +623,7 @@ export async function runOffhostBackup( // two are hours apart and the row is meant to say when this account's // object landed. if (objectBytes !== null) { - const bytes = objectBytes; + const bytes = BigInt(objectBytes); try { await prisma.offhostBackupState.upsert({ where: { userId: user.id }, diff --git a/tests/integration/offhost-backup-ledger.test.ts b/tests/integration/offhost-backup-ledger.test.ts index 2c8f8c186..d588eaf89 100644 --- a/tests/integration/offhost-backup-ledger.test.ts +++ b/tests/integration/offhost-backup-ledger.test.ts @@ -104,7 +104,7 @@ describe("off-host backup ledger (real Postgres)", () => { `${new Date().toISOString().slice(0, 10)}/user-${row.userId}.json.enc`, ); expect(object).toBeDefined(); - expect(row.sizeBytes).toBe(object?.byteLength); + expect(Number(row.sizeBytes)).toBe(object?.byteLength); expect( classifyOffhostBackup({