老胡茶室
老胡茶室

出海技术人的“全能基础设施套装” —— Serverless DB 篇

冯宇

书接上文,在上一篇文章中,我们解决了“部署上线”的问题,这一篇要解决的是出海产品最核心、也最容易翻车的部分——数据库架构。如果你正在用 Vercel 构建应用,那你接下来最需要的,就是一个真正适配 Serverless 架构、免维护、还能高性能扩展的云数据库方案。本文将详解 Neon 和 Supabase 两个顶级 Serverless PostgreSQL 方案,带你从选型逻辑到实战集成,一步步搭建能支撑商业化的数据库底座。

在本文中你可以了解到:

  • 什么是 Serverless 数据库?为什么 Vercel 需要 Serverless 数据库?
  • Neon 和 Supabase 的主要特点和区别
  • Drizzle ORM 的快速上手
  • Drizzle ORM 连接 Supabase,部署在 Vercel 环境
  • Polar.sh 商品同步脚本的编写和集成

PostgreSQL 云数据库解决方案 —— Neon / Supabase

对于在部署在 Vercel 的应用,我们需要选择一个 Serverless 的云数据库解决方案。那什么是 Serverless 数据库呢?和传统的独立部署的数据库服务或者 RDS 云服务有何区别呢?

关于 Serverless 数据库的概念,可以直接参考 AWS 的 Blog: 什么是无服务器数据库?,简单来说,Serverless 数据库是一种完全云原生的,按需自动扩展的数据库服务,开发者无需管理底层基础设施,可以专注于应用程序的开发。

和独立部署的数据库服务相比,Serverless 数据库是云原生的,完全免运维的,而且支持弹性伸缩,使用更简便。

和 RDS 云数据库实例相比,它的用量申请更灵活,支持按需计费,也具有更高的可扩展性。而 RDS 通常是预先配置的实例,大部分情况可能只能逐步升级和扩容,难以降级和缩容。

另外就是,Serverless 数据库完全贴合 Serverless 架构的场景,提供服务端的连接池功能,通常直接提供基于 PgBouncer 的连接池服务,传统的 RDS 通常不提供这个服务,需要自己配置 PgBouncer 实现。

而 Vercel 是典型的 Lambda 架构 (在 Vercel 中,它被称为 Vercel Functions),和传统的 C/S, B/S 架构不同,Lambda 不是长时间运行的进程服务,而是由一系列短暂的函数组成,这些函数在需要时被动态调用,执行完毕之后资源就被释放。因此在 Lambda 架构中,你无法像传统的服务端程序一样,使用任何内存来存储临时数据(如缓存,session 等),更无法使用传统的数据库连接池,因为他们是在客户端维护的长连接,使用内存来存储连接池信息。所以在 Lambda 这种短时运行的架构中,是无法使用客户端长连接实现连接池的。因此我们只能使用 Serverless 数据库提供的服务端连接池功能。

而 PostgreSQL Serverless 解决方案中,Neon 和 Supabase 是两个非常优秀的开源云数据库解决方案,而且这两家在 Vercel 都有集成,在 Vercel 的 Marketplace 中可以直接找到它们的集成,点击 Install 即可使用。集成后,你可以在 Vercel 的项目设置中配置数据库连接信息,Vercel 会自动为你创建数据库实例,并提供连接字符串。

如果不需要 Vercel 集成,你也可以直接访问它们的官网进行注册和使用,自行管理实例,在环境变量中配置好连接信息即可。

Neon 和 Supabase 的介绍与对比

Neon 和 Supabase 都是基于 PostgreSQL 的云数据库解决方案,但它们在功能和定位上有所不同。

Neon

Neon 是一个专注于 PostgreSQL 的 Serverless 数据库解决方案,提供了以下主要特点:

  • Serverless 架构:Neon 提供了完全的 Serverless 数据库服务,支持自动扩展和按需计费。
  • 分层存储:Neon 采用了分层存储架构,将热数据和冷数据分开存储,优化了性能和成本。
  • pgrag: 独家支持 pgrag 插件,如需简单的 RAG 需求可以考虑直接使用

Supabase

