導入「使用者列表」的游標分頁(Keyset Pagination),統一錯誤碼與 HTTP 狀態碼映射,補上資料庫複合索引與更貼近真實的種子資料,同步調整 API、頁面與測試。
created_at DESC, id DESC
為序的游標分頁;壞游標回傳一致錯誤碼ErrorCode
搭配 ErrorConfig
決定訊息與狀態碼GET /api/users
支援 limit
、cursor
查詢參數;POST /api/users
使用集中錯誤UsersList
,支援按鈕載入更多(created_at, id)
複合索引,配合 keyset 分頁GET /api/users
handler 覆蓋、服務層分頁與壞游標測試dayjs
處理時間;ESLint 新增 simple-import-sort
這次新增與調整:
dayjs
eslint-plugin-simple-import-sort
studio
/push
):{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/db/migrate.ts",
"db:seed": "tsx scripts/db/seed.ts",
"db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push"
}
}
為配合 keyset 分頁,將 users
表建立複合索引:
src/lib/db/schema.ts
import { index, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
export const users = pgTable(
'users',
{
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [index('users_created_at_id_idx').on(table.createdAt, table.id)]
);
集中錯誤碼與映射:
src/server/errors.ts
export enum ErrorCode {
VALIDATION_ERROR = 'VALIDATION_ERROR',
EMAIL_TAKEN = 'EMAIL_TAKEN',
INTERNAL_ERROR = 'INTERNAL_ERROR',
BAD_CURSOR = 'BAD_CURSOR',
}
export const ErrorConfig = {
[ErrorCode.VALIDATION_ERROR]: { message: '驗證錯誤', statusCode: 400 },
[ErrorCode.EMAIL_TAKEN]: { message: 'Email 已被註冊', statusCode: 409 },
[ErrorCode.INTERNAL_ERROR]: { message: '內部錯誤', statusCode: 500 },
[ErrorCode.BAD_CURSOR]: { message: '游標格式錯誤', statusCode: 400 },
} as const;
Repository 以 keyset 分頁(相同 created_at
以 id DESC
作為 tie‑breaker):
src/server/repos/usersRepo.ts
export async function listByCreatedAt(limit = 20, cursor?: UsersCursor | null) {
const pageSize = Math.max(1, limit);
let query = db.select().from(users);
if (cursor) {
query = query.where(
or(
lt(users.createdAt, cursor.createdAt),
and(eq(users.createdAt, cursor.createdAt), lt(users.id, cursor.id))
)
);
}
const rows = await query.orderBy(desc(users.createdAt), desc(users.id)).limit(pageSize + 1);
const hasMore = rows.length > pageSize;
const items = hasMore ? rows.slice(0, pageSize) : rows;
const nextCursor = hasMore
? { createdAt: items[items.length - 1]!.createdAt, id: items[items.length - 1]!.id }
: null;
return { items, nextCursor };
}
服務層 getUsers
會 clamp limit
,並提供 Base64 JSON 游標的 encode/decode 與壞游標錯誤:
src/server/users.ts
function encodeCursor(cursor: UsersCursor): string {
return Buffer.from(
JSON.stringify({ createdAt: cursor.createdAt.toISOString(), id: cursor.id }),
'utf8'
).toString('base64');
}
function decodeCursor(cursor: string): UsersCursor | null {
try {
const raw = Buffer.from(cursor, 'base64').toString('utf8');
const safe = CursorPayloadSchema.safeParse(JSON.parse(raw));
if (!safe.success) return null;
const createdAt = dayjs(safe.data.createdAt).toDate();
return dayjs(createdAt).isValid() ? { createdAt, id: safe.data.id } : null;
} catch {
return null;
}
}
統一以集中錯誤映射回傳狀態碼;GET
支援 limit
與 cursor
查詢參數:
src/app/api/users/route.ts
export async function GET(req: NextRequest) {
try {
const limitParam = req.nextUrl.searchParams.get('limit');
const cursor = req.nextUrl.searchParams.get('cursor');
const limit = limitParam !== null ? Number(limitParam) : undefined;
const result = await getUsers(limit, cursor);
const status = result.ok ? 200 : ErrorConfig[result.code].statusCode;
return NextResponse.json(result, { status });
} catch {
return NextResponse.json(
{
ok: false,
code: ErrorCode.INTERNAL_ERROR,
message: ErrorConfig[ErrorCode.INTERNAL_ERROR].message,
},
{ status: 500 }
);
}
}
以 Client Component 管理「載入更多」狀態與錯誤展示:
src/app/_components/users-list.tsx
const handleLoadMore = async () => {
if (!nextCursor || loading) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users?cursor=${encodeURIComponent(nextCursor)}`);
const json = (await res.json()) as ApiResult<PageResult<UserItem>>;
if (!res.ok || !json.ok) throw new Error(!res.ok ? '載入失敗' : json.message);
setItems((prev) => [...prev, ...json.data.items]);
setNextCursor(json.data.nextCursor);
} catch (err) {
setError(err instanceof Error ? err.message : '載入時發生錯誤');
} finally {
setLoading(false);
}
};
tests/api.users.get.test.ts
驗證 limit/cursor
傳遞與壞游標 400tests/service.users.test.ts
:
nextCursor = null
created_at
以 id DESC
作 tie‑breakerBAD_CURSOR
tests/api.users.post.test.ts
與 service
測試改用 ErrorCode
與 dayjs
執行:
npm run db:migrate
npm run db:seed
npm run test # 一次跑完
npm run test:watch # 監看模式
以 bcrypt
預先雜湊密碼,並使用 dayjs
生成人性化的 createdAt
,便於觀察分頁:
scripts/db/seed.ts
const passwordHash = await bcrypt.hash('password123', 10);
const now = dayjs();
const seedUsers: NewUser[] = baseUsers.map((user, index) => {
const createdAt = now.subtract(index, 'minute').toDate();
return { ...user, password: passwordHash, createdAt, updatedAt: createdAt };
});
await db.insert(users).values(seedUsers);
本來想推進更多功能,但從頭看AI產出的內容發現蠻多要調整的,所以今天只有完善API相關內容,明天預計把登入功能完成。