feat(catalog) : sync Lunaris updates from Discord
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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: {}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user