Supabase 是一个开源的后端即服务 (BaaS) 平台,提供了完整的后端解决方案,包括数据库、认证、存储等。通常被视作 Firebase 的开源替代品。它的主要特点包括:

  • 全栈解决方案:Supabase 提供了数据库、认证、存储、Edge Functions 等完整的后端服务,适合快速开发全栈应用。
  • 实时功能:Supabase 内置实时功能,支持实时更新和订阅,适合构建实时应用。
  • AI:Supabase 提供了 AI 相关的功能,如自动化数据标注和智能查询等,帮助开发者更高效地构建应用。

Neon vs Supabase

Neon 专注于提供数据库服务,并且还提供 Supabase 不支持的 pgrag 插件。而 Supabase 则是一个全栈解决方案,提供了更多的后端服务和功能。

费用上,两者都提供了免费额度,单论 PostgreSQL 数据库的使用来说,两者的免费额度都是 500MB 存储,免费额度都限制资源使用,supabase 免费用户限制 500MB RAM,而 Neon 限制 190 计算小时/月。

两者都提供了分支开发的功能,可以和 Git 或 GitHub 集成,支持自动化部署和分支管理功能。

特别注意 对于自动备份和分支开发的功能,两者都是付费功能,free plan 用户无法使用。

对于不活跃的项目 (1 星期没有访问),Neon 和 Supabase 都会自动暂停数据库实例,下次使用时需要重新启动实例。

Neon 是 V0 默认的 Serverless 数据库解决方案,在 v0 生成代码的过程中,如果需要连接 PostgreSQL 数据库,界面上会提示连接 Neon 数据库。

Screenshot of Neon integration in Vercel UI

Branch 支持

Neon 和 Supabase 都支持分支管理。结合 Branch Deploy 功能,你可以在每个分支上创建独立的数据库实例,方便进行分支开发和测试。

如需结合 Github Action 使用,可以参阅 Supabase 官方文档: Branching

注意: Neon 和 Supabase 的 Branch 功能是付费使用,Free Plan 用户无法使用。

Branch

Drizzle ORM 简介

Drizzle ORM 是一个为 TypeScript 生态设计的现代关系型数据库 ORM 工具。相比 Prisma 那种“黑盒”式重抽象风格,Drizzle 更加透明,写出来的查询语句几乎就是 TypeScript + SQL 的混合体,类型推导极其优秀,非常适合喜欢“手写 SQL + 类型安全”的开发者。

此外,Drizzle ORM 还对 PgVector 的提供了原生支持,方便我们在项目中使用向量数据库的功能,但需要自行在 PostgreSQL 实例安装 vector 插件。一个简单的表定义示例如下:

import { index, pgTable, serial, text, vector } from "drizzle-orm/pg-core";
export const guides = pgTable(
  "guides",
  {
    id: serial("id").primaryKey(),
    title: text("title").notNull(),
    description: text("description").notNull(),
    url: text("url").notNull(),
    embedding: vector("embedding", { dimensions: 1536 }),
  },
  (table) => [
    index("embeddingIndex").using(
      "hnsw",
      table.embedding.op("vector_cosine_ops")
    ),
  ]
);

通过 PgVector 插件,我们可以在 PostgreSQL 中实现 RAG 的功能,详情可以阅读我们之前的文章:

初始化 Drizzle ORM 工程非常简单,只需要在项目根目录下添加配置文件 drizzle.config.ts,然后安装相关依赖即可:

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
// drizzle.config.ts 示例
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/schemas/db/*.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: "postgres://postgres:password@localhost:5432/mydb",
  },
});

这个示例中,schema 指定了数据库表的 TypeScript 定义文件,out 指定了生成的迁移文件输出目录,dialect 指定了数据库类型,dbCredentials 中的 url 则是数据库连接字符串。

之后我们就可以通过 drizzle-kit 工具来生成和执行数据库迁移脚本了。此外,drizzle-kit 还提供了很多实用的功能,帮助我们快速上手和使用 Drizzle ORM。

例如:

  • drizzle-kit check: 检查数据库和 schema 的一致性。
  • drizzle-kit pull: 从数据库中拉取最新的 schema 定义。非常适合将现有数据库迁移到 Drizzle ORM。
  • drizzle-kit push: 将本地的 schema 定义推送到数据库。非常适合在开发过程中更新数据库结构,验证 schema 定义的正确性。
  • drizzle-kit studio: 启动 Drizzle Studio,提供可视化的数据库管理界面。

一个典型的 Drizzle ORM 工作流如下:

  1. 定义 Schema: 使用 TypeScript 定义数据库结构和类型。
  2. 生成 SQL: 使用 Drizzle 提供的工具 (drizzle-kit) 将 TypeScript 定义的结构转换为数据库迁移的 SQL 脚本。
  3. 执行迁移: 使用 Drizzle 提供的迁移工具执行生成的 SQL 迁移脚本,更新数据库结构。
  4. 重复步骤 1-3,直到数据库结构满足需求。

定义 Schema 的过程就是我们直接编写 TypeScript 代码描述数据库的结构(如表,视图,关系,schema 等),注意一定要放在 drizzle.config.ts 中配置的 schema 的路径,例如:

// src/schemas/db/users.ts
import { integer, pgTable, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
  id: integer(),
  firstName: varchar("first_name"),
});

然后通过 drizzle-kit 提供的 generate 命令,将这个 TypeScript 定义转换为 SQL 迁移脚本,生成的 SQL 脚本会保存在 drizzle.config.ts 中配置的 out 目录下。

# SQL 迁移脚本会输出到 drizzle/ 目录
pnpm drizzle-kit generate

如果旧的 Schema 发生了变化,generate 指令会自动检测到变化,并生成相应的 SQL 脚本,但是这个脚本需要仔细审核,因为它只是简单的通过类似于 diff 的方式生成 SQL,不保证旧数据一定能正确迁移到新结构中,如果你的旧数据有特殊的迁移逻辑,可以通过 generate --custom 指令生成一个空的 SQL 迁移脚本,然后手动编写 SQL 语句来实现数据迁移。

pnpm drizzle-kit generate --custom

为了减少手写 SQL 的工作量,尽量不要对已有数据库结构引入破坏性更新,尽量做到平滑迁移数据库结构。

最后,执行 drizzle-kit 提供的 migrate 命令,将生成的 SQL 脚本应用到数据库中,更新数据库结构。

pnpm drizzle-kit migrate

将整个 Drizzle ORM 相关的文件和目录可以全部提交到版本库中,实现数据库结构的版本控制和协作开发。

Drizzle ORM 多分支开发的局限性

Drizzle ORM 在多分支开发下有坑,详见我们提的 issue: #1221,以及我们写的 排错:Drizzle ORM 在多分支开发场景下,发现产品环境缺失 migrate 记录

在多分支开发的场景下,timestamp 在前的 SQL 迁移脚本可能后被合并进主干,导致这个 SQL 不被执行,因为它的逻辑是检测最后一个执行的 migrate 的 timestamp 来确定知否执行过某个迁移脚本。由于 timestamp 更靠后的脚本被先合并进入主干先执行完毕,导致后合并的脚本由于 timestamp 更早而被跳过执行。

如果是这种情况,可以参考这几个讨论,以及 issue 的回复区,选择一个合适的解决方案绕过:

项目中集成 Supabase

我们还是以之前文章出海产品的技术“黄金组合”:Next.js + Better-Auth + Polar + Resend 实战指南中使用的 Next.js 项目为例,来集成 Supabase。之前的文章出海技术人的“全能基础设施套装” —— Vercel 篇已经介绍了如何使用 Vercel 部署 Next.js 应用,接下来我们将介绍如何在这个项目中集成 Supabase,将我们之前的项目在 Vercel 正式跑起来。

Vercel Function 默认的 Region 是 us-east-1,所以我们应该选择地理位置最近的 Supabase 实例,建议选择 East US (North Virginia),这样可以减少网络延迟,提高数据库访问的速度。

我们的项目中之前引入了 Drizzle ORM 来操作数据库,所以我们需要在项目中配置 Supabase 的连接信息,并使用 Drizzle ORM 来操作数据库。首先我们先对 Drizzle ORM 进行一个快速的上手介绍。

Drizzle ORM 配置

之前我们在项目中已经使用了 Drizzle ORM 来操作数据库,我们只需要配置 Supabase 的连接信息即可。正好在 Supabase 的 Project 页面顶上的 Connect 按钮中可以找到 Drizzle ORM 的配置信息,我们直接使用那个配置即可:

DATABASE_URL="postgresql://postgres.[YOUR-PROJECT-ID]:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres"

将环境变量写入 .env.env.local 文件中即可。

特别注意: 如果使用其他客户端连接 Supabase 数据库,那么在 Vercel 环境应该使用 Transaction pooler 这个连接字符串,因为它使用了 PgBouncer 提供的服务端连接池,适合 Lambda 架构的应用。

配置一下 drizzle.config.ts 文件,参考内容如下:

import { loadEnvConfig } from "@next/env";
import { defineConfig } from "drizzle-kit";

const projectDir = process.cwd();
loadEnvConfig(projectDir);

export default defineConfig({
  schema: "./src/schemas/**/*.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL ?? "postgres://localhost:5432/mydb",
  },
});

由于我们需要脱离 Next.js 运行环境使用 drizzle-kit 工具,所以需要使用 loadEnvConfig 函数加载环境变量,详情可以参考官方文档 Loading Environment Variables with @next/env。需要提前安装 @next/env:

pnpm add -D @next/env

为了简化 drizzle-kit 的使用,我们可以在 package.json 中添加常用的 drizzle-kit 指令,方便我们执行数据库迁移操作:

{
  "scripts": {
    "generate": "drizzle-kit generate",
    "migrate": "drizzle-kit migrate"
  }
}

这样我们就能方便的使用 pnpm generatepnpm migrate 来生成和执行数据库迁移脚本了。

TIPS: 如果你期望使用 Supabase CLI 来管理数据库的迁移,那么你可以考虑将 drizzle.config.ts 中的 out 目录改为 ./supabase/migrations,这样就可以和 Supabase CLI 的迁移脚本目录保持一致,方便直接使用 Supabase CLI,详情参见: Drizzle with Supabase Database

为什么我们选择手工运行 drizzle-kit而不是在应用启动的时候自动运行?自动 migrate 数据库有一定的复杂性,尽管各种 ORM 可能会帮你自动生成一些 migrate 脚本,但请注意,这些脚本并不完全可靠,可能会执行失败甚至丢失数据。比如,你新增了一个必填字段,那么对于老旧数据你应该怎么处理?是填充默认值还是删除旧数据?你删除了一个字段,或者改变了外键的约束条件,那么旧数据你应该怎么处理?再比如,你的数据库非常庞大,启动的时候自动 migrate 可能会导致应用启动时间过长,甚至超时失败,造成应用长时间宕机,很可能是不可接受的。所以这些复杂的数据库迁移操作,还是建议手工执行,确保每次迁移都是经过测试和验证的。因此我们选择每次修改 schema 之后,手工执行 drizzle-kit,而不是放在程序启动时自动执行。

关于 Supabase 的 RLS Disabled 警告: 由于默认的 Supabase 实例是没有开启 RLS (Row Level Security) 的,所以在 Supabase 的控制台中会提示 RLS Disabled 的警告。这个警告是正常的,想要开启 RLS 可以在 Supabase 的控制台中进行设置,或者使用 SQL 命令开启 RLS,详情可以参见 Supabase 的文档

Polar.sh 的商品同步

我们之前在 Polar.sh 中已经创建了商品数据,并且在项目中通过 Better-Auth 集成了 Polar.sh 的 Checkout 功能。我们可以更进一步,不需要手工在 Polar.sh 中添加商品数据,而是通过 Polar.sh 提供的 API 配合我们的脚本,实现自动化的商品同步。

为了方便自动化处理,我们在 constant.ts 中创建 products 常量,将商品统一管理:

// src/constants.ts
import type { Product } from "@/lib/db/schema";

export const products: Product[] = [
  {
    id: 1,
    title: "现代化仪表板UI套件",
    description:
      "包含50+精美组件的现代化仪表板设计系统,支持深色模式,完全响应式设计。包含Figma源文件和React代码实现。",
    longDescription: `...`,
    price: "99.00",
    originalPrice: "149.00",
    imageUrl: "/placeholder.svg?height=600&width=800",
    downloadUrl: "#",
    category: "UI/UX Templates",
    tags: ["dashboard", "ui-kit", "figma", "react", "typescript", "responsive"],
    featured: true,
    downloads: 1247,
    rating: 4.8,
    reviews: 156,
    features: [
      "50+ 设计组件",
      "Figma源文件",
      "React代码",
      "深色模式支持",
      "响应式设计",
      "终身更新",
    ],
    gallery: [
      "/placeholder.svg?height=400&width=600&v=1",
      "/placeholder.svg?height=400&width=600&v=2",
      "/placeholder.svg?height=400&width=600&v=3",
      "/placeholder.svg?height=400&width=600&v=4",
    ],
  },
  // ...
  {
    id: 6,
    title: "手绘风格插画包",
    description:
      "100+手绘风格插画,适用于网站、应用、营销材料等。提供AI、EPS、PNG格式,可商用。",
    price: "69.00",
    originalPrice: "89.00",
    imageUrl: "/placeholder.svg?height=400&width=600",
    downloadUrl: "#",
    category: "Icons & Graphics",
    tags: ["illustration", "hand-drawn", "commercial", "marketing"],
    featured: false,
    downloads: 312,
  },
];

// 产品类别列表
export const categories = [
  "全部",
  "UI/UX Templates",
  "Icons & Graphics",
  "Code Templates",
  "Digital Tools",
];

然后我们在 scripts 目录下创建一个 sync-products-to-polar.ts 脚本,用于将这些商品数据同步到 Polar.sh,这个脚本难度不高,但是真正完整的实现也需要不少时间,这种活我选择交给 AI 来完成,它生成的代码意外还不错,而且只用了 10 分钟不到的时间就全部完成了:

sync products

我只需要在此基础上略加改动,比如改用 @next/env 来加载环境变量,调整生成的 products.json 的内容格式和路径等,就可以直接使用了。最终生成的完整代码如下:

#!/usr/bin/env tsx

import fs from "node:fs/promises";
import path from "node:path";
import { loadEnvConfig } from "@next/env";
import { Polar } from "@polar-sh/sdk";
import type { Product } from "@polar-sh/sdk/models/components/product";
import { products } from "../src/constant";

loadEnvConfig(process.cwd());

// Product mapping interface
interface ProductMapping {
  id: number;
  polarProductId: string;
  title: string;
  lastSynced: string;
}

interface ProductsRecord {
  products: ProductMapping[];
  lastUpdated: string;
}

interface SyncOptions {
  /** 是否在记录中保留已归档产品的历史记录 */
  keepArchivedHistory?: boolean;
}

class PolarProductSync {
  private readonly polar: Polar;
  private readonly recordFile: string;

  constructor() {
    const accessToken = process.env.POLAR_ACCESS_TOKEN;
    if (!accessToken) {
      throw new Error("POLAR_ACCESS_TOKEN environment variable is required");
    }

    this.polar = new Polar({
      accessToken: accessToken,
      server:
        (process.env.POLAR_SERVER as "sandbox" | "production" | undefined) ??
        "sandbox",
    });

    this.recordFile = path.join(process.cwd(), "src", "products.json");
  }

  /**
   * 读取现有的产品记录
   */
  private async readProductsRecord(): Promise<ProductsRecord> {
    try {
      const content = await fs.readFile(this.recordFile, "utf-8");
      return JSON.parse(content);
    } catch (error) {
      console.warn("No existing products.json found, creating new one...");
      return {
        products: [],
        lastUpdated: new Date().toISOString(),
      };
    }
  }

  /**
   * 保存产品记录
   */
  private async saveProductsRecord(record: ProductsRecord): Promise<void> {
    record.lastUpdated = new Date().toISOString();
    await fs.writeFile(this.recordFile, JSON.stringify(record, null, 2));
    console.log(`✅ Products record saved to ${this.recordFile}`);
  }

  /**
   * 将本地产品转换为 Polar 产品格式
   */
  private convertToProductCreate(localProduct: (typeof products)[0]) {
    return {
      name: localProduct.title,
      description: localProduct.longDescription || localProduct.description,
      recurringInterval: null, // 一次性购买产品
      // Polar 使用美分作为单位
      prices: [
        {
          amountType: "fixed" as const,
          priceAmount: Math.round(Number.parseFloat(localProduct.price) * 100),
          priceCurrency: "usd" as const,
        },
      ],
      // 使用本地 ID 作为外部参考
      metadata: {
        local_id: localProduct.id.toString(),
        category: localProduct.category,
        tags: localProduct.tags?.join(",") || "",
        featured: localProduct.featured?.toString() || "false",
        original_price: localProduct.originalPrice || "",
        image_url: localProduct.imageUrl,
        download_url: localProduct.downloadUrl,
        short_description: localProduct.description,
        long_description:
          localProduct.longDescription || localProduct.description,
      },
    };
  }

