
如果你直接用过 Azure Cosmos DB,就会知道那种痛苦:手写原始 SQL 查询,管理参数化输入,数据入库前没有任何验证。
对比一下查询 100 美元以下电子产品的两种方式:
原始 @azure/cosmos SDK,要写一个 query 对象,parameters 数组手动拼参,一不小心就 SQL 注入。而用 Cosmoose,直接 Product.find({ category: 'electronics', price: { $gte: 10, $lte: 100 } }),链式调用,类型安全,底层自动生成参数化 SQL。
这就是 Cosmoose 解决的问题——为 Cosmos DB 打造的类型安全 ODM,用过 MongoDB 的 Mongoose 的话,上手非常快。
先建项目,装依赖:
mkdir cosmoose-product-catalog && cd cosmoose-product-catalog
pnpm init
pnpm add @cosmoose/core express dotenv
pnpm add -D typescript tsx @types/express @types/node
创建 .env 存放 Cosmos DB 凭据:
COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your-primary-key-here
COSMOS_DATABASE=product-catalog
创建 src/db.ts,初始化 Cosmoose 并注册模型:
import { Cosmoose } from '@cosmoose/core';
import { productSchema, type Product } from './schemas/product.js';
export const cosmoose = new Cosmoose({
endpoint: process.env.COSMOS_ENDPOINT!,
key: process.env.COSMOS_KEY!,
databaseName: process.env.COSMOS_DATABASE || 'product-catalog',
});
export let Product: ReturnType<typeof cosmoose.model<Product>>;
export async function connectDB() {
await cosmoose.connect();
Product = cosmoose.model<Product>('products', productSchema);
}
如果数据库不存在会自动创建。model() 注册 Product 模型,绑定到 Cosmos DB 的 products 容器。
这是 Cosmoose 真正发挥优势的地方:
interface Specs {
weight: number;
dimensions: string;
color?: string;
}
export interface Product {
name: string;
sku: string;
description?: string;
price: number;
stock: number;
category: string;
tags: string[];
status: string;
specs: Specs;
}
const specsSchema = new Schema<Specs>({
weight: { type: Type.NUMBER },
dimensions: { type: Type.STRING },
color: { type: Type.STRING, optional: true },
});
export const productSchema = new Schema<Product>(
{
name: { type: Type.STRING, trim: true },
sku: { type: Type.STRING, uppercase: true },
description: { type: Type.STRING, optional: true },
price: { type: Type.NUMBER },
stock: { type: Type.NUMBER },
category: { type: Type.STRING },
tags: { type: Type.ARRAY, items: { type: Type.STRING } },
status: { type: Type.STRING, default: 'draft' },
specs: { type: Type.OBJECT, schema: specsSchema },
},
{
timestamps: true,
container: { partitionKey: '/category' },
},
);
这个 Schema 包含了不少东西,逐一解析:
trim: true:自动去除首尾空白。" Wireless Mouse " 变成 "Wireless Mouse"uppercase: true:转大写。"wm-001" 变成 "WM-001"optional: true:字段不是必需的default: 'draft':未提供时使用默认值specs 对象:独立 Schema 做类型安全嵌套验证timestamps: true:自动管理 createdAt 和 updatedAtpartitionKey: '/category':按 category 分区所有验证在数据进入 Cosmos DB 之前完成,底层由 Zod 提供支持。验证失败时抛出 SchemaValidationFailedException。
productsRouter.post('/', async (req, res) => {
try {
const product = await Product.create(req.body);
res.status(201).json(product);
} catch (err) {
if (err instanceof SchemaValidationFailedException) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
return;
}
throw err;
}
});
Product.create() 会根据 Schema 验证输入、自动生成 UUID v7 id、设置时间戳,并应用 transforms——所有在插入 Cosmos DB 之前完成。
productsRouter.get('/', async (req, res) => {
const { category, status, minPrice, maxPrice, sort, order, limit, offset } = req.query;
const filter: Record<string, unknown> = {};
if (category) filter.category = category;
if (status) filter.status = status;
if (minPrice || maxPrice) {
filter.price = {};
if (minPrice) (filter.price as Record<string, number>).$gte = Number(minPrice);
if (maxPrice) (filter.price as Record<string, number>).$lte = Number(maxPrice);
}
let query = Product.find(filter);
if (sort) query = query.sort({ [sort as string]: order === 'desc' ? -1 : 1 });
if (limit) query = query.limit(Number(limit));
if (offset) query = query.offset(Number(offset));
const products = await query;
res.json(products);
});
链式 API 允许在 await 之前调用 .sort()、.limit()、.offset()。Cosmoose 在底层生成参数化 SQL——没有 SQL 注入风险,没有字符串拼接。
productsRouter.get('/:id', async (req, res) => {
const product = await Product.findOne({ id: req.params.id });
if (!product) return res.status(404).json({ error: 'Product not found' });
res.json(product);
});
productsRouter.patch('/:id', async (req, res) => {
const { category } = req.query;
const updated = await Product.patchById(
req.params.id,
{ $set: req.body },
{ partitionKeyValue: category as string },
);
if (!updated) return res.status(404).json({ error: 'Product not found' });
res.json(updated);
});
$set 只更新指定字段,其他不变。不需要发送完整文档。
productsRouter.post('/:id/restock', async (req, res) => {
const { category } = req.query;
const { quantity } = req.body;
const updated = await Product.patchById(
req.params.id,
{ $incr: { stock: quantity } },
{ partitionKeyValue: category as string },
);
if (!updated) return res.status(404).json({ error: 'Product not found' });
res.json(updated);
});
$incr 原子性地递增数值字段。无需先读取再写回,避免了并发场景下的竞态条件。
Cosmoose 的核心价值在于:把 Cosmos DB 从「手写 SQL 的原始时代」拉回到「类型安全的现代 ORM 体验」。Schema 定义 + 自动验证 + 链式查询构建器,对于已经在用 MongoDB/Mongoose 的团队来说迁移成本很低。
如果你正在用 Cosmos DB 而不是在忍 Cosmos DB,Cosmoose 值得一试。
原文:Build a Type-Safe Product Catalog API with Express, Cosmoose, and Azure Cosmos DB