fix: add cache fallbacks for tag data
This commit is contained in:
@@ -1,105 +1,93 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import crypto from 'crypto'
|
import { revalidatePath } from "next/cache";
|
||||||
import { revalidatePath } from 'next/cache'
|
import type { TagCategory } from "@prisma/client";
|
||||||
import type { TagCategory } from '@prisma/client'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { isValidApiKey } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
ProjectTagResetRequestSchema,
|
ProjectTagResetRequestSchema,
|
||||||
type ProjectTagResetItem,
|
type ProjectTagResetItem,
|
||||||
type ResettableTagCategory,
|
type ResettableTagCategory,
|
||||||
} from '@/lib/validations'
|
} from "@/lib/validations";
|
||||||
|
|
||||||
type ResetResultItem = {
|
type ResetResultItem = {
|
||||||
projectSlug: string
|
projectSlug: string;
|
||||||
status: 'updated' | 'dry-run' | 'failed'
|
status: "updated" | "dry-run" | "failed";
|
||||||
selectedTagCount: number
|
selectedTagCount: number;
|
||||||
addedCount: number
|
addedCount: number;
|
||||||
removedCount: number
|
removedCount: number;
|
||||||
details: string[]
|
details: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
|
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
|
||||||
'FIXED_PROJECT_TYPE',
|
"FIXED_PROJECT_TYPE",
|
||||||
'TECH_STACK',
|
"TECH_STACK",
|
||||||
'AI_PARADIGM',
|
"AI_PARADIGM",
|
||||||
'PRODUCT_FORM',
|
"PRODUCT_FORM",
|
||||||
'DOMAIN_SCENARIO',
|
"DOMAIN_SCENARIO",
|
||||||
]
|
];
|
||||||
|
|
||||||
function isApiKeyValid(providedApiKey: string, expectedApiKey?: string): boolean {
|
|
||||||
if (!expectedApiKey) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const providedBuf = Buffer.from(providedApiKey)
|
|
||||||
const expectedBuf = Buffer.from(expectedApiKey)
|
|
||||||
return (
|
|
||||||
providedBuf.length === expectedBuf.length &&
|
|
||||||
crypto.timingSafeEqual(providedBuf, expectedBuf)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSlug(slug: string): string {
|
function normalizeSlug(slug: string): string {
|
||||||
return slug.trim().toLowerCase()
|
return slug.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectSelectedTagSlugs(
|
function collectSelectedTagSlugs(
|
||||||
item: ProjectTagResetItem,
|
item: ProjectTagResetItem,
|
||||||
categories: ResettableTagCategory[]
|
categories: ResettableTagCategory[]
|
||||||
): string[] {
|
): string[] {
|
||||||
const selectedTagSlugSet = new Set<string>()
|
const selectedTagSlugSet = new Set<string>();
|
||||||
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
const categorySlugs = item.selectedTagSlugsByCategory[category] || []
|
const categorySlugs = item.selectedTagSlugsByCategory[category] || [];
|
||||||
for (const slug of categorySlugs) {
|
for (const slug of categorySlugs) {
|
||||||
selectedTagSlugSet.add(normalizeSlug(slug))
|
selectedTagSlugSet.add(normalizeSlug(slug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...selectedTagSlugSet]
|
return [...selectedTagSlugSet];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectValidationErrorsForProjectItem(params: {
|
function collectValidationErrorsForProjectItem(params: {
|
||||||
item: ProjectTagResetItem
|
item: ProjectTagResetItem;
|
||||||
categories: ResettableTagCategory[]
|
categories: ResettableTagCategory[];
|
||||||
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>
|
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const { item, categories, existingTagBySlug } = params
|
const { item, categories, existingTagBySlug } = params;
|
||||||
const errors: string[] = []
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
|
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
|
||||||
const normalizedSlug = normalizeSlug(rawSlug)
|
const normalizedSlug = normalizeSlug(rawSlug);
|
||||||
const existingTag = existingTagBySlug.get(normalizedSlug)
|
const existingTag = existingTagBySlug.get(normalizedSlug);
|
||||||
|
|
||||||
if (!existingTag) {
|
if (!existingTag) {
|
||||||
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`)
|
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (existingTag.category !== category) {
|
if (existingTag.category !== category) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
|
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json();
|
||||||
const validation = ProjectTagResetRequestSchema.safeParse(body)
|
const validation = ProjectTagResetRequestSchema.safeParse(body);
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Validation error',
|
error: "Validation error",
|
||||||
details: validation.error.errors.map((issue) => issue.message),
|
details: validation.error.errors.map((issue) => issue.message),
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -108,27 +96,26 @@ export async function POST(request: NextRequest) {
|
|||||||
replaceAllCategories,
|
replaceAllCategories,
|
||||||
projects,
|
projects,
|
||||||
categories: requestedCategories,
|
categories: requestedCategories,
|
||||||
} = validation.data
|
} = validation.data;
|
||||||
|
|
||||||
if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) {
|
if (!isValidApiKey(apiKey)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unauthorized',
|
error: "Unauthorized",
|
||||||
details: ['Invalid or missing API Key'],
|
details: ["Invalid or missing API Key"],
|
||||||
},
|
},
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = requestedCategories.length > 0
|
const categories =
|
||||||
? requestedCategories
|
requestedCategories.length > 0 ? requestedCategories : DEFAULT_RESET_CATEGORIES;
|
||||||
: DEFAULT_RESET_CATEGORIES
|
|
||||||
|
|
||||||
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug))
|
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug));
|
||||||
const normalizedSelectedTagSlugs = [
|
const normalizedSelectedTagSlugs = [
|
||||||
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
|
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
|
||||||
]
|
];
|
||||||
|
|
||||||
const [existingProjects, existingTags] = await Promise.all([
|
const [existingProjects, existingTags] = await Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
@@ -157,63 +144,67 @@ export async function POST(request: NextRequest) {
|
|||||||
category: true,
|
category: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]))
|
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]));
|
||||||
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
|
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]));
|
||||||
|
|
||||||
const results: ResetResultItem[] = []
|
const results: ResetResultItem[] = [];
|
||||||
const updatedProjectSlugs: string[] = []
|
const updatedProjectSlugs: string[] = [];
|
||||||
|
|
||||||
for (const item of projects) {
|
for (const item of projects) {
|
||||||
const projectSlug = normalizeSlug(item.projectSlug)
|
const projectSlug = normalizeSlug(item.projectSlug);
|
||||||
const project = projectBySlug.get(projectSlug)
|
const project = projectBySlug.get(projectSlug);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
results.push({
|
results.push({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
status: 'failed',
|
status: "failed",
|
||||||
selectedTagCount: 0,
|
selectedTagCount: 0,
|
||||||
addedCount: 0,
|
addedCount: 0,
|
||||||
removedCount: 0,
|
removedCount: 0,
|
||||||
details: [`Project with slug "${projectSlug}" not found`],
|
details: [`Project with slug "${projectSlug}" not found`],
|
||||||
})
|
});
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationErrors = collectValidationErrorsForProjectItem({
|
const validationErrors = collectValidationErrorsForProjectItem({
|
||||||
item,
|
item,
|
||||||
categories,
|
categories,
|
||||||
existingTagBySlug,
|
existingTagBySlug,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
results.push({
|
results.push({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
status: 'failed',
|
status: "failed",
|
||||||
selectedTagCount: 0,
|
selectedTagCount: 0,
|
||||||
addedCount: 0,
|
addedCount: 0,
|
||||||
removedCount: 0,
|
removedCount: 0,
|
||||||
details: validationErrors,
|
details: validationErrors,
|
||||||
})
|
});
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTagIds = collectSelectedTagSlugs(item, categories)
|
const selectedTagIds = collectSelectedTagSlugs(item, categories)
|
||||||
.map((slug) => existingTagBySlug.get(slug)?.id)
|
.map((slug) => existingTagBySlug.get(slug)?.id)
|
||||||
.filter((id): id is string => Boolean(id))
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
const previousCategoryTagIds = new Set(
|
const previousCategoryTagIds = new Set(
|
||||||
project.tags
|
project.tags
|
||||||
.filter((projectTag) => categories.includes(projectTag.tag.category as ResettableTagCategory))
|
.filter((projectTag) =>
|
||||||
|
categories.includes(projectTag.tag.category as ResettableTagCategory)
|
||||||
|
)
|
||||||
.map((projectTag) => projectTag.tag.id)
|
.map((projectTag) => projectTag.tag.id)
|
||||||
)
|
);
|
||||||
|
|
||||||
const nextTagIdSet = new Set(selectedTagIds)
|
const nextTagIdSet = new Set(selectedTagIds);
|
||||||
const removedCount = replaceAllCategories
|
const removedCount = replaceAllCategories
|
||||||
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
|
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
|
||||||
: 0
|
: 0;
|
||||||
const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length
|
const addedCount = [...nextTagIdSet].filter(
|
||||||
|
(tagId) => !previousCategoryTagIds.has(tagId)
|
||||||
|
).length;
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -227,7 +218,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTagIds.length > 0) {
|
if (selectedTagIds.length > 0) {
|
||||||
@@ -237,32 +228,32 @@ export async function POST(request: NextRequest) {
|
|||||||
tagId,
|
tagId,
|
||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
updatedProjectSlugs.push(projectSlug)
|
updatedProjectSlugs.push(projectSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
status: dryRun ? 'dry-run' : 'updated',
|
status: dryRun ? "dry-run" : "updated",
|
||||||
selectedTagCount: selectedTagIds.length,
|
selectedTagCount: selectedTagIds.length,
|
||||||
addedCount,
|
addedCount,
|
||||||
removedCount,
|
removedCount,
|
||||||
details: [],
|
details: [],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCount = results.filter((item) => item.status === 'updated').length
|
const updatedCount = results.filter((item) => item.status === "updated").length;
|
||||||
const dryRunCount = results.filter((item) => item.status === 'dry-run').length
|
const dryRunCount = results.filter((item) => item.status === "dry-run").length;
|
||||||
const failedCount = results.filter((item) => item.status === 'failed').length
|
const failedCount = results.filter((item) => item.status === "failed").length;
|
||||||
|
|
||||||
if (!dryRun && updatedProjectSlugs.length > 0) {
|
if (!dryRun && updatedProjectSlugs.length > 0) {
|
||||||
revalidatePath('/zh/projects', 'page')
|
revalidatePath("/zh/projects", "page");
|
||||||
revalidatePath('/en/projects', 'page')
|
revalidatePath("/en/projects", "page");
|
||||||
for (const projectSlug of updatedProjectSlugs) {
|
for (const projectSlug of updatedProjectSlugs) {
|
||||||
revalidatePath(`/zh/projects/${projectSlug}`, 'page')
|
revalidatePath(`/zh/projects/${projectSlug}`, "page");
|
||||||
revalidatePath(`/en/projects/${projectSlug}`, 'page')
|
revalidatePath(`/en/projects/${projectSlug}`, "page");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,16 +269,16 @@ export async function POST(request: NextRequest) {
|
|||||||
failedCount,
|
failedCount,
|
||||||
results,
|
results,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[POST /api/tags/reset-projects] Error:', error)
|
console.error("[POST /api/tags/reset-projects] Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal server error',
|
error: "Internal server error",
|
||||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -1,31 +1,31 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { runWithCacheFallback } from "@/lib/cache";
|
||||||
|
|
||||||
const TAGS_CACHE_REVALIDATE_SECONDS = 300;
|
const TAGS_CACHE_REVALIDATE_SECONDS = 300;
|
||||||
|
|
||||||
const getCachedTags = unstable_cache(
|
async function getTagsFromDb() {
|
||||||
async () =>
|
return prisma.tag.findMany({
|
||||||
prisma.tag.findMany({
|
include: {
|
||||||
include: {
|
_count: {
|
||||||
_count: {
|
select: { projects: true },
|
||||||
select: { projects: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
},
|
||||||
name: "asc",
|
orderBy: {
|
||||||
},
|
name: "asc",
|
||||||
}),
|
},
|
||||||
["api-tags:v1"],
|
});
|
||||||
{
|
}
|
||||||
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
|
||||||
tags: ["api-tags"],
|
const getCachedTags = unstable_cache(getTagsFromDb, ["api-tags:v1"], {
|
||||||
}
|
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
||||||
);
|
tags: ["api-tags"],
|
||||||
|
});
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const tags = await getCachedTags();
|
const tags = await runWithCacheFallback(getCachedTags, getTagsFromDb);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
+98
-104
@@ -1,77 +1,82 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getTopTags } from '@/hooks/useProjects'
|
import { getTopTags } from "@/hooks/useProjects";
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from "@prisma/client";
|
||||||
import { unstable_cache } from 'next/cache'
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { runWithCacheFallback } from "@/lib/cache";
|
||||||
|
|
||||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const DEFAULT_RANKING_LIMIT = 6
|
const DEFAULT_RANKING_LIMIT = 6;
|
||||||
const DEFAULT_TIMELINE_LIMIT = 8
|
const DEFAULT_TIMELINE_LIMIT = 8;
|
||||||
const DEFAULT_TOP_TAG_LIMIT = 12
|
const DEFAULT_TOP_TAG_LIMIT = 12;
|
||||||
const HOME_PAGE_REVALIDATE_SECONDS = 300
|
const HOME_PAGE_REVALIDATE_SECONDS = 300;
|
||||||
|
|
||||||
export type HomeProjectSummary = {
|
export type HomeProjectSummary = {
|
||||||
id: string
|
id: string;
|
||||||
slug: string
|
slug: string;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string | null
|
nameEn: string | null;
|
||||||
description: string
|
description: string;
|
||||||
descriptionEn: string | null
|
descriptionEn: string | null;
|
||||||
githubStars: number
|
githubStars: number;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
tags: Array<{
|
tags: Array<{
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string | null
|
nameEn: string | null;
|
||||||
slug: string
|
slug: string;
|
||||||
}>
|
}>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type HomePageData = {
|
export type HomePageData = {
|
||||||
overview: {
|
overview: {
|
||||||
totalProjects: number
|
totalProjects: number;
|
||||||
newProjects30d: number
|
newProjects30d: number;
|
||||||
newProjects7d: number
|
newProjects7d: number;
|
||||||
newProjects24h: number
|
newProjects24h: number;
|
||||||
}
|
};
|
||||||
rankings: {
|
rankings: {
|
||||||
latestByWindow: {
|
latestByWindow: {
|
||||||
'24h': HomeProjectSummary[]
|
"24h": HomeProjectSummary[];
|
||||||
'7d': HomeProjectSummary[]
|
"7d": HomeProjectSummary[];
|
||||||
'30d': HomeProjectSummary[]
|
"30d": HomeProjectSummary[];
|
||||||
}
|
};
|
||||||
topStars: HomeProjectSummary[]
|
topStars: HomeProjectSummary[];
|
||||||
}
|
};
|
||||||
tagInsights: {
|
tagInsights: {
|
||||||
topTags: Array<{
|
topTags: Array<{
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string | null
|
nameEn: string | null;
|
||||||
slug: string
|
slug: string;
|
||||||
projectCount: number
|
projectCount: number;
|
||||||
}>
|
}>;
|
||||||
}
|
};
|
||||||
timeline: HomeProjectSummary[]
|
timeline: HomeProjectSummary[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type ProjectWithRelations = Prisma.ProjectGetPayload<{
|
type ProjectWithRelations = Prisma.ProjectGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
tags: {
|
tags: {
|
||||||
include: {
|
include: {
|
||||||
tag: true
|
tag: true;
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
async function safeQuery<T>(operationName: string, fallback: T, task: () => Promise<T>): Promise<T> {
|
async function safeQuery<T>(
|
||||||
|
operationName: string,
|
||||||
|
fallback: T,
|
||||||
|
task: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
return await task()
|
return await task();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[db] ${operationName} degraded to fallback:`,
|
`[db] ${operationName} degraded to fallback:`,
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
return fallback
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +96,17 @@ function mapProjectSummary(project: ProjectWithRelations): HomeProjectSummary {
|
|||||||
nameEn: projectTag.tag.nameEn,
|
nameEn: projectTag.tag.nameEn,
|
||||||
slug: projectTag.tag.slug,
|
slug: projectTag.tag.slug,
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestProjects(limit: number, createdAfter?: Date): Promise<HomeProjectSummary[]> {
|
async function getLatestProjects(
|
||||||
const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () =>
|
limit: number,
|
||||||
|
createdAfter?: Date
|
||||||
|
): Promise<HomeProjectSummary[]> {
|
||||||
|
const projects = await safeQuery("getLatestProjects", [] as ProjectWithRelations[], () =>
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
...(createdAfter ? { createdAt: { gte: createdAfter } } : {}),
|
...(createdAfter ? { createdAt: { gte: createdAfter } } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -109,20 +117,20 @@ async function getLatestProjects(limit: number, createdAfter?: Date): Promise<Ho
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
take: limit,
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
return projects.map(mapProjectSummary)
|
return projects.map(mapProjectSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
|
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
|
||||||
const projects = await safeQuery('getTopStarsProjects', [] as ProjectWithRelations[], () =>
|
const projects = await safeQuery("getTopStarsProjects", [] as ProjectWithRelations[], () =>
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
tags: {
|
tags: {
|
||||||
@@ -131,12 +139,12 @@ async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ githubStars: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ githubStars: "desc" }, { createdAt: "desc" }],
|
||||||
take: limit,
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
return projects.map(mapProjectSummary)
|
return projects.map(mapProjectSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestProjectsByWindow(
|
function getLatestProjectsByWindow(
|
||||||
@@ -144,17 +152,15 @@ function getLatestProjectsByWindow(
|
|||||||
createdAfter: Date,
|
createdAfter: Date,
|
||||||
limit: number
|
limit: number
|
||||||
): HomeProjectSummary[] {
|
): HomeProjectSummary[] {
|
||||||
return projects
|
return projects.filter((project) => new Date(project.createdAt) >= createdAfter).slice(0, limit);
|
||||||
.filter((project) => new Date(project.createdAt) >= createdAfter)
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildHomePageData(): Promise<HomePageData> {
|
async function buildHomePageData(): Promise<HomePageData> {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
const last24Hours = new Date(now - ONE_DAY_MS)
|
const last24Hours = new Date(now - ONE_DAY_MS);
|
||||||
const last7Days = new Date(now - ONE_DAY_MS * 7)
|
const last7Days = new Date(now - ONE_DAY_MS * 7);
|
||||||
const last30Days = new Date(now - ONE_DAY_MS * 30)
|
const last30Days = new Date(now - ONE_DAY_MS * 30);
|
||||||
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT)
|
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
totalProjects,
|
totalProjects,
|
||||||
@@ -165,31 +171,31 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
topStars,
|
topStars,
|
||||||
topTags,
|
topTags,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
safeQuery('countTotalProjects', 0, () => prisma.project.count()),
|
safeQuery("countTotalProjects", 0, () => prisma.project.count()),
|
||||||
safeQuery('countNewProjects30d', 0, () =>
|
safeQuery("countNewProjects30d", 0, () =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: last30Days,
|
gte: last30Days,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
safeQuery('countNewProjects7d', 0, () =>
|
safeQuery("countNewProjects7d", 0, () =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: last7Days,
|
gte: last7Days,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
safeQuery('countNewProjects24h', 0, () =>
|
safeQuery("countNewProjects24h", 0, () =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: last24Hours,
|
gte: last24Hours,
|
||||||
},
|
},
|
||||||
@@ -199,24 +205,12 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
getLatestProjects(latestProjectsLimit),
|
getLatestProjects(latestProjectsLimit),
|
||||||
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
||||||
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const latest24h = getLatestProjectsByWindow(
|
const latest24h = getLatestProjectsByWindow(latestProjects, last24Hours, DEFAULT_RANKING_LIMIT);
|
||||||
latestProjects,
|
const latest7d = getLatestProjectsByWindow(latestProjects, last7Days, DEFAULT_RANKING_LIMIT);
|
||||||
last24Hours,
|
const latest30d = getLatestProjectsByWindow(latestProjects, last30Days, DEFAULT_RANKING_LIMIT);
|
||||||
DEFAULT_RANKING_LIMIT
|
const timeline = latestProjects.slice(0, DEFAULT_TIMELINE_LIMIT);
|
||||||
)
|
|
||||||
const latest7d = getLatestProjectsByWindow(
|
|
||||||
latestProjects,
|
|
||||||
last7Days,
|
|
||||||
DEFAULT_RANKING_LIMIT
|
|
||||||
)
|
|
||||||
const latest30d = getLatestProjectsByWindow(
|
|
||||||
latestProjects,
|
|
||||||
last30Days,
|
|
||||||
DEFAULT_RANKING_LIMIT
|
|
||||||
)
|
|
||||||
const timeline = latestProjects.slice(0, DEFAULT_TIMELINE_LIMIT)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overview: {
|
overview: {
|
||||||
@@ -227,9 +221,9 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
},
|
},
|
||||||
rankings: {
|
rankings: {
|
||||||
latestByWindow: {
|
latestByWindow: {
|
||||||
'24h': latest24h,
|
"24h": latest24h,
|
||||||
'7d': latest7d,
|
"7d": latest7d,
|
||||||
'30d': latest30d,
|
"30d": latest30d,
|
||||||
},
|
},
|
||||||
topStars,
|
topStars,
|
||||||
},
|
},
|
||||||
@@ -243,14 +237,14 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
timeline,
|
timeline,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCachedHomePageData = unstable_cache(buildHomePageData, ['home-page-data:v1'], {
|
const getCachedHomePageData = unstable_cache(buildHomePageData, ["home-page-data:v1"], {
|
||||||
revalidate: HOME_PAGE_REVALIDATE_SECONDS,
|
revalidate: HOME_PAGE_REVALIDATE_SECONDS,
|
||||||
tags: ['home-page-data'],
|
tags: ["home-page-data"],
|
||||||
})
|
});
|
||||||
|
|
||||||
export async function getHomePageData(): Promise<HomePageData> {
|
export async function getHomePageData(): Promise<HomePageData> {
|
||||||
return getCachedHomePageData()
|
return runWithCacheFallback(getCachedHomePageData, buildHomePageData);
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-198
@@ -1,52 +1,54 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Prisma, type TagCategory } from '@prisma/client'
|
import { Prisma, type TagCategory } from "@prisma/client";
|
||||||
import { unstable_cache } from 'next/cache'
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { runWithCacheFallback } from "@/lib/cache";
|
||||||
import {
|
import {
|
||||||
FIXED_PROJECT_TYPE_TAGS,
|
FIXED_PROJECT_TYPE_TAGS,
|
||||||
TAG_CATEGORY_META,
|
TAG_CATEGORY_META,
|
||||||
getTagCategoryOrder,
|
getTagCategoryOrder,
|
||||||
isFixedProjectTypeSlug,
|
isFixedProjectTypeSlug,
|
||||||
type FixedProjectTypeSlug,
|
type FixedProjectTypeSlug,
|
||||||
} from '@/lib/tag-taxonomy'
|
} from "@/lib/tag-taxonomy";
|
||||||
|
|
||||||
const DB_RETRY_DELAYS_MS = [300, 900] as const
|
const DB_RETRY_DELAYS_MS = [300, 900] as const;
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(resolve, ms)
|
setTimeout(resolve, ms);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransientDbError(error: unknown): boolean {
|
function isTransientDbError(error: unknown): boolean {
|
||||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
if (error instanceof Prisma.PrismaClientRustPanicError) {
|
if (error instanceof Prisma.PrismaClientRustPanicError) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
const message =
|
||||||
|
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
return (
|
return (
|
||||||
message.includes("can't reach database server") ||
|
message.includes("can't reach database server") ||
|
||||||
message.includes('p1001') ||
|
message.includes("p1001") ||
|
||||||
message.includes('connection terminated') ||
|
message.includes("connection terminated") ||
|
||||||
message.includes('timeout') ||
|
message.includes("timeout") ||
|
||||||
message.includes('econnreset')
|
message.includes("econnreset")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Promise<T> {
|
async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Promise<T> {
|
||||||
let lastError: unknown
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= DB_RETRY_DELAYS_MS.length; attempt += 1) {
|
for (let attempt = 0; attempt <= DB_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
return await task()
|
return await task();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error;
|
||||||
if (!isTransientDbError(error) || attempt === DB_RETRY_DELAYS_MS.length) {
|
if (!isTransientDbError(error) || attempt === DB_RETRY_DELAYS_MS.length) {
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0)
|
await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,56 +56,56 @@ async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Pr
|
|||||||
`[db] ${operationName} failed: ${
|
`[db] ${operationName} failed: ${
|
||||||
lastError instanceof Error ? lastError.message : String(lastError)
|
lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
}`
|
}`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义带有标签和链接的项目类型
|
// 定义带有标签和链接的项目类型
|
||||||
export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{
|
export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } }
|
tags: { include: { tag: true } };
|
||||||
links: true
|
links: true;
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
// 定义扁平化标签的项目类型
|
// 定义扁平化标签的项目类型
|
||||||
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, 'tags'> & {
|
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, "tags"> & {
|
||||||
tags: Prisma.TagGetPayload<{}>[]
|
tags: Prisma.TagGetPayload<{}>[];
|
||||||
}
|
};
|
||||||
|
|
||||||
// 定义标签计数类型
|
// 定义标签计数类型
|
||||||
export type TagWithProjectCount = Prisma.TagGetPayload<{
|
export type TagWithProjectCount = Prisma.TagGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { projects: true } }
|
_count: { select: { projects: true } };
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
export type FilterTagCategoryGroup = {
|
export type FilterTagCategoryGroup = {
|
||||||
category: Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>
|
category: Exclude<TagCategory, "FIXED_PROJECT_TYPE">;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string
|
nameEn: string;
|
||||||
tags: TagWithProjectCount[]
|
tags: TagWithProjectCount[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type FixedProjectTypeFilter = {
|
export type FixedProjectTypeFilter = {
|
||||||
slug: FixedProjectTypeSlug
|
slug: FixedProjectTypeSlug;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string
|
nameEn: string;
|
||||||
projectCount: number
|
projectCount: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const
|
export const PROJECT_SORT_OPTIONS = ["latest", "stars_desc", "stars_asc"] as const;
|
||||||
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
|
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number];
|
||||||
const DEFAULT_PAGE = 1
|
const DEFAULT_PAGE = 1;
|
||||||
const DEFAULT_LIMIT = 10
|
const DEFAULT_LIMIT = 10;
|
||||||
const MAX_LIMIT = 100
|
const MAX_LIMIT = 100;
|
||||||
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300
|
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300;
|
||||||
|
|
||||||
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
||||||
const candidate = String(value || '').trim()
|
const candidate = String(value || "").trim();
|
||||||
if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) {
|
if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) {
|
||||||
return candidate as ProjectSortOption
|
return candidate as ProjectSortOption;
|
||||||
}
|
}
|
||||||
return 'latest'
|
return "latest";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePositiveInteger(
|
function normalizePositiveInteger(
|
||||||
@@ -111,30 +113,30 @@ function normalizePositiveInteger(
|
|||||||
fallback: number,
|
fallback: number,
|
||||||
max?: number
|
max?: number
|
||||||
): number {
|
): number {
|
||||||
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback
|
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback;
|
||||||
const bounded = normalized > 0 ? normalized : fallback
|
const bounded = normalized > 0 ? normalized : fallback;
|
||||||
return typeof max === 'number' ? Math.min(bounded, max) : bounded
|
return typeof max === "number" ? Math.min(bounded, max) : bounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjects(options?: {
|
export async function getProjects(options?: {
|
||||||
search?: string
|
search?: string;
|
||||||
tag?: string
|
tag?: string;
|
||||||
tags?: string[]
|
tags?: string[];
|
||||||
domains?: string[]
|
domains?: string[];
|
||||||
productForms?: string[]
|
productForms?: string[];
|
||||||
projectType?: string
|
projectType?: string;
|
||||||
sort?: ProjectSortOption
|
sort?: ProjectSortOption;
|
||||||
status?: 'ACTIVE' | 'ARCHIVED'
|
status?: "ACTIVE" | "ARCHIVED";
|
||||||
page?: number
|
page?: number;
|
||||||
limit?: number
|
limit?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
projects: ProjectWithFlatTags[]
|
projects: ProjectWithFlatTags[];
|
||||||
pagination: {
|
pagination: {
|
||||||
page: number
|
page: number;
|
||||||
limit: number
|
limit: number;
|
||||||
total: number
|
total: number;
|
||||||
totalPages: number
|
totalPages: number;
|
||||||
}
|
};
|
||||||
}> {
|
}> {
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@@ -143,65 +145,57 @@ export async function getProjects(options?: {
|
|||||||
domains = [],
|
domains = [],
|
||||||
productForms = [],
|
productForms = [],
|
||||||
projectType,
|
projectType,
|
||||||
sort = 'latest',
|
sort = "latest",
|
||||||
status = 'ACTIVE',
|
status = "ACTIVE",
|
||||||
page = DEFAULT_PAGE,
|
page = DEFAULT_PAGE,
|
||||||
limit = DEFAULT_LIMIT,
|
limit = DEFAULT_LIMIT,
|
||||||
} = options || {}
|
} = options || {};
|
||||||
|
|
||||||
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE)
|
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE);
|
||||||
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
|
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||||
|
|
||||||
const where: Prisma.ProjectWhereInput = {
|
const where: Prisma.ProjectWhereInput = {
|
||||||
status,
|
status,
|
||||||
}
|
};
|
||||||
|
|
||||||
// 添加搜索字符串长度验证
|
// 添加搜索字符串长度验证
|
||||||
if (search && search.length >= 2 && search.length <= 100) {
|
if (search && search.length >= 2 && search.length <= 100) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: 'insensitive' } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
{ nameEn: { contains: search, mode: "insensitive" } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: "insensitive" } },
|
||||||
{ descriptionEn: { contains: search, mode: 'insensitive' } },
|
{ descriptionEn: { contains: search, mode: "insensitive" } },
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const andFilters: Prisma.ProjectWhereInput[] = []
|
const andFilters: Prisma.ProjectWhereInput[] = [];
|
||||||
const normalizedDomainSlugs = Array.from(
|
const normalizedDomainSlugs = Array.from(
|
||||||
new Set(
|
new Set(domains.map((value) => (value || "").trim()).filter((value) => value.length > 0))
|
||||||
domains
|
);
|
||||||
.map((value) => (value || '').trim())
|
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const normalizedProductFormSlugs = Array.from(
|
const normalizedProductFormSlugs = Array.from(
|
||||||
new Set(
|
new Set(productForms.map((value) => (value || "").trim()).filter((value) => value.length > 0))
|
||||||
productForms
|
);
|
||||||
.map((value) => (value || '').trim())
|
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const normalizedTagSlugs = Array.from(
|
const normalizedTagSlugs = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
[tag, ...tags]
|
[tag, ...tags]
|
||||||
.map((value) => (value || '').trim())
|
.map((value) => (value || "").trim())
|
||||||
.filter((value) => value.length > 0)
|
.filter((value) => value.length > 0)
|
||||||
.filter((value) => !normalizedDomainSlugs.includes(value))
|
.filter((value) => !normalizedDomainSlugs.includes(value))
|
||||||
.filter((value) => !normalizedProductFormSlugs.includes(value))
|
.filter((value) => !normalizedProductFormSlugs.includes(value))
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
|
|
||||||
for (const domainSlug of normalizedDomainSlugs) {
|
for (const domainSlug of normalizedDomainSlugs) {
|
||||||
andFilters.push({
|
andFilters.push({
|
||||||
tags: {
|
tags: {
|
||||||
some: {
|
some: {
|
||||||
tag: {
|
tag: {
|
||||||
category: 'DOMAIN_SCENARIO',
|
category: "DOMAIN_SCENARIO",
|
||||||
slug: domainSlug,
|
slug: domainSlug,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tagSlug of normalizedTagSlugs) {
|
for (const tagSlug of normalizedTagSlugs) {
|
||||||
@@ -213,7 +207,7 @@ export async function getProjects(options?: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const productFormSlug of normalizedProductFormSlugs) {
|
for (const productFormSlug of normalizedProductFormSlugs) {
|
||||||
@@ -221,12 +215,12 @@ export async function getProjects(options?: {
|
|||||||
tags: {
|
tags: {
|
||||||
some: {
|
some: {
|
||||||
tag: {
|
tag: {
|
||||||
category: 'PRODUCT_FORM',
|
category: "PRODUCT_FORM",
|
||||||
slug: productFormSlug,
|
slug: productFormSlug,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (projectType && isFixedProjectTypeSlug(projectType)) {
|
if (projectType && isFixedProjectTypeSlug(projectType)) {
|
||||||
@@ -238,25 +232,25 @@ export async function getProjects(options?: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (andFilters.length > 0) {
|
if (andFilters.length > 0) {
|
||||||
where.AND = andFilters
|
where.AND = andFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy: Prisma.ProjectOrderByWithRelationInput[] =
|
const orderBy: Prisma.ProjectOrderByWithRelationInput[] =
|
||||||
sort === 'stars_desc'
|
sort === "stars_desc"
|
||||||
? [{ githubStars: 'desc' }, { createdAt: 'desc' }]
|
? [{ githubStars: "desc" }, { createdAt: "desc" }]
|
||||||
: sort === 'stars_asc'
|
: sort === "stars_asc"
|
||||||
? [{ githubStars: 'asc' }, { createdAt: 'desc' }]
|
? [{ githubStars: "asc" }, { createdAt: "desc" }]
|
||||||
: [{ createdAt: 'desc' }]
|
: [{ createdAt: "desc" }];
|
||||||
|
|
||||||
let projects: ProjectWithTagsAndLinks[] = []
|
let projects: ProjectWithTagsAndLinks[] = [];
|
||||||
let total = 0
|
let total = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
;[projects, total] = await withDbRetry('getProjects', () =>
|
[projects, total] = await withDbRetry("getProjects", () =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -274,19 +268,19 @@ export async function getProjects(options?: {
|
|||||||
}),
|
}),
|
||||||
prisma.project.count({ where }),
|
prisma.project.count({ where }),
|
||||||
])
|
])
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[db] getProjects degraded to empty result:',
|
"[db] getProjects degraded to empty result:",
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
const transformedProjects = projects.map((project) => ({
|
const transformedProjects = projects.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((pt) => pt.tag),
|
tags: project.tags.map((pt) => pt.tag),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects: transformedProjects,
|
projects: transformedProjects,
|
||||||
@@ -296,11 +290,11 @@ export async function getProjects(options?: {
|
|||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / safeLimit),
|
totalPages: Math.ceil(total / safeLimit),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
||||||
const project = await withDbRetry('getProjectBySlug', () =>
|
const project = await withDbRetry("getProjectBySlug", () =>
|
||||||
prisma.project.findUnique({
|
prisma.project.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
@@ -312,25 +306,25 @@ export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTag
|
|||||||
links: true,
|
links: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((pt) => pt.tag),
|
tags: project.tags.map((pt) => pt.tag),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
||||||
return withDbRetry('getAllTags', () =>
|
return withDbRetry("getAllTags", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -339,18 +333,18 @@ export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc',
|
name: "asc",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
||||||
const tags = await withDbRetry('getTagsWithProjectCounts', () =>
|
const tags = await withDbRetry("getTagsWithProjectCounts", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -359,16 +353,16 @@ export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc',
|
name: "asc",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
return tags.filter(tag => tag._count.projects > 0)
|
return tags.filter((tag) => tag._count.projects > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
||||||
return withDbRetry('getTopTags', () =>
|
return withDbRetry("getTopTags", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -377,49 +371,45 @@ async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
some: {},
|
some: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ projects: { _count: 'desc' } }, { name: 'asc' }],
|
orderBy: [{ projects: { _count: "desc" } }, { name: "asc" }],
|
||||||
take: limit,
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>()
|
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>();
|
||||||
|
|
||||||
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
|
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
|
||||||
const existing = topTagsCache.get(limit)
|
const existing = topTagsCache.get(limit);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetcher = unstable_cache(
|
const fetcher = unstable_cache(async () => getTopTagsFromDb(limit), [`top-tags:${limit}`], {
|
||||||
async () => getTopTagsFromDb(limit),
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
[`top-tags:${limit}`],
|
tags: ["top-tags"],
|
||||||
{
|
});
|
||||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
topTagsCache.set(limit, fetcher);
|
||||||
tags: ['top-tags'],
|
return fetcher;
|
||||||
}
|
|
||||||
)
|
|
||||||
topTagsCache.set(limit, fetcher)
|
|
||||||
return fetcher
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
||||||
return getTopTagsCachedFetcher(limit)()
|
return runWithCacheFallback(getTopTagsCachedFetcher(limit), () => getTopTagsFromDb(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFixedProjectTypeFiltersFromDb(
|
async function getFixedProjectTypeFiltersFromDb(
|
||||||
status: 'ACTIVE' | 'ARCHIVED'
|
status: "ACTIVE" | "ARCHIVED"
|
||||||
): Promise<FixedProjectTypeFilter[]> {
|
): Promise<FixedProjectTypeFilter[]> {
|
||||||
let counts: number[] = []
|
let counts: number[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
counts = await withDbRetry('getFixedProjectTypeFilters', () =>
|
counts = await withDbRetry("getFixedProjectTypeFilters", () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
FIXED_PROJECT_TYPE_TAGS.map((type) =>
|
FIXED_PROJECT_TYPE_TAGS.map((type) =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
@@ -436,12 +426,12 @@ async function getFixedProjectTypeFiltersFromDb(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[db] getFixedProjectTypeFilters degraded to zero counts:',
|
"[db] getFixedProjectTypeFilters degraded to zero counts:",
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({
|
return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({
|
||||||
@@ -449,20 +439,20 @@ async function getFixedProjectTypeFiltersFromDb(
|
|||||||
name: type.name,
|
name: type.name,
|
||||||
nameEn: type.nameEn,
|
nameEn: type.nameEn,
|
||||||
projectCount: counts[index] ?? 0,
|
projectCount: counts[index] ?? 0,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixedProjectTypeFilterCache = new Map<
|
const fixedProjectTypeFilterCache = new Map<
|
||||||
'ACTIVE' | 'ARCHIVED',
|
"ACTIVE" | "ARCHIVED",
|
||||||
() => Promise<FixedProjectTypeFilter[]>
|
() => Promise<FixedProjectTypeFilter[]>
|
||||||
>()
|
>();
|
||||||
|
|
||||||
function getFixedProjectTypeFilterCachedFetcher(
|
function getFixedProjectTypeFilterCachedFetcher(
|
||||||
status: 'ACTIVE' | 'ARCHIVED'
|
status: "ACTIVE" | "ARCHIVED"
|
||||||
): () => Promise<FixedProjectTypeFilter[]> {
|
): () => Promise<FixedProjectTypeFilter[]> {
|
||||||
const existing = fixedProjectTypeFilterCache.get(status)
|
const existing = fixedProjectTypeFilterCache.get(status);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetcher = unstable_cache(
|
const fetcher = unstable_cache(
|
||||||
@@ -470,28 +460,30 @@ function getFixedProjectTypeFilterCachedFetcher(
|
|||||||
[`fixed-project-type-filters:${status}`],
|
[`fixed-project-type-filters:${status}`],
|
||||||
{
|
{
|
||||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
tags: ['fixed-project-type-filters'],
|
tags: ["fixed-project-type-filters"],
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
fixedProjectTypeFilterCache.set(status, fetcher)
|
fixedProjectTypeFilterCache.set(status, fetcher);
|
||||||
return fetcher
|
return fetcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFixedProjectTypeFilters(
|
export async function getFixedProjectTypeFilters(
|
||||||
status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE'
|
status: "ACTIVE" | "ARCHIVED" = "ACTIVE"
|
||||||
): Promise<FixedProjectTypeFilter[]> {
|
): Promise<FixedProjectTypeFilter[]> {
|
||||||
return getFixedProjectTypeFilterCachedFetcher(status)()
|
return runWithCacheFallback(getFixedProjectTypeFilterCachedFetcher(status), () =>
|
||||||
|
getFixedProjectTypeFiltersFromDb(status)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
||||||
let tags: TagWithProjectCount[] = []
|
let tags: TagWithProjectCount[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tags = await withDbRetry('getTagCategoryGroups', () =>
|
tags = await withDbRetry("getTagCategoryGroups", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
some: {},
|
some: {},
|
||||||
@@ -503,35 +495,35 @@ async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc',
|
name: "asc",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[db] getTagCategoryGroups degraded to empty groups:',
|
"[db] getTagCategoryGroups degraded to empty groups:",
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = new Map<Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>, TagWithProjectCount[]>()
|
const groups = new Map<Exclude<TagCategory, "FIXED_PROJECT_TYPE">, TagWithProjectCount[]>();
|
||||||
for (const category of getTagCategoryOrder()) {
|
for (const category of getTagCategoryOrder()) {
|
||||||
if (category === 'FIXED_PROJECT_TYPE') {
|
if (category === "FIXED_PROJECT_TYPE") {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
groups.set(category, [])
|
groups.set(category, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
if (tag.category === 'FIXED_PROJECT_TYPE') {
|
if (tag.category === "FIXED_PROJECT_TYPE") {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
const current = groups.get(tag.category)
|
const current = groups.get(tag.category);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
groups.set(tag.category, [tag])
|
groups.set(tag.category, [tag]);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
current.push(tag)
|
current.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groups.entries())
|
return Array.from(groups.entries())
|
||||||
@@ -540,39 +532,39 @@ async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
|||||||
name: TAG_CATEGORY_META[category].name,
|
name: TAG_CATEGORY_META[category].name,
|
||||||
nameEn: TAG_CATEGORY_META[category].nameEn,
|
nameEn: TAG_CATEGORY_META[category].nameEn,
|
||||||
tags: categoryTags.sort((a, b) => {
|
tags: categoryTags.sort((a, b) => {
|
||||||
const countDiff = b._count.projects - a._count.projects
|
const countDiff = b._count.projects - a._count.projects;
|
||||||
if (countDiff !== 0) {
|
if (countDiff !== 0) {
|
||||||
return countDiff
|
return countDiff;
|
||||||
}
|
}
|
||||||
return a.name.localeCompare(b.name, 'zh')
|
return a.name.localeCompare(b.name, "zh");
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.filter((group) => group.tags.length > 0)
|
.filter((group) => group.tags.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCachedTagCategoryGroups = unstable_cache(
|
const getCachedTagCategoryGroups = unstable_cache(
|
||||||
getTagCategoryGroupsFromDb,
|
getTagCategoryGroupsFromDb,
|
||||||
['tag-category-groups:v1'],
|
["tag-category-groups:v1"],
|
||||||
{
|
{
|
||||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
tags: ['tag-category-groups'],
|
tags: ["tag-category-groups"],
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
||||||
return getCachedTagCategoryGroups()
|
return runWithCacheFallback(getCachedTagCategoryGroups, getTagCategoryGroupsFromDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义 AI 搜索结果类型
|
// 定义 AI 搜索结果类型
|
||||||
export type AISearchResultItem = ProjectWithFlatTags & {
|
export type AISearchResultItem = ProjectWithFlatTags & {
|
||||||
similarity: number
|
similarity: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
// n8n 返回的简化搜索结果类型
|
// n8n 返回的简化搜索结果类型
|
||||||
export type N8NSearchResult = {
|
export type N8NSearchResult = {
|
||||||
id: string
|
id: string;
|
||||||
similarity: number
|
similarity: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 ID 列表批量获取项目(用于 AI 搜索结果组装)
|
* 根据 ID 列表批量获取项目(用于 AI 搜索结果组装)
|
||||||
@@ -581,10 +573,10 @@ export type N8NSearchResult = {
|
|||||||
*/
|
*/
|
||||||
export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTags[]> {
|
export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTags[]> {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await withDbRetry('getProjectsByIds', () =>
|
const projects = await withDbRetry("getProjectsByIds", () =>
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
@@ -600,11 +592,11 @@ export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTa
|
|||||||
links: true,
|
links: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
return projects.map((project) => ({
|
return projects.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((pt) => pt.tag),
|
tags: project.tags.map((pt) => pt.tag),
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function isUnstableCacheUnavailableError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.message.toLowerCase().includes("incrementalcache missing in unstable_cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWithCacheFallback<T>(
|
||||||
|
cachedFetcher: () => Promise<T>,
|
||||||
|
fallbackFetcher: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await cachedFetcher();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnstableCacheUnavailableError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackFetcher();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user