11 Commits
40 changed files with 1384 additions and 158 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Build and Deploy Next Preview
on:
workflow_dispatch:
push:
branches:
- next
concurrency:
group: buzz-next-deploy
cancel-in-progress: false
jobs:
build-deploy:
if: gitea.ref == 'refs/heads/next'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kubectl
run: |
set -euo pipefail
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -fsSLO "https://dl.k8s.io/release/v1.35.0/bin/linux/${ARCH}/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/kubectl
- name: Configure kubectl
env:
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
run: |
set -euo pipefail
kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true
kubectl config set-credentials ci-deployer --token="$KUBE_TOKEN"
kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz
kubectl config use-context buzz
- name: Prepare shared environment for preview build
run: |
set -euo pipefail
kubectl get secret buzz-env -n buzz \
-o go-template='{{range $k,$v := .data}}{{$k}}={{$v|base64decode}}{{"\n"}}{{end}}' > .env
chmod 600 .env
sed -i '/^BASE_URL=/d' .env
sed -i '/^BETTER_AUTH_URL=/d;/^DISCORD_REDIRECT_URI=/d' .env
printf '%s\n' \
'BASE_URL=https://buzz-next.sudloh.com' \
'BETTER_AUTH_URL=https://buzz-next.sudloh.com' \
'DISCORD_REDIRECT_URI=https://buzz-next.sudloh.com/rubgram/callback' >> .env
- name: Build and push immutable frontend preview image
env:
TAG: ${{ gitea.sha }}
IMAGE_REPOSITORY: registry.neko-piranha.ts.net/${{ gitea.repository }}
run: |
set -euo pipefail
docker build -t "$IMAGE_REPOSITORY:frontend-next-$TAG" .
docker push "$IMAGE_REPOSITORY:frontend-next-$TAG"
- name: Update prebootstrapped preview and apply networking resources
env:
TAG: ${{ gitea.sha }}
IMAGE_REPOSITORY: registry.neko-piranha.ts.net/${{ gitea.repository }}
run: |
set -euo pipefail
if ! kubectl get deployment/app-next -n buzz >/dev/null; then
echo "Required prebootstrapped Deployment app-next is missing in namespace buzz; bootstrap it locally before running this workflow." >&2
exit 1
fi
kubectl apply -f k8s/next/service-app-next.yaml -f k8s/next/ingress-app-next.yaml
kubectl set image deployment/app-next app-next="$IMAGE_REPOSITORY:frontend-next-$TAG" -n buzz
kubectl rollout status deployment/app-next -n buzz --timeout=180s
+3
View File
@@ -59,3 +59,6 @@ node_modules/
.direnv
!.envrc
.env*
.version
.serena
+1 -1
View File
@@ -1,4 +1,4 @@
import { Section } from "./page";
import { Section } from "./section";
export function SettingsAccountSection() {
return <Section title="บัญชี">ยังไม่พร้อมให้บริการ</Section>;
-26
View File
@@ -1,12 +1,4 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { SettingsAccountSection } from "./account";
import { getSettongs } from "./api";
import { SettingsServicesSection } from "./services";
@@ -36,22 +28,4 @@ export default async function () {
);
}
export function Section({
title,
description,
children,
className,
...props
}: React.ComponentProps<"div"> & { description?: string }) {
return (
<Card className={cn("h-full", className)} {...props}>
<CardHeader>
{title && <CardTitle className="text-2xl">{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className="h-full">{children}</CardContent>
</Card>
);
}
export const dynamic = "force-dynamic";
+26
View File
@@ -0,0 +1,26 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
export function Section({
title,
description,
children,
className,
...props
}: React.ComponentProps<"div"> & { description?: string }) {
return (
<Card className={cn("h-full", className)} {...props}>
<CardHeader>
{title && <CardTitle className="text-2xl">{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className="h-full">{children}</CardContent>
</Card>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ import {
toggleEnka,
} from "./api";
import { Broadcaster } from "./broadcaster";
import { Section } from "./page";
import { Section } from "./section";
export function SettingsServicesSection({
enka: enkaInitial,
+3
View File
@@ -0,0 +1,3 @@
export default function DonateAdminDefault() {
return null;
}
+4 -2
View File
@@ -7,6 +7,7 @@ import {
LoaderPinwheel,
StickyNote,
Table,
UserCog,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -19,7 +20,7 @@ export function Navbar() {
const mobile = useIsMobile();
if (
!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact|guide|donate)$/gm.test(
!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact|guide|donate|repair)$/gm.test(
pathname,
)
)
@@ -44,7 +45,8 @@ export function Navbar() {
/>
<Item icon={<Table size={16} />} name="จัดเทียร์ลิสต์" href="/tl" />
<Item icon={<StickyNote size={16} />} name="ไกด์ตัวละคร" href="/guide" />
<Item icon={<Bitcoin size={16} />} name="โดเนท" href="/donate" last />
<Item icon={<Bitcoin size={16} />} name="โดเนท" href="/donate" />
<Item icon={<UserCog size={16} />} name="ซ่อมไอดี" href="/repair" last />
</div>
);
}
+13
View File
@@ -7,6 +7,7 @@ import DonateLogo from "#/logos/donate.webp";
import GuideLogo from "#/logos/guide.webp";
import RubgramLogo from "#/logos/rubgram.webp";
import TierlistLogo from "#/logos/tierlist.webp";
import CommissionLogo from "#/logos/commission.webp";
import Image from "@/components/image";
import { SimpleTooltip } from "@/components/tooltip";
import { cn } from "@/lib/utils";
@@ -66,6 +67,18 @@ export default function Home() {
href="/donate"
logo={DonateLogo}
/>
{/* <HomeLink
name="ซ่อมไอดีชาวบ้าน"
desc="ซ่อมไอดีชาวบ้าน"
href="/repair"
logo={DonateLogo}
/> */}
<HomeLink
name="คอมมิชชั่น"
desc="คอมมิชชั่นจ้างแคลทีมดาเมท เปรียบเทียบดาเมท กลุ่มดาว"
href="https://guide.sudloh.com/commission"
logo={CommissionLogo}
/>
</div>
<footer className="flex h-full flex-col justify-end pb-3 text-muted-foreground md:justify-center">
<div className="flex flex-col justify-between gap-2 text-center sm:flex-row sm:text-left">
+3
View File
@@ -0,0 +1,3 @@
export default function RubgramAdminDefault() {
return null;
}
+12 -3
View File
@@ -2,7 +2,7 @@
import { MessageSquareText, Trash } from "lucide-react";
import Link from "next/link";
import { useContext, useEffect, useState } from "react";
import { useContext, useEffect, useRef, useState } from "react";
import AmberIcon from "#/amber.png";
import Image from "@/components/image";
import { SimpleTooltip } from "@/components/tooltip";
@@ -44,6 +44,7 @@ export function Draggable({
const [comment, setComment] = useState("");
const [assignedBadges, setBadges] = useState<string[]>([]);
const [dirty, setDirty] = useState(0);
const editGeneration = useRef(0);
useEffect(() => {
if (dirty) return;
@@ -54,13 +55,20 @@ export function Draggable({
useEffect(() => {
const timeout = setTimeout(() => {
if (!dirty || !cid) return;
setDirty(0);
setState(cid, { comment, badges: assignedBadges });
const generation = editGeneration.current;
void setState(cid, { comment, badges: assignedBadges })
.then(() => {
if (editGeneration.current === generation) setDirty(0);
})
.catch(() => {
// Keep the local edit dirty so a later edit retries the latest intent.
});
}, dirty);
return () => clearTimeout(timeout);
}, [dirty, comment, setState, cid, assignedBadges]);
function toggleBadge(badge: string) {
editGeneration.current++;
setDirty(200);
setBadges(
assignedBadges.includes(badge)
@@ -264,6 +272,7 @@ export function Draggable({
className="aspect-square resize-none bg-card disabled:opacity-90"
value={comment}
onChange={(ev) => {
editGeneration.current++;
setDirty(500);
setComment(ev.target.value);
}}
+2 -2
View File
@@ -14,7 +14,7 @@ export const TierListContext = createContext<{
setState: (
char: string,
data: Partial<typeof tierlistStates.$inferInsert>,
) => void;
) => Promise<void>;
editable: boolean;
deleteMode: boolean;
removeChar: (cid: string) => void;
@@ -23,7 +23,7 @@ export const TierListContext = createContext<{
badges: [],
tileSize: 64,
badgeSize: 24,
setState: () => {},
setState: async () => {},
editable: false,
deleteMode: false,
removeChar: () => {},
+186 -25
View File
@@ -77,6 +77,16 @@ import type {
tierlistVersions,
} from "@/lib/db/schema";
import { tlSse } from "@/lib/db/sse-endpoints";
import {
reconcileCanonicalStates,
reconcileScopedStateWrite,
TierlistCanonicalFetch,
} from "@/lib/tierlist-canonical-fetch";
import { TierlistSaveQueue } from "@/lib/tierlist-save-queue";
import type {
TierlistSyncData,
TierlistWriteResult,
} from "@/lib/tierlist-sync";
import { cn } from "@/lib/utils";
import { TierListCell } from "./cell";
import { Draggable } from "./character";
@@ -138,6 +148,22 @@ export function TierList({
const [states, setStates] = useState<(typeof tierlistStates.$inferSelect)[]>(
[],
);
const stateSaveGeneration = useRef(new Map<string, number>());
const stateEditRevision = useRef(new Map<string, number>());
const placementSaveGeneration = useRef(0);
const canonicalFetcher = useRef(
new TierlistCanonicalFetch<TierlistSyncData>(),
);
canonicalFetcher.current.setVersion(version.id);
const activeSaveQueues = useRef(new Set<string>());
const setSaveQueuePending = useCallback((key: string, pending: boolean) => {
if (pending) activeSaveQueues.current.add(key);
else activeSaveQueues.current.delete(key);
setEvStatus((status) => ({
...status,
upload: activeSaveQueues.current.size > 0,
}));
}, []);
const [_evStatus, setEvStatus] = useState<{
upload: boolean;
download: boolean;
@@ -167,33 +193,79 @@ export function TierList({
//#endregion
//#region states fetching
const fetchStates = useCallback(async () => {
setEvStatus((x) => ({ ...x, download: true }));
setStates(
await fetch(`/api/tl/${version.id}/states`)
.then((r) => r.json())
.finally(() => setEvStatus((x) => ({ ...x, download: false }))),
const fetchCanonical = useCallback(() => {
const revisionsAtRequest = new Map(stateEditRevision.current);
setEvStatus((status) => ({ ...status, download: true }));
canonicalFetcher.current.fetch(
version.id,
async () => {
const response = await fetch(`/api/tl/${version.id}/sync`, {
cache: "no-store",
});
if (!response.ok)
throw new Error(`Sync fetch failed (${response.status})`);
return (await response.json()) as TierlistSyncData;
},
(canonical) => {
setStates((current) => {
const locallyPending = stateSaveGeneration.current;
const protectedRefs = new Set(
[...stateEditRevision.current]
.filter(
([ref, revision]) => revision !== revisionsAtRequest.get(ref),
)
.map(([ref]) => ref),
);
return reconcileCanonicalStates(
canonical.states.filter((entry) => !locallyPending.has(entry.ref)),
current,
new Set([...protectedRefs, ...[...locallyPending.keys()]]),
);
});
if (placementSaveGeneration.current === 0) {
setPlacements((current) => ({
...Object.fromEntries(
tiers.flatMap((tier) =>
columns.map((column) => [
`${tier.id}-${column.id}`,
[] as string[],
]),
),
),
...canonical.placements,
untiered: current.untiered,
}));
}
},
(error) => {
toast.error(
`Sync ล้มเหลว: ${error instanceof Error ? error.message : error}`,
);
},
() => setEvStatus((status) => ({ ...status, download: false })),
);
}, [version.id]);
}, [version.id, tiers, columns]);
// Reload after connection restored
shared.signal("sync", fetchStates);
useEffect(() => {
fetchStates();
}, [fetchStates]);
shared.signal("sync", fetchCanonical);
useEffect(() => {
setEvStatus((x) => ({ ...x, ev: "connecting" }));
return tlSse(version.id).subMany(
canonicalFetcher.current.activate(version.id);
const subscription = tlSse(version.id).subMany(
{
update_states: setStates,
update_placements: setPlacements,
update_states: () => void fetchCanonical(),
update_placements: () => void fetchCanonical(),
},
{
onopen: () => setEvStatus((x) => ({ ...x, ev: "ready" })),
onerror: () => setEvStatus((x) => ({ ...x, ev: "unknown" })),
},
).clean;
}, [version.id]);
);
void fetchCanonical();
return () => {
subscription.clean();
};
}, [version.id, fetchCanonical]);
useEffect(() => () => canonicalFetcher.current.dispose(), []);
//#endregion
const colRef = useRef<HTMLDivElement | null>(null);
@@ -277,6 +349,69 @@ export function TierList({
...version.placements,
untiered: chars.map((ch) => ch.id).filter((c) => !tiered.includes(c)),
});
const placementQueueGeneration = useRef(0);
const saveQueueVersion = useRef(version.id);
const stateQueues = useRef(
new Map<
string,
TierlistSaveQueue<
typeof tierlistStates.$inferInsert,
TierlistWriteResult<(typeof tierlistStates.$inferSelect)[]>
>
>(),
);
const placementQueue = useRef<TierlistSaveQueue<
Record<string, string[]>,
TierlistWriteResult<Record<string, string[]>>
> | null>(null);
if (saveQueueVersion.current !== version.id) {
placementQueue.current?.dispose();
for (const queue of stateQueues.current.values()) queue.dispose();
stateQueues.current.clear();
placementQueue.current = null;
saveQueueVersion.current = version.id;
stateSaveGeneration.current.clear();
stateEditRevision.current.clear();
placementSaveGeneration.current = 0;
placementQueueGeneration.current = 0;
}
if (!placementQueue.current) {
placementQueue.current = new TierlistSaveQueue(
(value) => tlPlacements(version.id, value),
({ generation, result }) => {
if (generation !== placementQueueGeneration.current) return;
canonicalFetcher.current.invalidate(version.id);
setEvStatus((status) => ({ ...status, download: false }));
placementSaveGeneration.current = 0;
setPlacements((current) => ({
...Object.fromEntries(
tiers.flatMap((tier) =>
columns.map((column) => [
`${tier.id}-${column.id}`,
[] as string[],
]),
),
),
...result.data,
untiered: current.untiered,
}));
if (result.publication === "failed") void fetchCanonical();
},
(pending) => setSaveQueuePending("placements", pending),
(error) =>
toast.error(
`Sync ล้มเหลว: ${error instanceof Error ? error.message : error}. เปลี่ยนแปลงยังไม่บันทึก; ลองแก้ไขอีกครั้งเพื่อ retry`,
),
);
}
useEffect(
() => () => {
placementQueue.current?.dispose();
for (const queue of stateQueues.current.values()) queue.dispose();
stateQueues.current.clear();
},
[],
);
const [dragging, setDragging] = useState<string | null>(null);
function handleDragEnd(event: DragEndEvent) {
@@ -346,16 +481,15 @@ export function TierList({
}
setPlacements(newPlacements);
placementSaveGeneration.current++;
posthog.capture("tierlist_character_placed", {
character_id: charId.split("#")[0],
target_cell: typeof over.id === "string" ? over.id : undefined,
tierlist_version: version.id,
tierlist_type: type.id,
});
setEvStatus((x) => ({ ...x, upload: true }));
tlPlacements(version.id, newPlacements)
.catch((e) => toast.error(`Sync ล้มเหลว: ${e.message || e}`))
.finally(() => setEvStatus((x) => ({ ...x, upload: false })));
placementQueueGeneration.current++;
void placementQueue.current?.enqueue(newPlacements).catch(() => {});
}
//#endregion placements
@@ -383,9 +517,37 @@ export function TierList({
ref,
};
setStates((s) => [...s.filter((e) => e.ref !== ref), newEntry]);
tlState(newEntry).catch((e) =>
toast.error(`Sync ล้มเหลว: ${e.message || e}`),
const generation = (stateSaveGeneration.current.get(ref) ?? 0) + 1;
stateSaveGeneration.current.set(ref, generation);
stateEditRevision.current.set(
ref,
(stateEditRevision.current.get(ref) ?? 0) + 1,
);
canonicalFetcher.current.invalidate(version.id);
let queue = stateQueues.current.get(ref);
if (!queue) {
queue = new TierlistSaveQueue(
(value) => tlState(value),
({ generation: savedGeneration, result }) => {
if (stateSaveGeneration.current.get(ref) !== savedGeneration)
return;
canonicalFetcher.current.invalidate(version.id);
setEvStatus((status) => ({ ...status, download: false }));
stateSaveGeneration.current.delete(ref);
setStates((current) =>
reconcileScopedStateWrite(current, ref, result.data),
);
if (result.publication === "failed") void fetchCanonical();
},
(pending) => setSaveQueuePending(`state:${ref}`, pending),
(error) =>
toast.error(
`Sync ล้มเหลว: ${error instanceof Error ? error.message : error}. เปลี่ยนแปลงยังไม่บันทึก; ลองแก้ไขอีกครั้งเพื่อ retry`,
),
);
stateQueues.current.set(ref, queue);
}
await queue.enqueue(newEntry).then(() => undefined);
},
async removeChar(cid: string) {
setPlacements((s) =>
@@ -449,8 +611,7 @@ export function TierList({
>
<span>
{type.name} เวอร์ชั่น {version.name} ระดับ
<span className="text-yellow-400">{type.mode}</span>{" "}
ใช้ได้ถึง{" "}
<span className="text-yellow-400">{type.mode}</span> ใช้ได้ถึง{" "}
</span>
<span className="text-green-400">{version.deprecates}</span>
{deleteMode && (
+1 -1
View File
@@ -8,7 +8,7 @@ const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record<string, string>;
const redis = redisShared!;
export const revalidate = 60;
export const dynamic = "force-dynamic";
export async function GET() {
const red = await redis
+30
View File
@@ -0,0 +1,30 @@
import { eq, sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { tierlistStates, tierlistVersions } from "@/lib/db/schema";
import type { TierlistSyncData } from "@/lib/tierlist-sync";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ ver: string }> },
) {
const { ver } = await params;
const data = await db.transaction(async (tx): Promise<TierlistSyncData> => {
await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`);
const states = await tx
.select()
.from(tierlistStates)
.where(eq(tierlistStates.list, ver));
const [version] = await tx
.select({ placements: tierlistVersions.placements })
.from(tierlistVersions)
.where(eq(tierlistVersions.id, ver))
.limit(1);
return { states, placements: version?.placements ?? {} };
});
return NextResponse.json(data, {
headers: { "Cache-Control": "no-store, max-age=0" },
});
}
+12
View File
@@ -41,6 +41,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${base}/donate`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${base}/repair`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
...tlTypes.map(
(t) =>
({
+49 -43
View File
@@ -12,7 +12,7 @@
"@headless-tree/core": "^1.6.0",
"@headless-tree/react": "^1.6.0",
"@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "16.2.6",
"@next/bundle-analyzer": "16.4.0",
"@origin-space/image-cropper": "^0.1.9",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -56,7 +56,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^1.27.0",
"motion": "^12.40.0",
"next": "16.2.6",
"next": "16.4.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"posthog-js": "^1.367.0",
@@ -247,7 +247,7 @@
"@drizzle-team/brocli": ["@drizzle-team/[email protected]", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/[email protected]", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -321,55 +321,59 @@
"@hookform/resolvers": ["@hookform/[email protected]", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-arm64": ["@img/[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="],
"@img/sharp-darwin-x64": ["@img/[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-darwin-x64": ["@img/[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-freebsd-wasm32": ["@img/[email protected]", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="],
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-arm": ["@img/[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia[email protected]4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm[email protected]5.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x[email protected]4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm[email protected]5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="],
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="],
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -383,25 +387,25 @@
"@mrleebo/prisma-ast": ["@mrleebo/[email protected]", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-VxS+okLpNp6YUDLz3rAXv6sE8G4W5cX039bumkPub65xRr9BFxjHaT5ebX4DsiixIGaBwitjWTvx3mAbyI2EUQ=="],
"@next/bundle-analyzer": ["@next/bundle-analyzer@16.2.6", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA=="],
"@next/bundle-analyzer": ["@next/bundle-analyzer@16.4.0", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-z4iuh6QGVhPKa+Pq/e6WO1d5jFeVGDtjaVY2PVnguTyGTNY/Tw/ozRAHJoaQUyiKEcREot8iQnvmuIOkD+1wyw=="],
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
"@next/env": ["@next/env@16.4.0", "", {}, "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ=="],
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
@@ -677,7 +681,7 @@
"@standard-schema/utils": ["@standard-schema/[email protected]", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@swc/helpers": ["@swc/[email protected].15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@swc/helpers": ["@swc/[email protected].23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@@ -1065,7 +1069,7 @@
"nanostores": ["[email protected]", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
"next": ["next@16.4.0", "", { "dependencies": { "@next/env": "16.4.0", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.0", "@next/swc-darwin-x64": "16.4.0", "@next/swc-linux-arm64-gnu": "16.4.0", "@next/swc-linux-arm64-musl": "16.4.0", "@next/swc-linux-x64-gnu": "16.4.0", "@next/swc-linux-x64-musl": "16.4.0", "@next/swc-win32-arm64-msvc": "16.4.0", "@next/swc-win32-x64-msvc": "16.4.0", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg=="],
"next-themes": ["[email protected]", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
@@ -1223,7 +1227,7 @@
"set-harmonic-interval": ["[email protected]", "", {}, "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g=="],
"sharp": ["[email protected]4.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"sharp": ["[email protected]5.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="],
"sirv": ["[email protected]", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="],
@@ -1395,7 +1399,7 @@
"nano-css/csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"oxlint-tailwindcss/@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
@@ -1413,7 +1417,7 @@
"radix-ui/@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"stacktrace-gps/source-map": ["[email protected]", "", {}, "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA=="],
@@ -1473,6 +1477,8 @@
"https-proxy-agent/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"next/postcss/nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"oxlint-tailwindcss/@tailwindcss/node/enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
"oxlint-tailwindcss/@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+2
View File
@@ -0,0 +1,2 @@
[install]
globalStore = false
+3 -1
View File
@@ -13,7 +13,9 @@ export function ErrorModal({
<ModalBase title="Modal Error">
An error occured while spawning modal:
<pre className="break-all whitespace-pre-wrap">
{error.stack?.split("\n").slice(0, 2).join("\n")}
{error instanceof Error
? error.stack?.split("\n").slice(0, 2).join("\n")
: String(error)}
</pre>
<DialogFooter>
<DialogClose asChild>
+20
View File
@@ -1,5 +1,25 @@
# Buzz Events — k3s Deployment
## Next.js Preview (`next` branch)
- The `next` branch deploys a separate preview at `buzz-next.sudloh.com` using only `k8s/next/` resources: Deployment, Service, and Ingress named `app-next` with label `component: app-next`.
- The preview shares the `buzz` namespace and existing `buzz-env` secret, including its database credentials. `BASE_URL`, `BETTER_AUTH_URL`, and `DISCORD_REDIRECT_URI` are overridden for preview build and runtime behavior so production auth URLs in the shared secret cannot redirect preview traffic.
- Register `https://buzz-next.sudloh.com/rubgram/callback` as an allowed redirect URI in the Discord OAuth application used by `buzz-env`. DNS and HTTPS/TLS for `buzz-next.sudloh.com` must also be routed to the cluster ingress before preview auth is usable.
- The preview currently has no backend deployment. No backend images or production image tags are built, pushed, or rolled out by `.github/workflows/build-next.yml`.
- Each run builds and pushes only the immutable `frontend-next-$SHA` image. The preview workflow never builds, tags, pushes, starts, waits for, or cleans up a database migration; it does not run migrations. The preview shares the production database, so schema changes remain a separate, deliberate operation.
- The workflow is limited to the local `next` branch (push or manual dispatch from `next`) and does not alter the existing main/dev workflow or root `k8s/kustomization.yaml`.
Bootstrap the preview Deployment once from a trusted local kubectl context; do not have CI create it or apply the Deployment manifest. The CI service account can update/patch existing Deployments, but cannot create them. Before CI runs, a cluster operator must create `app-next` in namespace `buzz` using the `k8s/next/deployment-app-next.yaml` template, replacing `__TAG__` with a real immutable frontend tag (for example `frontend-next-<commit-sha>`), and ensure `buzz-env` exists. For example:
```bash
TAG=<commit-sha>
IMAGE=registry.neko-piranha.ts.net/astral/buzz
sed "s|${IMAGE}:frontend-next-__TAG__|${IMAGE}:frontend-next-${TAG}|g" k8s/next/deployment-app-next.yaml | kubectl apply -f -
kubectl apply -f k8s/next/service-app-next.yaml -f k8s/next/ingress-app-next.yaml
```
The workflow checks that `deployment/app-next` exists, applies only the preview Service and Ingress, then updates its `app-next` container image and waits for rollout. Do not apply the root `k8s/` kustomization for this preview because it manages production and backend resources. The existing Role already permits creating Services and Ingresses and updating/patching Deployments; no Deployment `create` permission is required.
## Architecture Decisions
### Ingress
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-next
labels:
app: buzz
component: app-next
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: buzz
component: app-next
template:
metadata:
labels:
app: buzz
component: app-next
spec:
terminationGracePeriodSeconds: 5
containers:
- name: app-next
image: registry.neko-piranha.ts.net/astral/buzz:frontend-next-__TAG__
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
protocol: TCP
envFrom:
- secretRef:
name: buzz-env
env:
- name: BASE_URL
value: https://buzz-next.sudloh.com
- name: BETTER_AUTH_URL
value: https://buzz-next.sudloh.com
- name: DISCORD_REDIRECT_URI
value: https://buzz-next.sudloh.com/rubgram/callback
resources:
limits:
cpu: "1"
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 60
timeoutSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 15
+19
View File
@@ -0,0 +1,19 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-next
annotations:
traefik.ingress.kubernetes.io/read-timeout: "200"
spec:
ingressClassName: traefik
rules:
- host: buzz-next.sudloh.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app-next
port:
number: 3000
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: buzz
resources:
- deployment-app-next.yaml
- service-app-next.yaml
- ingress-app-next.yaml
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: app-next
labels:
app: buzz
component: app-next
spec:
selector:
app: buzz
component: app-next
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
type: ClusterIP
+97 -43
View File
@@ -1,7 +1,7 @@
"use server";
import { fetch, randomUUIDv7 } from "bun";
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
import { and, asc, eq, inArray, isNotNull, lt, not, sql } from "drizzle-orm";
import type { PgDatabase } from "drizzle-orm/pg-core";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
@@ -23,6 +23,14 @@ import {
tierlistVersions,
} from "./db/schema";
import { sse, tlSse } from "./db/sse-endpoints";
import type { TierlistWriteResult } from "./tierlist-sync";
import {
finishTierlistWrite,
lockTierlistList,
normalizeTierlistPlacements,
resolveTierlistStateTarget,
validateTierlistStateInput,
} from "./tierlist-write";
import { b2s } from "./utils";
export async function getCharacters(chars: string[]) {
@@ -262,60 +270,106 @@ export async function revalidateCard(sub: string) {
export async function tlState(
data: Partial<typeof tierlistStates.$inferInsert>,
) {
): Promise<TierlistWriteResult<(typeof tierlistStates.$inferSelect)[]>> {
if (!(await adminCheck())) throw "Unauthorized";
const [existing] = await db
.select()
.from(tierlistStates)
.where(
or(
eq(tierlistStates.uuid, `${data.uuid}`),
and(
eq(tierlistStates.ref, `${data.ref}`),
eq(tierlistStates.list, `${data.list}`),
),
),
);
if (existing)
await db
.update(tierlistStates)
.set(data)
.where(eq(tierlistStates.uuid, existing.uuid));
else
await db
.insert(tierlistStates)
.values(data as typeof tierlistStates.$inferInsert);
const list = data.list || existing?.list;
const states = await db
.select()
.from(tierlistStates)
.where(eq(tierlistStates.list, list));
revalidatePath(`/api/tl/${list}/states`);
const { list, ref } = validateTierlistStateInput(data);
await actionLog(`Updated a state in tierlist ${list}`, data);
tlSse(list).pub("update_states", states);
const states = await db.transaction(async (tx) => {
await lockTierlistList(tx, list);
const [byUuid] = data.uuid
? await tx
.select()
.from(tierlistStates)
.where(eq(tierlistStates.uuid, data.uuid))
.limit(1)
: [];
// If legacy duplicate refs exist, update the lexicographically first UUID
// deterministically. No duplicate rows are deleted by this write.
const [byRef] = await tx
.select()
.from(tierlistStates)
.where(and(eq(tierlistStates.ref, ref), eq(tierlistStates.list, list)))
.orderBy(asc(tierlistStates.uuid))
.limit(1);
const existing = resolveTierlistStateTarget(byUuid, byRef, list);
if (existing) {
const { uuid: _uuid, ...updates } = data;
await tx
.update(tierlistStates)
.set(updates)
.where(
and(
eq(tierlistStates.uuid, existing.uuid),
eq(tierlistStates.list, list),
),
);
} else {
await tx
.insert(tierlistStates)
.values(data as typeof tierlistStates.$inferInsert);
}
return tx
.select()
.from(tierlistStates)
.where(eq(tierlistStates.list, list))
.orderBy(asc(tierlistStates.uuid));
});
return finishTierlistWrite({
list,
operation: "state",
data: states,
audit: () => actionLog(`Updated a state in tierlist ${list}`, data),
publish: () => tlSse(list).pub("update_states", states),
revalidate: () => revalidatePath(`/api/tl/${list}/states`),
});
}
export async function tlPlacements(
list: string,
placements: Record<string, string[]>,
) {
): Promise<TierlistWriteResult<Record<string, string[]>>> {
if (!(await adminCheck())) throw "Unauthorized";
const { untiered: _, ...placementObj } = placements;
if (typeof list !== "string" || !list.trim())
throw new Error("A tierlist version is required.");
if (
!placements ||
typeof placements !== "object" ||
Array.isArray(placements) ||
Object.entries(placements).some(
([tier, refs]) =>
!tier.trim() ||
!Array.isArray(refs) ||
refs.some((ref) => typeof ref !== "string"),
)
)
throw new Error("Invalid tierlist placements.");
await db
.update(tierlistVersions)
.set({
placements: placementObj,
})
.where(eq(tierlistVersions.id, list));
// Clone before removing the client-only untiered bucket; callers retain their
// original object and the legacy SSE event still receives its raw payload.
const placementObj = normalizeTierlistPlacements(placements);
const [version] = await db.transaction(async (tx) => {
await lockTierlistList(tx, list);
return tx
.update(tierlistVersions)
.set({ placements: placementObj })
.where(eq(tierlistVersions.id, list))
.returning({ placements: tierlistVersions.placements });
});
if (!version) throw new Error("Tierlist version not found.");
revalidatePath(`/api/tl/${list}`);
await actionLog(`Updated a placement in tierlist ${list}`);
tlSse(list).pub("update_placements", placements);
return finishTierlistWrite({
list,
operation: "placements",
data: version.placements,
audit: () => actionLog(`Updated a placement in tierlist ${list}`),
publish: () => tlSse(list).pub("update_placements", placements),
revalidate: () => revalidatePath(`/api/tl/${list}`),
});
}
export async function cdnDelete(ids: string[], force = false) {
+5 -3
View File
@@ -4,7 +4,9 @@ import { withReplicas } from "drizzle-orm/pg-core";
export const db = process.env.REPLICA_DATABASE_URL
? withReplicas(drizzle(process.env.DATABASE_URL!, { schema }), [
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
])
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
])
: drizzle(process.env.DATABASE_URL!, { schema });
// export const db = drizzle(process.env.DATABASE_URL!, { schema });
+6 -1
View File
@@ -136,10 +136,15 @@ export class EventSourceEndpoint<T extends EventSourceEventMap> {
throw new Error(
"EventSourceEndpoint.pub(...) can only be called on the server.",
);
this.manager.publish(this.eventMap[event].parse(data), {
const publication = this.manager.publish(this.eventMap[event].parse(data), {
topic: this.endpoint,
event: String(event),
});
// Existing publishers intentionally fire-and-forget. Mark the rejection as
// observed for those callers while still returning the original promise to
// callers that need to await delivery.
void publication.catch(() => {});
return publication;
}
subMany(
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "bun:test";
import {
reconcileCanonicalStates,
reconcileScopedStateWrite,
TierlistCanonicalFetch,
} from "./tierlist-canonical-fetch";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flush() {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("TierlistCanonicalFetch", () => {
it("fetches the switched-to version while ignoring the older response", async () => {
const coordinator = new TierlistCanonicalFetch<string>();
const old = deferred<string>();
const newVersion = deferred<string>();
const applied: string[] = [];
coordinator.setVersion("A");
coordinator.fetch(
"A",
() => old.promise,
(value) => applied.push(value),
() => {},
);
coordinator.setVersion("B");
coordinator.fetch(
"B",
() => newVersion.promise,
(value) => applied.push(value),
() => {},
);
old.resolve("A");
await flush();
expect(applied).toEqual([]);
newVersion.resolve("B");
await flush();
expect(applied).toEqual(["B"]);
});
it("refetches after an SSE invalidation during the initial fetch", async () => {
const coordinator = new TierlistCanonicalFetch<string>();
const first = deferred<string>();
const applied: string[] = [];
let fetchCount = 0;
coordinator.setVersion("A");
const load = () =>
++fetchCount === 1 ? first.promise : Promise.resolve("latest");
const fetch = () =>
coordinator.fetch(
"A",
load,
(value) => applied.push(value),
() => {},
);
fetch();
fetch();
first.resolve("stale");
await flush();
expect(fetchCount).toBe(2);
expect(applied).toEqual(["latest"]);
});
it("does not start a replacement after disposal", async () => {
const coordinator = new TierlistCanonicalFetch<string>();
const first = deferred<string>();
let fetchCount = 0;
coordinator.setVersion("A");
const load = () =>
++fetchCount === 1 ? first.promise : Promise.resolve("next");
coordinator.fetch(
"A",
load,
() => {},
() => {},
);
coordinator.fetch(
"A",
load,
() => {},
() => {},
);
coordinator.dispose();
first.resolve("stale");
await flush();
expect(fetchCount).toBe(1);
});
it("preserves a local edit made during a fetch while reconciling other refs", () => {
const merged = reconcileCanonicalStates(
[
{ ref: "one", value: "stale server value" },
{ ref: "two", value: "fresh server value" },
],
[
{ ref: "one", value: "local edit" },
{ ref: "two", value: "old local value" },
],
new Set(["one"]),
);
expect(merged).toEqual([
{ ref: "two", value: "fresh server value" },
{ ref: "one", value: "local edit" },
]);
});
it("scopes out-of-order server write responses to their own ref", () => {
let states = [
{ ref: "one", value: "newer one" },
{ ref: "two", value: "newer two" },
];
states = reconcileScopedStateWrite(states, "two", [
{ ref: "one", value: "stale one" },
{ ref: "two", value: "saved two" },
]);
states = reconcileScopedStateWrite(states, "one", [
{ ref: "one", value: "saved one" },
{ ref: "two", value: "stale two" },
]);
expect(states).toEqual([
{ ref: "two", value: "saved two" },
{ ref: "one", value: "saved one" },
]);
});
});
+103
View File
@@ -0,0 +1,103 @@
/** Keeps canonical reads current across invalidations and version switches. */
export function reconcileCanonicalStates<T extends { ref: string }>(
canonical: T[],
current: T[],
protectedRefs: Set<string>,
): T[] {
return [
...canonical.filter((entry) => !protectedRefs.has(entry.ref)),
...current.filter((entry) => protectedRefs.has(entry.ref)),
];
}
export function reconcileScopedStateWrite<T extends { ref: string }>(
current: T[],
ref: string,
saved: T[],
): T[] {
return [
...current.filter((entry) => entry.ref !== ref),
...saved.filter((entry) => entry.ref === ref),
];
}
export class TierlistCanonicalFetch<T> {
private activeVersion: string | undefined;
private disposed = false;
private readonly requests = new Map<
string,
{ generation: number; invalidated: boolean; inFlight: boolean }
>();
activate(version: string) {
this.disposed = false;
this.activeVersion = version;
}
setVersion(version: string) {
if (this.activeVersion === version) return;
this.activeVersion = version;
const request = this.requests.get(version);
if (request?.inFlight) request.invalidated = true;
}
invalidate(version: string) {
const request = this.requests.get(version);
if (request?.inFlight) request.invalidated = true;
}
fetch(
version: string,
load: () => Promise<T>,
apply: (data: T) => void,
onError: (error: unknown) => void,
onSettled: () => void = () => {},
) {
if (this.disposed || this.activeVersion !== version) return;
let request = this.requests.get(version);
if (request?.inFlight) {
request.invalidated = true;
return;
}
request ??= { generation: 0, invalidated: false, inFlight: false };
request.inFlight = true;
request.invalidated = false;
const generation = ++request.generation;
this.requests.set(version, request);
void load()
.then(
(data) => {
if (
!this.disposed &&
this.activeVersion === version &&
request?.generation === generation &&
!request.invalidated
)
apply(data);
},
(error: unknown) => {
if (!this.disposed && this.activeVersion === version) onError(error);
},
)
.finally(() => {
if (request?.generation !== generation) return;
request.inFlight = false;
if (
this.disposed ||
this.activeVersion !== version ||
!request.invalidated
) {
if (this.activeVersion === version) onSettled();
return;
}
request.invalidated = false;
this.fetch(version, load, apply, onError, onSettled);
});
}
dispose() {
this.disposed = true;
this.requests.clear();
}
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "bun:test";
import { TierlistSaveQueue } from "./tierlist-save-queue";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function flushMicrotasks() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
describe("TierlistSaveQueue", () => {
it("serializes writes and persists the latest coalesced value", async () => {
const writes: string[] = [];
const first = deferred<string>();
const second = deferred<string>();
const queue = new TierlistSaveQueue<string, string>(
(value) => {
writes.push(value);
return writes.length === 1 ? first.promise : second.promise;
},
() => {},
);
const a = queue.enqueue("a");
const b = queue.enqueue("b");
const c = queue.enqueue("c");
await flushMicrotasks();
expect(writes).toEqual(["a"]);
first.resolve("saved a");
await flushMicrotasks();
expect(writes).toEqual(["a", "c"]);
second.resolve("saved c");
await expect(Promise.all([a, b, c])).resolves.toEqual([
"saved a",
"saved c",
"saved c",
]);
});
it("retains failed intent for explicit retry", async () => {
const writes: string[] = [];
const retry = deferred<string>();
const queue = new TierlistSaveQueue<string, string>(
(value) => {
writes.push(value);
if (writes.length === 1) return Promise.reject(new Error("offline"));
return retry.promise;
},
() => {},
);
await expect(queue.enqueue("latest")).rejects.toThrow("offline");
const retried = queue.retry();
await flushMicrotasks();
expect(writes).toEqual(["latest", "latest"]);
retry.resolve("saved");
await expect(retried).resolves.toBe("saved");
});
it("continues with a newer queued value after an older write fails", async () => {
const writes: string[] = [];
const latest = deferred<string>();
const queue = new TierlistSaveQueue<string, string>(
(value) => {
writes.push(value);
return writes.length === 1
? Promise.reject(new Error("offline"))
: latest.promise;
},
() => {},
);
const oldIntent = queue.enqueue("old");
const latestIntent = queue.enqueue("latest");
await flushMicrotasks();
await expect(oldIntent).rejects.toThrow("offline");
await flushMicrotasks();
expect(writes).toEqual(["old", "latest"]);
latest.resolve("saved latest");
await expect(latestIntent).resolves.toBe("saved latest");
});
});
+101
View File
@@ -0,0 +1,101 @@
export type SaveQueueResult<T, R> = {
generation: number;
value: T;
result: R;
};
type Waiter<R> = {
generation: number;
resolve: (result: R) => void;
reject: (error: unknown) => void;
};
/** Serializes writes while coalescing edits made during an in-flight write. */
export class TierlistSaveQueue<T, R> {
private generation = 0;
private desired: { generation: number; value: T } | undefined;
private writing = false;
private disposed = false;
private waiters: Waiter<R>[] = [];
constructor(
private readonly write: (value: T) => Promise<R>,
private readonly onSuccess: (saved: SaveQueueResult<T, R>) => void,
private readonly onPendingChange: (pending: boolean) => void = () => {},
private readonly onFailure: (error: unknown, value: T) => void = () => {},
) {}
get isPending() {
return this.writing;
}
enqueue(value: T): Promise<R> {
if (this.disposed)
return Promise.reject(new Error("Save queue is disposed"));
const generation = ++this.generation;
this.desired = { generation, value };
this.onPendingChange(true);
const promise = new Promise<R>((resolve, reject) => {
this.waiters.push({ generation, resolve, reject });
});
this.flush();
return promise;
}
retry(): Promise<R> {
if (!this.desired)
return Promise.reject(new Error("No failed save to retry"));
return this.enqueue(this.desired.value);
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.desired = undefined;
for (const waiter of this.waiters)
waiter.reject(new Error("Save queue disposed"));
this.waiters = [];
this.onPendingChange(false);
}
private flush() {
if (this.writing || !this.desired) return;
const attempted = this.desired;
this.writing = true;
this.onPendingChange(true);
void Promise.resolve()
.then(() => this.write(attempted.value))
.then(
(result) => {
if (this.disposed) return;
this.onSuccess({ ...attempted, result });
this.waiters = this.waiters.filter((waiter) => {
if (waiter.generation > attempted.generation) return true;
waiter.resolve(result);
return false;
});
if (this.desired?.generation === attempted.generation)
this.desired = undefined;
this.writing = false;
this.onPendingChange(false);
this.flush();
},
(error: unknown) => {
if (this.disposed) return;
this.onFailure(error, attempted.value);
this.waiters = this.waiters.filter((waiter) => {
if (waiter.generation > attempted.generation) return true;
waiter.reject(error);
return false;
});
this.writing = false;
this.onPendingChange(
!!this.desired && this.desired.generation > attempted.generation,
);
// Continue newer work; otherwise keep failed intent for explicit retry.
if (this.desired && this.desired.generation > attempted.generation)
this.flush();
},
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { tierlistStates } from "./db/schema";
export type TierlistState = typeof tierlistStates.$inferSelect;
export type TierlistSyncData = {
states: TierlistState[];
placements: Record<string, string[]>;
};
export type TierlistWriteResult<T> = {
persisted: true;
data: T;
publication: "sent" | "failed";
};
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, spyOn, test } from "bun:test";
import { PgDialect } from "drizzle-orm/pg-core";
import type { tierlistStates } from "./db/schema";
import {
finishTierlistWrite,
lockTierlistList,
normalizeTierlistPlacements,
resolveTierlistStateTarget,
validateTierlistStateInput,
} from "./tierlist-write";
type StateRow = typeof tierlistStates.$inferSelect;
const stateRow = (uuid: string, list: string, ref = "char-1") =>
({ uuid, list, ref }) as StateRow;
describe("tierlist write consistency helpers", () => {
test("validates list, ref, and optional UUID before writing", () => {
expect(
validateTierlistStateInput({
list: "v1",
ref: "char-1",
uuid: undefined,
}),
).toEqual({ list: "v1", ref: "char-1" });
expect(() =>
validateTierlistStateInput({ list: "", ref: "char-1" }),
).toThrow();
expect(() => validateTierlistStateInput({ list: "v1", ref: "" })).toThrow();
expect(() =>
validateTierlistStateInput({ list: "v1", ref: "char-1", uuid: "bad" }),
).toThrow();
});
test("rejects cross-list UUIDs and canonicalizes duplicate refs", () => {
const uuidMatch = stateRow("uuid-1", "v1");
const canonicalRefMatch = stateRow("uuid-0", "v1");
expect(() =>
resolveTierlistStateTarget(stateRow("uuid-2", "v2"), uuidMatch, "v1"),
).toThrow("belongs to another version");
expect(resolveTierlistStateTarget(uuidMatch, undefined, "v1")).toBe(
uuidMatch,
);
expect(resolveTierlistStateTarget(uuidMatch, canonicalRefMatch, "v1")).toBe(
canonicalRefMatch,
);
expect(resolveTierlistStateTarget(undefined, uuidMatch, "v1")).toBe(
uuidMatch,
);
});
test("uses one transaction-scoped advisory lock per list before mutations", async () => {
const order: string[] = [];
const queries: unknown[] = [];
await lockTierlistList(
{
execute: async (query) => {
order.push("lock");
queries.push(query);
},
},
"v1",
);
order.push("write");
const compiled = new PgDialect().sqlToQuery(queries[0] as never);
expect(compiled.sql).toContain("pg_advisory_xact_lock");
expect(compiled.sql).toContain("hashtextextended");
expect(compiled.params).toContain("v1");
expect(order).toEqual(["lock", "write"]);
});
test("normalizes placements without mutating the caller's untiered data", () => {
const placements = { S: ["a"], untiered: ["b"] };
expect(normalizeTierlistPlacements(placements)).toEqual({ S: ["a"] });
expect(placements).toEqual({ S: ["a"], untiered: ["b"] });
});
test("returns persisted success when publication fails after commit", async () => {
const errorLog = spyOn(console, "error").mockImplementation(() => {});
try {
const result = await finishTierlistWrite({
list: "v1",
operation: "state",
data: [{ uuid: "state-1" }],
audit: async () => {},
revalidate: () => {},
publish: async () => {
throw new Error("redis unavailable");
},
});
expect(result).toEqual({
persisted: true,
data: [{ uuid: "state-1" }],
publication: "failed",
});
expect(errorLog).toHaveBeenCalledTimes(1);
} finally {
errorLog.mockRestore();
}
});
test("reports sent when post-commit publication succeeds", async () => {
const result = await finishTierlistWrite({
list: "v1",
operation: "placements",
data: { S: ["char-1"] },
audit: async () => {},
revalidate: () => {},
publish: async () => 1,
});
expect(result).toEqual({
persisted: true,
data: { S: ["char-1"] },
publication: "sent",
});
});
test("does not turn a precommit transaction failure into persisted success", async () => {
const failure = new Error("transaction failed");
await expect(
lockTierlistList({ execute: async () => Promise.reject(failure) }, "v1"),
).rejects.toBe(failure);
});
});
+98
View File
@@ -0,0 +1,98 @@
import { sql } from "drizzle-orm";
import type { tierlistStates } from "./db/schema";
import type { TierlistWriteResult } from "./tierlist-sync";
type StateInput = Partial<typeof tierlistStates.$inferInsert>;
type StateRow = typeof tierlistStates.$inferSelect;
export function validateTierlistStateInput(data: StateInput) {
const { list, ref, uuid } = data;
if (typeof list !== "string" || !list.trim())
throw new Error("A tierlist version is required.");
if (typeof ref !== "string" || !ref.trim())
throw new Error("A tierlist state reference is required.");
if (
uuid !== undefined &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
uuid,
)
)
throw new Error("A valid tierlist state UUID is required.");
return { list, ref };
}
export function resolveTierlistStateTarget(
byUuid: StateRow | undefined,
byRef: StateRow | undefined,
list: string,
) {
if (byUuid && byUuid.list !== list)
throw new Error("Tierlist state UUID belongs to another version.");
if (byUuid && byRef && byUuid.ref === byRef.ref) return byRef;
return byUuid ?? byRef;
}
type TransactionLike = {
execute(query: ReturnType<typeof sql>): Promise<unknown>;
};
export async function lockTierlistList(tx: TransactionLike, list: string) {
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${list}, 0))`,
);
}
export function normalizeTierlistPlacements(
placements: Record<string, string[]>,
) {
const { untiered: _untiered, ...persistedPlacements } =
structuredClone(placements);
return persistedPlacements;
}
export async function finishTierlistWrite<T>({
list,
operation,
data,
audit,
publish,
revalidate,
}: {
list: string;
operation: "state" | "placements";
data: T;
audit: () => Promise<unknown>;
publish: () => Promise<unknown>;
revalidate: () => void;
}): Promise<TierlistWriteResult<T>> {
try {
revalidate();
} catch (error) {
logTierlistPostCommitFailure(list, operation, "revalidation", error);
}
try {
await audit();
} catch (error) {
logTierlistPostCommitFailure(list, operation, "audit", error);
}
let publication: TierlistWriteResult<T>["publication"] = "sent";
try {
await publish();
} catch (error) {
publication = "failed";
logTierlistPostCommitFailure(list, operation, "publication", error);
}
return { persisted: true, data, publication };
}
function logTierlistPostCommitFailure(
list: string,
operation: string,
step: string,
error: unknown,
) {
console.error(
`Tierlist ${operation} post-commit ${step} failed for ${list} (${error instanceof Error ? error.name : "unknown error"}).`,
);
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { shouldSnapshot, type TierlistResolvedConfig } from "./tierlist";
function config({
deprecates = "01/01/2000",
chars = [{ id: "char-1" }, { id: "char-2" }],
placements = { S: ["char-1#2026-01", "char-2"] },
}: {
deprecates?: string;
chars?: { id: string }[];
placements?: Record<string, string[]>;
} = {}) {
return {
version: { deprecates, placements },
chars,
} as unknown as TierlistResolvedConfig;
}
describe("shouldSnapshot", () => {
test("does not snapshot a fully tiered version before its deprecation date", () => {
expect(shouldSnapshot(config({ deprecates: "31/12/2999" }))).toBe(false);
});
test("does not snapshot an old version with unfinished placements", () => {
expect(shouldSnapshot(config({ placements: { S: ["char-1"] } }))).toBe(
false,
);
});
test("snapshots an old version only when every character is tiered", () => {
expect(shouldSnapshot(config())).toBe(true);
});
test("does not consider an empty character list fully tiered", () => {
expect(shouldSnapshot(config({ chars: [] }))).toBe(false);
});
test("does not snapshot when the deprecation date is invalid", () => {
expect(
shouldSnapshot(
config({
deprecates: "not-a-date",
placements: { S: ["char-1", "char-2"] },
}),
),
).toBe(false);
});
test("does not snapshot when the deprecation date is missing", () => {
expect(
shouldSnapshot(
config({ deprecates: "", placements: { S: ["char-1", "char-2"] } }),
),
).toBe(false);
});
});
+2 -2
View File
@@ -102,7 +102,7 @@ async function buildLiveTierlistConfig(
});
}
function shouldSnapshot(config: TierlistResolvedConfig) {
export function shouldSnapshot(config: TierlistResolvedConfig) {
const deprecatedAt = parseDate(config.version.deprecates);
const olderThanMonth =
Number.isFinite(deprecatedAt) &&
@@ -117,7 +117,7 @@ function shouldSnapshot(config: TierlistResolvedConfig) {
config.chars.length > 0 &&
config.chars.every((char) => placed.has(char.id));
return olderThanMonth || allTiered;
return olderThanMonth && allTiered;
}
function parseDate(value: string) {
+1 -1
View File
@@ -43,9 +43,9 @@ export default {
minimumCacheTTL: 86400,
},
experimental: {
agentUpgrade: "latest",
imgOptTimeoutInSeconds: 30,
typedEnv: true,
viewTransition: true,
serverActions: { bodySizeLimit: "30mb" },
},
deploymentId: process.env.NEXT_DEPLOYMENT_ID,
+2 -2
View File
@@ -26,7 +26,7 @@
"@headless-tree/core": "^1.6.0",
"@headless-tree/react": "^1.6.0",
"@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "16.2.6",
"@next/bundle-analyzer": "16.4.0",
"@origin-space/image-cropper": "^0.1.9",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -70,7 +70,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^1.27.0",
"motion": "^12.40.0",
"next": "16.2.6",
"next": "16.4.0",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"posthog-js": "^1.367.0",
+3 -1
View File
@@ -1,5 +1,7 @@
const config = {
plugins: ["@tailwindcss/postcss"],
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB