feat(catalog) : sync Lunaris updates from Discord
CI / Verify (push) Successful in 1m13s
CI / Build immutable images and deploy (push) Successful in 5m34s

This commit is contained in:
2026-09-03 17:51:02 +07:00 Unverified
parent 2018005758
commit 76395a0b42
17 changed files with 520 additions and 19 deletions
+4
View File
@@ -23,6 +23,10 @@ OUTBOX_BATCH_SIZE=20
OUTBOX_POLL_MS=1000 OUTBOX_POLL_MS=1000
OUTBOX_LEASE_MS=30000 OUTBOX_LEASE_MS=30000
# Discord-triggered Lunaris catalog sync
DISCORD_BOT_TOKEN=replace-with-discord-bot-token
DISCORD_CHANNEL_ID=replace-with-private-channel-id
# S3-compatible private media storage # S3-compatible private media storage
S3_ENDPOINT=https://s3.example.internal S3_ENDPOINT=https://s3.example.internal
S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev
+6
View File
@@ -167,9 +167,15 @@ jobs:
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
deployment/buzz-sheet-worker \ deployment/buzz-sheet-worker \
worker="$REGISTRY_IMAGE:$REVISION" worker="$REGISTRY_IMAGE:$REVISION"
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
deployment/buzz-sheet-discord-worker \
discord-worker="$REGISTRY_IMAGE:$REVISION"
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet \ deployment/buzz-sheet \
--timeout=10m --timeout=10m
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-worker \ deployment/buzz-sheet-worker \
--timeout=10m --timeout=10m
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-discord-worker \
--timeout=10m
+6 -1
View File
@@ -13,7 +13,11 @@ COPY . .
RUN bun build scripts/outbox-worker.ts \ RUN bun build scripts/outbox-worker.ts \
--target=bun \ --target=bun \
--minify \ --minify \
--outfile=dist/outbox-worker.js --outfile=dist/outbox-worker.js && \
bun build backend/discord.ts \
--target=bun \
--minify \
--outfile=dist/discord.js
FROM node:22-bookworm-slim AS next-builder FROM node:22-bookworm-slim AS next-builder
WORKDIR /app WORKDIR /app
@@ -52,6 +56,7 @@ COPY --from=next-builder --chown=bun:bun /app/.next/standalone ./
COPY --from=next-builder --chown=bun:bun /app/.next/static ./.next/static COPY --from=next-builder --chown=bun:bun /app/.next/static ./.next/static
COPY --from=next-builder --chown=bun:bun /app/public ./public COPY --from=next-builder --chown=bun:bun /app/public ./public
COPY --from=worker-builder --chown=bun:bun /app/dist/outbox-worker.js ./worker/outbox-worker.js COPY --from=worker-builder --chown=bun:bun /app/dist/outbox-worker.js ./worker/outbox-worker.js
COPY --from=worker-builder --chown=bun:bun /app/dist/discord.js ./worker/discord.js
COPY --from=worker-builder --chown=bun:bun /app/lib/catalog/assets ./lib/catalog/assets COPY --from=worker-builder --chown=bun:bun /app/lib/catalog/assets ./lib/catalog/assets
USER 1000:1000 USER 1000:1000
EXPOSE 3000 EXPOSE 3000
+8 -6
View File
@@ -9,7 +9,7 @@ Extra sections. Use the English terms “Build” and “Team” throughout the
The admin workflow stays intentionally simple: The admin workflow stays intentionally simple:
1. Manually Sync the latest Lunaris catalog. 1. Sync the latest Lunaris catalog manually or from its Discord announcement.
2. Select one Lunaris character at `/admin/create`. 2. Select one Lunaris character at `/admin/create`.
3. Upload a required Guide Image and write the Overview. 3. Upload a required Guide Image and write the Overview.
4. Edit one structured section at a time under `/admin/[charid]`. 4. Edit one structured section at a time under `/admin/[charid]`.
@@ -32,11 +32,13 @@ Remove Guide moves content to a recoverable Trash before permanent deletion.
## Catalog and media ## Catalog and media
The Sync button enqueues a worker job. It fetches Lunaris `version.json` and The manual Sync button fetches Lunaris `version.json`. A dedicated Discord
the versioned character, weapon, and artifact lists, validates them, filters worker instead extracts the exact version from followed announcements and
placeholder or missing-image records, mirrors unique WebP assets and normalized recovers missed announcements from recent channel history. Both paths share a
JSON to Bun S3, then atomically activates the completed version. Failed jobs do Redis lock, validate the versioned character, weapon, and artifact lists, filter
not replace the active catalog. There is no automatic sync. placeholder or missing-image records, mirror unique assets and normalized JSON
to Bun S3, then activate the completed version. Failed syncs do not replace the
active catalog.
`S3_ENDPOINT` is the private Garage API used for writes. `S3_ENDPOINT` is the private Garage API used for writes.
`S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev` is the public read base. Browser `S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev` is the public read base. Browser
+12 -4
View File
@@ -26,6 +26,7 @@ Production target: `https://guide.sudloh.com`
retryable outbox. retryable outbox.
- Shared Redis cache invalidation and privacy-safe Server-Sent Events across - Shared Redis cache invalidation and privacy-safe Server-Sent Events across
replicas. replicas.
- Discord-triggered Lunaris catalog updates with exact-version synchronization.
The five reference workbooks are represented only by sanitized structural and The five reference workbooks are represented only by sanitized structural and
formula fixtures. Their guide text and media are not imported or published. formula fixtures. Their guide text and media are not imported or published.
@@ -109,6 +110,7 @@ and external services. Never enable it in a deployed environment.
| `bun run db:generate` | Generate a Drizzle migration from schema changes | | `bun run db:generate` | Generate a Drizzle migration from schema changes |
| `bun run db:migrate` | Apply committed Drizzle migrations | | `bun run db:migrate` | Apply committed Drizzle migrations |
| `bun run db:seed` | Upsert the built-in templates | | `bun run db:seed` | Upsert the built-in templates |
| `bun run worker:discord` | Listen for Lunaris update announcements |
| `bun run worker:outbox` | Process cache and event outbox records | | `bun run worker:outbox` | Process cache and event outbox records |
## Runtime design ## Runtime design
@@ -118,7 +120,13 @@ versions, revision snapshots, media references, and outbox records in one
PostgreSQL transaction. The outbox worker then invalidates affected Redis tags PostgreSQL transaction. The outbox worker then invalidates affected Redis tags
before publishing an opaque `{ type, id, version }` event. Connected clients before publishing an opaque `{ type, id, version }` event. Connected clients
refetch authoritative state; SSE never contains page content and is not the refetch authoritative state; SSE never contains page content and is not the
source of correctness. source of truth.
The Discord catalog worker watches the private channel configured by
`DISCORD_CHANNEL_ID`. It parses Lunaris `Version Change` announcements and
synchronizes that exact version. On startup it checks recent channel history,
so it does not need to poll Lunaris while idle. Enable the Discord Message
Content intent and grant the bot View Channel and Read Message History.
Public snapshots use a shared Redis-backed Next.js cache. Admin routes remain Public snapshots use a shared Redis-backed Next.js cache. Admin routes remain
dynamic. SSE streams send a 90-second heartbeat and close after 30 minutes so dynamic. SSE streams send a 90-second heartbeat and close after 30 minutes so
@@ -160,7 +168,7 @@ Without `REDIS_INTEGRATION_URL`, that external-service test is skipped.
## Containers ## Containers
The application image compiles Next.js in a Node builder stage, then runs the The application image compiles Next.js in a Node builder stage, then runs the
standalone server and bundled outbox worker on Bun as the unprivileged numeric standalone server and bundled workers on Bun as the unprivileged numeric
UID/GID `1000:1000`. The migration image runs the committed Drizzle migrations UID/GID `1000:1000`. The migration image runs the committed Drizzle migrations
with the same identity. with the same identity.
@@ -186,7 +194,7 @@ The runtime images do not contain local `.env` files.
Kustomize resources live in `k8s/` and define: Kustomize resources live in `k8s/` and define:
- Namespace `buzz-sheet`. - Namespace `buzz-sheet`.
- Two rolling web replicas and one outbox worker. - Two rolling web replicas, one outbox worker, and one Discord catalog worker.
- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`. - ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`.
- Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route. - Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route.
- Startup, liveness, and dependency-aware readiness probes. - Startup, liveness, and dependency-aware readiness probes.
@@ -257,6 +265,6 @@ Configure these Gitea secrets later:
The deploy job creates a revision-named migration Job, waits for completion, The deploy job creates a revision-named migration Job, waits for completion,
updates `NEXT_DEPLOYMENT_ID`, and only then rolls out the immutable application updates `NEXT_DEPLOYMENT_ID`, and only then rolls out the immutable application
image to the web and worker deployments. Schema changes must follow an image to the web and both worker deployments. Schema changes must follow an
expand/contract sequence so the previous application revision stays compatible expand/contract sequence so the previous application revision stays compatible
during a rolling update. during a rolling update.
+176
View File
@@ -0,0 +1,176 @@
import {
ChannelType,
Client,
Events,
GatewayIntentBits,
type Message,
} from "discord.js";
import { closeDb } from "@/db/client";
import { processCatalogSync } from "@/lib/catalog/sync";
import {
compareLunarisVersions,
parseLunarisVersionChange,
} from "@/lib/catalog/version";
import { closeRedisClient } from "@/lib/redis/client";
const RETRY_DELAYS_MS = [0, 30_000, 120_000, 600_000, 1_800_000] as const;
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required for the Discord catalog worker.`);
return value;
}
function log(event: string, details: Record<string, unknown> = {}): void {
console.log(JSON.stringify({ component: "discord-catalog-worker", event, ...details }));
}
function messageText(message: Message): string {
return [
message.content,
...message.embeds.flatMap((embed) => [
embed.title ?? "",
embed.description ?? "",
...embed.fields.flatMap((field) => [field.name, field.value]),
]),
].join("\n");
}
function wait(milliseconds: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return Promise.resolve(false);
return new Promise((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener("abort", aborted);
resolve(true);
}, milliseconds);
const aborted = () => {
clearTimeout(timer);
resolve(false);
};
signal.addEventListener("abort", aborted, { once: true });
});
}
const token = required("DISCORD_BOT_TOKEN");
const channelId = required("DISCORD_CHANNEL_ID");
const shutdown = new AbortController();
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
let pendingVersion: string | null = null;
let runner: Promise<void> | null = null;
async function syncWithRetry(version: string): Promise<void> {
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt += 1) {
if (shutdown.signal.aborted) return;
if (
pendingVersion &&
compareLunarisVersions(pendingVersion, version) > 0
) {
log("sync_superseded", { version, pendingVersion });
return;
}
if (!(await wait(RETRY_DELAYS_MS[attempt], shutdown.signal))) return;
if (
pendingVersion &&
compareLunarisVersions(pendingVersion, version) > 0
) {
log("sync_superseded", { version, pendingVersion });
return;
}
try {
log("sync_started", { version, attempt: attempt + 1 });
const result = await processCatalogSync(version);
log(result === "synced" ? "sync_completed" : "sync_ignored", {
version,
reason: result === "unchanged" ? "current-or-older" : undefined,
});
return;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
if (attempt === RETRY_DELAYS_MS.length - 1) {
log("sync_failed", { version, attempts: attempt + 1, error: message });
return;
}
log("sync_retrying", { version, attempt: attempt + 1, error: message });
}
}
}
async function drain(): Promise<void> {
while (pendingVersion && !shutdown.signal.aborted) {
const version = pendingVersion;
pendingVersion = null;
await syncWithRetry(version);
}
}
function enqueue(version: string, source: "history" | "message"): void {
if (!pendingVersion || compareLunarisVersions(version, pendingVersion) > 0) {
pendingVersion = version;
log("version_queued", { version, source });
}
if (!runner) {
runner = drain().finally(() => {
runner = null;
});
}
}
function handleMessage(message: Message, source: "history" | "message"): void {
if (message.channelId !== channelId || message.author.id === client.user?.id) return;
const change = parseLunarisVersionChange(messageText(message));
if (change) enqueue(change.targetVersion, source);
}
client.on(Events.MessageCreate, (message) => handleMessage(message, "message"));
client.on(Events.Error, (cause) => log("discord_error", { error: cause.message }));
client.on(Events.Warn, (message) => log("discord_warning", { message }));
let stopWorker: (() => void) | undefined;
const stopped = new Promise<void>((resolve) => {
stopWorker = resolve;
});
const stop = () => {
shutdown.abort();
stopWorker?.();
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
try {
const ready = new Promise<void>((resolve) => {
client.once(Events.ClientReady, () => resolve());
});
await client.login(token);
await Promise.race([ready, stopped]);
if (!shutdown.signal.aborted) {
log("discord_ready", { userId: client.user?.id, channelId });
const channel = await client.channels.fetch(channelId);
if (
!channel ||
(channel.type !== ChannelType.GuildText &&
channel.type !== ChannelType.GuildAnnouncement)
) {
throw new Error("DISCORD_CHANNEL_ID must identify a guild text channel.");
}
const messages = await channel.messages.fetch({ limit: 25 });
for (const message of messages.values()) handleMessage(message, "history");
await stopped;
}
} finally {
shutdown.abort();
client.destroy();
await Promise.resolve(runner).catch(() => undefined);
await Promise.all([closeDb(), closeRedisClient()]);
log("stopped");
}
+46 -1
View File
@@ -14,6 +14,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
@@ -138,6 +139,18 @@
"@better-fetch/fetch": ["@better-fetch/[email protected]", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], "@better-fetch/fetch": ["@better-fetch/[email protected]", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="],
"@discordjs/builders": ["@discordjs/[email protected]", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="],
"@discordjs/collection": ["@discordjs/[email protected]", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
"@discordjs/formatters": ["@discordjs/[email protected]", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
"@discordjs/rest": ["@discordjs/[email protected]", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.50", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg=="],
"@discordjs/util": ["@discordjs/[email protected]", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@discordjs/ws": ["@discordjs/[email protected]", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
"@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/accessibility": ["@dnd-kit/[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], "@dnd-kit/core": ["@dnd-kit/[email protected]", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
@@ -426,6 +439,12 @@
"@rtsao/scc": ["@rtsao/[email protected]", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rtsao/scc": ["@rtsao/[email protected]", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@sapphire/async-queue": ["@sapphire/[email protected]", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
"@sapphire/snowflake": ["@sapphire/[email protected]", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
"@sec-ant/readable-stream": ["@sec-ant/[email protected]", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sec-ant/readable-stream": ["@sec-ant/[email protected]", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@sindresorhus/merge-streams": ["@sindresorhus/[email protected]", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
@@ -510,6 +529,8 @@
"@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/validate-npm-package-name": ["@types/[email protected]", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/ws": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
"@typescript-eslint/parser": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ=="], "@typescript-eslint/parser": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ=="],
@@ -590,6 +611,8 @@
"@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], "@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/[email protected]", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], "acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@@ -796,6 +819,10 @@
"diff": ["[email protected]", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "diff": ["[email protected]", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"discord-api-types": ["[email protected]", "", {}, "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA=="],
"discord.js": ["[email protected]", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.2", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.5", "discord-api-types": "^0.38.49", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "^6.27.0" } }, "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A=="],
"doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "doctrine": ["[email protected]", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dot-prop": ["[email protected]", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
@@ -1192,8 +1219,12 @@
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["[email protected]", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.snakecase": ["[email protected]", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"log-symbols": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "log-symbols": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["[email protected]", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -1202,6 +1233,8 @@
"lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg=="], "lucide-react": ["[email protected]", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg=="],
"magic-bytes.js": ["[email protected]", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], "magicast": ["[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="],
@@ -1536,6 +1569,8 @@
"ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-api-utils": ["[email protected]", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"ts-mixer": ["[email protected]", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"ts-morph": ["[email protected]", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "ts-morph": ["[email protected]", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
"tsconfig-paths": ["[email protected]", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], "tsconfig-paths": ["[email protected]", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
@@ -1564,7 +1599,7 @@
"unbox-primitive": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "unbox-primitive": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
@@ -1614,6 +1649,8 @@
"wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["[email protected]", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], "wsl-utils": ["[email protected]", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -1640,12 +1677,18 @@
"@base-ui/utils/reselect": ["[email protected]", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], "@base-ui/utils/reselect": ["[email protected]", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/[email protected]", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/[email protected]", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/commander": ["[email protected]", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/execa": ["[email protected]", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@dotenvx/dotenvx/open": ["[email protected]", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "@dotenvx/dotenvx/open": ["[email protected]", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"@dotenvx/dotenvx/undici": ["[email protected]", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"@dotenvx/dotenvx/which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@dotenvx/dotenvx/which": ["[email protected]", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@esbuild-kit/core-utils/esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
@@ -1744,6 +1787,8 @@
"shadcn/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "shadcn/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"shadcn/undici": ["[email protected]", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"shadcn/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "shadcn/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"string-width/emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "string-width/emoji-regex": ["[email protected]", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+9 -2
View File
@@ -27,8 +27,8 @@ kubectl create secret generic buzz-sheet-env \
--output=yaml | kubectl apply --filename=- --output=yaml | kubectl apply --filename=-
``` ```
Running pods do not reload Secret values automatically. Restart both the web Running pods do not reload Secret values automatically. Restart the web
application and the outbox worker, then wait for each rollout: application and both workers, then wait for each rollout:
```bash ```bash
kubectl rollout restart deployment/buzz-sheet \ kubectl rollout restart deployment/buzz-sheet \
@@ -37,6 +37,9 @@ kubectl rollout restart deployment/buzz-sheet \
kubectl rollout restart deployment/buzz-sheet-worker \ kubectl rollout restart deployment/buzz-sheet-worker \
--namespace buzz-sheet --namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-discord-worker \
--namespace buzz-sheet
kubectl rollout status deployment/buzz-sheet \ kubectl rollout status deployment/buzz-sheet \
--namespace buzz-sheet \ --namespace buzz-sheet \
--timeout=10m --timeout=10m
@@ -44,6 +47,10 @@ kubectl rollout status deployment/buzz-sheet \
kubectl rollout status deployment/buzz-sheet-worker \ kubectl rollout status deployment/buzz-sheet-worker \
--namespace buzz-sheet \ --namespace buzz-sheet \
--timeout=10m --timeout=10m
kubectl rollout status deployment/buzz-sheet-discord-worker \
--namespace buzz-sheet \
--timeout=10m
``` ```
Verify PostgreSQL and Redis readiness, then inspect the application logs: Verify PostgreSQL and Redis readiness, then inspect the application logs:
+1 -1
View File
@@ -26,7 +26,7 @@ rules:
verbs: ["get"] verbs: ["get"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["deployments"] resources: ["deployments"]
resourceNames: ["buzz-sheet", "buzz-sheet-worker"] resourceNames: ["buzz-sheet", "buzz-sheet-worker", "buzz-sheet-discord-worker"]
verbs: ["get", "watch", "patch", "update"] verbs: ["get", "watch", "patch", "update"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["replicasets"] resources: ["replicasets"]
+68
View File
@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: buzz-sheet-discord-worker
labels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: discord-worker
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: discord-worker
template:
metadata:
labels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: discord-worker
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
nodeSelector:
kubernetes.io/arch: arm64
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: discord-worker
image: buzz-sheet-app
imagePullPolicy: IfNotPresent
command: ["bun", "worker/discord.js"]
envFrom:
- configMapRef:
name: buzz-sheet-config
- secretRef:
name: buzz-sheet-env
env:
- name: NEXT_DEPLOYMENT_ID
valueFrom:
configMapKeyRef:
name: buzz-sheet-config
key: NEXT_DEPLOYMENT_ID
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: temporary
mountPath: /tmp
volumes:
- name: temporary
emptyDir: {}
+1
View File
@@ -6,6 +6,7 @@ resources:
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- worker-deployment.yaml - worker-deployment.yaml
- discord-worker-deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
- hpa.yaml - hpa.yaml
+57
View File
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
const LOCK_TTL_MS = 120_000;
const RENEW_INTERVAL_MS = 30_000;
const renewScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
end
return 0
`;
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
export class CatalogSyncBusyError extends Error {
constructor() {
super("กำลัง Sync Catalog อยู่");
this.name = "CatalogSyncBusyError";
}
}
export async function withCatalogSyncLock<T>(
task: () => Promise<T>,
): Promise<T> {
const redis = await getRedisClient();
const key = `${redisEventPrefix()}:catalog-sync-lock`;
const token = randomUUID();
const acquired = await redis.set(key, token, "PX", LOCK_TTL_MS, "NX");
if (acquired !== "OK") throw new CatalogSyncBusyError();
let renewing = false;
const timer = setInterval(() => {
if (renewing) return;
renewing = true;
void redis
.eval(renewScript, 1, key, token, String(LOCK_TTL_MS))
.catch(() => undefined)
.finally(() => {
renewing = false;
});
}, RENEW_INTERVAL_MS);
timer.unref();
try {
return await task();
} finally {
clearInterval(timer);
await redis.eval(releaseScript, 1, key, token).catch(() => undefined);
}
}
+37 -3
View File
@@ -21,15 +21,21 @@ import type {
CatalogTalent, CatalogTalent,
} from "@/lib/catalog/lunaris"; } from "@/lib/catalog/lunaris";
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers"; import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
import {
compareLunarisVersions,
lunarisVersionSchema,
} from "@/lib/catalog/version";
import { getMediaStorage } from "@/lib/media/storage"; import { getMediaStorage } from "@/lib/media/storage";
const VERSION_URL = "https://api.lunaris.moe/data/version.json"; const VERSION_URL = "https://api.lunaris.moe/data/version.json";
const API_ROOT = "https://api.lunaris.moe/data"; const API_ROOT = "https://api.lunaris.moe/data";
const versionSchema = z.object({ const versionSchema = z.object({
version: z.string().regex(/^\d+(?:\.\d+)+$/u), version: lunarisVersionSchema,
timestamp: z.number().int().optional(), timestamp: z.number().int().optional(),
}); });
const currentVersionSchema = z.object({ version: lunarisVersionSchema });
const characterSchema = z.record( const characterSchema = z.record(
z.string(), z.string(),
z.object({ z.object({
@@ -179,8 +185,7 @@ async function uploadAssets(
return missing; return missing;
} }
export async function processCatalogSync(): Promise<void> { async function syncCatalogVersion(version: string): Promise<void> {
const version = await getLatestLunarisVersion();
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([ const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`),
@@ -491,3 +496,32 @@ export async function processCatalogSync(): Promise<void> {
acl: "public-read", acl: "public-read",
}); });
} }
export type CatalogSyncResult = "synced" | "unchanged";
export async function getCurrentCatalogVersion(): Promise<string | null> {
const storage = await getMediaStorage();
const current = storage.file("catalog/current.json");
if (!(await current.exists())) return null;
return currentVersionSchema.parse(await current.json()).version;
}
export async function processCatalogSync(
requestedVersion?: string,
): Promise<CatalogSyncResult> {
const version = lunarisVersionSchema.parse(
requestedVersion ?? (await getLatestLunarisVersion()),
);
return withCatalogSyncLock(async () => {
const currentVersion = await getCurrentCatalogVersion();
if (
currentVersion &&
compareLunarisVersions(version, currentVersion) <= 0
) {
return "unchanged";
}
await syncCatalogVersion(version);
return "synced";
});
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
compareLunarisVersions,
parseLunarisVersionChange,
} from "./version";
describe("Lunaris version announcements", () => {
it("extracts a version change from a full announcement", () => {
expect(
parseLunarisVersionChange(`Lunaris has been updated!
Version Change: 7.0.53.2 → 7.0.54
Characters
Odette`),
).toEqual({
previousVersion: "7.0.53.2",
targetVersion: "7.0.54",
});
});
it("ignores ordinary and malformed messages", () => {
expect(parseLunarisVersionChange("hello")).toBeNull();
expect(parseLunarisVersionChange("Version Change: latest → 7.0.54")).toBeNull();
expect(parseLunarisVersionChange("Version Change: 7.0.53 -> 7.0.54")).toBeNull();
});
it("orders numeric dotted versions with different segment counts", () => {
expect(compareLunarisVersions("7.0.54", "7.0.53.2")).toBe(1);
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1);
});
});
+33
View File
@@ -0,0 +1,33 @@
import * as z from "zod";
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
export interface LunarisVersionChange {
previousVersion: string;
targetVersion: string;
}
export function parseLunarisVersionChange(
message: string,
): LunarisVersionChange | null {
const match = message.match(
/^Version Change:\s*(\d+(?:\.\d+)+)\s*→\s*(\d+(?:\.\d+)+)\s*$/imu,
);
if (!match) return null;
return {
previousVersion: lunarisVersionSchema.parse(match[1]),
targetVersion: lunarisVersionSchema.parse(match[2]),
};
}
export function compareLunarisVersions(left: string, right: string): number {
const leftParts = lunarisVersionSchema.parse(left).split(".").map(Number);
const rightParts = lunarisVersionSchema.parse(right).split(".").map(Number);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) return Math.sign(difference);
}
return 0;
}
+3 -1
View File
@@ -11,11 +11,12 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10", "logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,worker,discord-worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "bun scripts/migrate.ts", "db:migrate": "bun scripts/migrate.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-templates.ts", "db:seed": "bun scripts/seed-templates.ts",
"worker:discord": "bun backend/discord.ts",
"worker:outbox": "bun scripts/outbox-worker.ts" "worker:outbox": "bun scripts/outbox-worker.ts"
}, },
"dependencies": { "dependencies": {
@@ -28,6 +29,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"discord.js": "^14.27.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
+21
View File
@@ -62,6 +62,7 @@ describe("production deployment contract", () => {
"k8s/base/configmap.yaml", "k8s/base/configmap.yaml",
"k8s/base/deployment.yaml", "k8s/base/deployment.yaml",
"k8s/base/worker-deployment.yaml", "k8s/base/worker-deployment.yaml",
"k8s/base/discord-worker-deployment.yaml",
"k8s/base/service.yaml", "k8s/base/service.yaml",
"k8s/base/ingress.yaml", "k8s/base/ingress.yaml",
"k8s/base/hpa.yaml", "k8s/base/hpa.yaml",
@@ -88,6 +89,8 @@ describe("production deployment contract", () => {
expect(dockerfile.match(/^USER 1000:1000$/gmu)).toHaveLength(2); expect(dockerfile.match(/^USER 1000:1000$/gmu)).toHaveLength(2);
expect(dockerfile).toContain('ENTRYPOINT ["bun", "scripts/migrate.ts"]'); expect(dockerfile).toContain('ENTRYPOINT ["bun", "scripts/migrate.ts"]');
expect(dockerfile).toContain('CMD ["bun", "server.js"]'); expect(dockerfile).toContain('CMD ["bun", "server.js"]');
expect(dockerfile).toContain("backend/discord.ts");
expect(dockerfile).toContain("./worker/discord.js");
}); });
it("verifies first, publishes immutable images, and migrates before rollout", async () => { it("verifies first, publishes immutable images, and migrates before rollout", async () => {
@@ -106,6 +109,7 @@ describe("production deployment contract", () => {
"registry.neko-piranha.ts.net/astral/buzz-sheet", "registry.neko-piranha.ts.net/astral/buzz-sheet",
); );
expect(workflow).toContain("migrate-${{ gitea.sha }}"); expect(workflow).toContain("migrate-${{ gitea.sha }}");
expect(workflow).toContain("deployment/buzz-sheet-discord-worker");
const deleteMigrationJob = workflow.indexOf( const deleteMigrationJob = workflow.indexOf(
'delete job buzz-sheet-migrate --ignore-not-found', 'delete job buzz-sheet-migrate --ignore-not-found',
); );
@@ -150,6 +154,8 @@ describe("environment template contract", () => {
"S3_SECRET_ACCESS_KEY", "S3_SECRET_ACCESS_KEY",
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY", "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
"NEXT_DEPLOYMENT_ID", "NEXT_DEPLOYMENT_ID",
"DISCORD_BOT_TOKEN",
"DISCORD_CHANNEL_ID",
]; ];
for (const key of requiredKeys) { for (const key of requiredKeys) {
@@ -157,4 +163,19 @@ describe("environment template contract", () => {
} }
expect(environmentExample).toContain("BUZZ_DEMO_MODE=false"); expect(environmentExample).toContain("BUZZ_DEMO_MODE=false");
}); });
it("runs Discord catalog sync as an isolated worker", async () => {
const [deployment, rbac] = await Promise.all([
repositoryFile("k8s/base/discord-worker-deployment.yaml"),
repositoryFile("k8s/base/ci-rbac.yaml"),
]);
expect(deployment).toContain("name: buzz-sheet-discord-worker");
expect(deployment).toContain("type: Recreate");
expect(deployment).toContain('command: ["bun", "worker/discord.js"]');
expect(deployment).toContain("replicas: 1");
expect(deployment).toContain("readOnlyRootFilesystem: true");
expect(deployment).toContain('drop: ["ALL"]');
expect(rbac).toContain("buzz-sheet-discord-worker");
});
}); });