  /**
   * 创建新产品到 Polar
   */
  private async createProductInPolar(localProduct: (typeof products)[0]) {
    try {
      console.log(`📦 Creating product: ${localProduct.title}`);

      const productData = this.convertToProductCreate(localProduct);

      const response = await this.polar.products.create(productData);

      console.log(
        `✅ Created product "${localProduct.title}" with ID: ${response.id}`
      );
      return response.id;
    } catch (error) {
      console.error(
        `❌ Failed to create product "${localProduct.title}":`,
        error
      );
      throw error;
    }
  }

  /**
   * 更新 Polar 中的现有产品
   */
  private async updateProductInPolar(
    polarProductId: string,
    localProduct: (typeof products)[0]
  ) {
    try {
      console.log(`🔄 Updating product: ${localProduct.title}`);

      const productData = this.convertToProductCreate(localProduct);

      await this.polar.products.update({
        id: polarProductId,
        productUpdate: productData,
      });

      console.log(`✅ Updated product "${localProduct.title}"`);
    } catch (error) {
      console.error(
        `❌ Failed to update product "${localProduct.title}":`,
        error
      );
      throw error;
    }
  }

  /**
   * 获取 Polar 中的所有产品
   */
  private async getPolarProducts() {
    try {
      const products = [];
      const result = await this.polar.products.list({
        limit: 100, // 调整为合适的限制
      });

      for await (const page of result) {
        products.push(...page.result.items);
      }

      return products;
    } catch (error) {
      console.error("❌ Failed to fetch products from Polar:", error);
      throw error;
    }
  }

  /**
   * 归档 Polar 中的产品(下架处理)
   */
  private async archiveProductInPolar(polarProductId: string, title: string) {
    try {
      console.log(`📦 Archiving product: ${title}`);

      await this.polar.products.update({
        id: polarProductId,
        productUpdate: {
          isArchived: true,
        },
      });

      console.log(`✅ Archived product "${title}"`);
    } catch (error) {
      console.error(`❌ Failed to archive product "${title}":`, error);
      throw error;
    }
  }

  /**
   * 同步所有产品
   */
  async syncProducts(options?: SyncOptions) {
    console.log("🚀 Starting product synchronization...");

    try {
      // 读取现有记录
      const record = await this.readProductsRecord();

      // 获取 Polar 中的现有产品
      const polarProducts = await this.getPolarProducts();
      console.log(`📋 Found ${polarProducts.length} products in Polar`);

      const updatedMappings: ProductMapping[] = [];

      for (const localProduct of products) {
        const mapping = await this.syncSingleProduct(
          localProduct,
          record,
          polarProducts
        );
        updatedMappings.push(mapping);
      }

      // 检查需要下架的产品(存在于记录中但不在本地产品列表中)
      const localProductIds = new Set(products.map((p) => p.id));
      const archivedCount = await this.handleProductArchival(
        record,
        localProductIds,
        polarProducts
      );

      // 决定最终保存的映射记录
      const keepHistory = options?.keepArchivedHistory ?? false;
      const finalMappings = keepHistory
        ? [
            ...updatedMappings,
            ...record.products.filter((m) => !localProductIds.has(m.id)),
          ]
        : updatedMappings;

      // 保存更新后的记录
      await this.saveProductsRecord({
        products: finalMappings,
        lastUpdated: new Date().toISOString(),
      });

      console.log("\n🎉 Product synchronization completed successfully!");
      console.log(`📊 Synchronized ${finalMappings.length} products`);
      if (archivedCount > 0) {
        console.log(`📦 Archived ${archivedCount} products`);
      }

      // 显示映射摘要
      console.log("\n📋 Product Mappings:");
      for (const mapping of finalMappings) {
        console.log(
          `  ${mapping.id} -> ${mapping.polarProductId} (${mapping.title})`
        );
      }
    } catch (error) {
      console.error("\n❌ Synchronization failed:", error);
      process.exit(1);
    }
  }

  /**
   * 处理单个产品的同步逻辑
   */
  private async syncSingleProduct(
    localProduct: (typeof products)[0],
    record: ProductsRecord,
    polarProducts: Product[]
  ): Promise<ProductMapping> {
    console.log(`\n🔍 Processing product: ${localProduct.title}`);

    // 检查是否已经存在映射
    const existingMapping = record.products.find(
      (p) => p.id === localProduct.id
    );

    if (existingMapping) {
      return await this.handleExistingMapping(
        existingMapping,
        localProduct,
        polarProducts
      );
    }
    return await this.handleNewProduct(localProduct, polarProducts);
  }

  /**
   * 处理已有映射的产品
   */
  private async handleExistingMapping(
    existingMapping: ProductMapping,
    localProduct: (typeof products)[0],
    polarProducts: Product[]
  ): Promise<ProductMapping> {
    // 检查 Polar 中是否还存在这个产品
    const polarProduct = polarProducts.find(
      (p: Product) => p.id === existingMapping.polarProductId
    );

    if (polarProduct) {
      // 更新现有产品
      await this.updateProductInPolar(
        existingMapping.polarProductId,
        localProduct
      );
      return {
        ...existingMapping,
        title: localProduct.title,
        lastSynced: new Date().toISOString(),
      };
    }
    // Polar 中的产品不存在了,重新创建
    console.log("⚠️  Product not found in Polar, recreating...");
    const newPolarId = await this.createProductInPolar(localProduct);
    return {
      id: localProduct.id,
      polarProductId: newPolarId,
      title: localProduct.title,
      lastSynced: new Date().toISOString(),
    };
  }

  /**
   * 处理新产品
   */
  private async handleNewProduct(
    localProduct: (typeof products)[0],
    polarProducts: Product[]
  ): Promise<ProductMapping> {
    // 检查是否已经通过 metadata.local_id 存在
    const existingPolarProduct = polarProducts.find(
      (p: Product) => p.metadata?.local_id === localProduct.id.toString()
    );

    if (existingPolarProduct) {
      console.log("🔗 Found existing product in Polar, creating mapping...");
      return {
        id: localProduct.id,
        polarProductId: existingPolarProduct.id,
        title: localProduct.title,
        lastSynced: new Date().toISOString(),
      };
    }
    // 创建新产品
    const newPolarId = await this.createProductInPolar(localProduct);
    return {
      id: localProduct.id,
      polarProductId: newPolarId,
      title: localProduct.title,
      lastSynced: new Date().toISOString(),
    };
  }

  /**
   * 处理需要归档的产品
   */
  private async handleProductArchival(
    record: ProductsRecord,
    localProductIds: Set<number>,
    polarProducts: Product[]
  ): Promise<number> {
    console.log("\n🔍 Checking for products to archive...");
    const productsToArchive = record.products.filter(
      (mapping) => !localProductIds.has(mapping.id)
    );

    let archivedCount = 0;
    for (const mapping of productsToArchive) {
      console.log(
        `\n📦 Product "${mapping.title}" no longer exists locally, archiving...`
      );

      // 检查产品在 Polar 中是否仍然存在且未归档
      const polarProduct = polarProducts.find(
        (p: Product) => p.id === mapping.polarProductId
      );

      if (polarProduct && !polarProduct.isArchived) {
        await this.archiveProductInPolar(mapping.polarProductId, mapping.title);
        console.log(`✅ Archived product "${mapping.title}"`);
        archivedCount++;
      } else if (polarProduct?.isArchived) {
        console.log(
          `ℹ️  Product "${mapping.title}" is already archived in Polar`
        );
      } else {
        console.log(
          `⚠️  Product "${mapping.title}" not found in Polar, skipping...`
        );
      }
    }

    return archivedCount;
  }

