书接上文,在上一篇文章中,我们解决了“部署上线”的问题,这一篇要解决的是出海产品最核心、也最容易翻车的部分——数据库架构。如果你正在用 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 数据库。
Branch 支持
Neon 和 Supabase 都支持分支管理。结合 Branch Deploy 功能,你可以在每个分支上创建独立的数据库实例,方便进行分支开发和测试。
如需结合 Github Action 使用,可以参阅 Supabase 官方文档: Branching。
注意: Neon 和 Supabase 的 Branch 功能是付费使用,Free Plan 用户无法使用。
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 工作流如下:
- 定义 Schema: 使用 TypeScript 定义数据库结构和类型。
- 生成 SQL: 使用 Drizzle 提供的工具 (
drizzle-kit
) 将 TypeScript 定义的结构转换为数据库迁移的 SQL 脚本。 - 执行迁移: 使用 Drizzle 提供的迁移工具执行生成的 SQL 迁移脚本,更新数据库结构。
- 重复步骤 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 generate
和 pnpm 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 分钟不到的时间就全部完成了:
我只需要在此基础上略加改动,比如改用 @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();
它生成的代码还贴心的添加了 sync
和 status
两个参数:
$ 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,
};
});
};
// ...
你还可以根据需要,将其他信息存入数据库中,最后,别忘了使用 vercel
或 vercel --prod
发布你的应用。
至此,我们就已经完成了 Supabase 的集成,并且实现了商品数据的自动同步。现在,你可以在 Polar.sh 上看到最新的商品信息,并且可以通过 Vercel 部署的应用访问这些商品。
小结
技术选型不是拍脑袋,而是一次次上线、一次次踩坑之后留下的经验。 本文带你完整走通了 Vercel + Supabase 的数据库实战路线,从架构原理到 Drizzle ORM 再到 Polar 商品同步,全流程可复用、可集成、可上线。
如果你已经读到这里,说明你是认真在做产品的人。接下来几篇,我们还会继续深挖 AI 向应用的数据结构、分支部署管理、出海 SaaS 的风控设计等关键模块。
👉 建议收藏本文,并订阅系列内容,你会慢慢构建出属于自己的“出海武器库”。
本文包含付费内容,需要会员权限才能查看完整内容。