  /**
   * 显示当前产品状态
   */
  async showStatus() {
    try {
      const record = await this.readProductsRecord();
      const polarProducts = await this.getPolarProducts();

      console.log("\n📊 Current Status:");
      console.log(`Local products: ${products.length}`);
      console.log(`Mapped products: ${record.products.length}`);
      console.log(`Polar products: ${polarProducts.length}`);
      console.log(`Last updated: ${record.lastUpdated}`);

      if (record.products.length > 0) {
        console.log("\n📋 Product Mappings:");
        for (const mapping of record.products) {
          console.log(
            `  ${mapping.id} -> ${mapping.polarProductId} (${mapping.title})`
          );
        }
      }
    } catch (error) {
      console.error("❌ Failed to show status:", error);
    }
  }
}

async function main() {
  const command = process.argv[2];
  const keepHistory = process.argv.includes("--keep-history");

  try {
    const sync = new PolarProductSync();

    switch (command) {
      case "sync":
        await sync.syncProducts({ keepArchivedHistory: keepHistory });
        break;
      case "status":
        await sync.showStatus();
        break;
      default:
        console.log("Usage:");
        console.log(
          "  npm run sync-products sync [--keep-history]   - Sync local products to Polar"
        );
        console.log(
          "  npm run sync-products status                  - Show current sync status"
        );
        console.log("");
        console.log("Options:");
        console.log(
          "  --keep-history    Keep archived products in the mapping record"
        );
        process.exit(1);
    }
  } catch (error) {
    console.error("❌ Error:", error);
    process.exit(1);
  }
}

main();

它生成的代码还贴心的添加了 syncstatus 两个参数:

$ pnpm tsx scripts/sync-products-to-polar.ts
Usage:
  npm run sync-products sync [--keep-history]   - Sync local products to Polar
  npm run sync-products status                  - Show current sync status

Options:
  --keep-history    Keep archived products in the mapping record

它通过比对 products.json 的内容和当前 constant.ts 中的 products 常量,来决定是创建新产品、更新现有产品还是下架已存在的产品。

为了在每次产品构建时都能自动同步到 Polar.sh,我们可以在 package.json 中添加 prebuild脚本,调用 sync-products-to-polar.ts。这样,每次执行 pnpm build 构建之前,都会自动执行产品同步操作,确保 Polar.sh 上的产品信息始终是最新的:

{
  "scripts": {
    "prebuild": "tsx scripts/sync-products-to-polar.ts sync"
  }
}

这样我们在执行 pnpm build 的时候,就会先进行同步商品:

$ pnpm build

> digital-products-store@0.1.0 prebuild
> tsx scripts/sync-products-to-polar.ts sync

🚀 Starting product synchronization...
...
> digital-products-store@0.1.0 build
> next build

 Next.js 15.2.4
   - Environments: .env
   - Experiments (use with caution):
 typedRoutes

   Creating an optimized production build ...
...

剩下的工作就容易多了。最后,我们修改代码,读取 products.json 的数据就行了:

// src/services/productService.ts
import productsMapping from "@/products.json";

// ...
// 合并本地产品配置和 Polar 映射信息
const getEnhancedProducts = (): EnhancedProduct[] => {
  return products.map((product) => {
    const mapping = productsMapping.products.find(
      (m: ProductMapping) => m.id === product.id
    );

    return {
      ...product,
      polarProductId: mapping?.polarProductId,
      // 可以根据需要生成 polarSlug,或者从其他地方获取
      polarSlug: mapping?.polarProductId ? `product-${product.id}` : undefined,
    };
  });
};
// ...

你还可以根据需要,将其他信息存入数据库中,最后,别忘了使用 vercelvercel --prod 发布你的应用。

至此,我们就已经完成了 Supabase 的集成,并且实现了商品数据的自动同步。现在,你可以在 Polar.sh 上看到最新的商品信息,并且可以通过 Vercel 部署的应用访问这些商品。

小结

技术选型不是拍脑袋,而是一次次上线、一次次踩坑之后留下的经验。 本文带你完整走通了 Vercel + Supabase 的数据库实战路线,从架构原理到 Drizzle ORM 再到 Polar 商品同步,全流程可复用、可集成、可上线。

如果你已经读到这里,说明你是认真在做产品的人。接下来几篇,我们还会继续深挖 AI 向应用的数据结构、分支部署管理、出海 SaaS 的风控设计等关键模块。

👉 建议收藏本文,并订阅系列内容,你会慢慢构建出属于自己的“出海武器库”。

付费内容

本文包含付费内容,需要会员权限才能查看完整内容。