From 76395a0b42849f00d0697c0cb2eeba5eca196d8b Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 3 Sep 2026 17:51:02 +0700 Subject: [PATCH] feat(catalog) : sync Lunaris updates from Discord --- .env.example | 4 + .gitea/workflows/ci.yml | 6 + Dockerfile | 7 +- Plan.md | 14 +- README.md | 16 ++- backend/discord.ts | 176 ++++++++++++++++++++++++ bun.lock | 47 ++++++- k8s.md | 11 +- k8s/base/ci-rbac.yaml | 2 +- k8s/base/discord-worker-deployment.yaml | 68 +++++++++ k8s/base/kustomization.yaml | 1 + lib/catalog/sync-lock.ts | 57 ++++++++ lib/catalog/sync.ts | 40 +++++- lib/catalog/version.test.ts | 32 +++++ lib/catalog/version.ts | 33 +++++ package.json | 4 +- tests/deployment-contract.test.ts | 21 +++ 17 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 backend/discord.ts create mode 100644 k8s/base/discord-worker-deployment.yaml create mode 100644 lib/catalog/sync-lock.ts create mode 100644 lib/catalog/version.test.ts create mode 100644 lib/catalog/version.ts diff --git a/.env.example b/.env.example index 4f07cc7..e4461bd 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ OUTBOX_BATCH_SIZE=20 OUTBOX_POLL_MS=1000 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_ENDPOINT=https://s3.example.internal S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 86d75c8..f8d4f1f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -167,9 +167,15 @@ jobs: kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ deployment/buzz-sheet-worker \ 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 \ deployment/buzz-sheet \ --timeout=10m kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ deployment/buzz-sheet-worker \ --timeout=10m + kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ + deployment/buzz-sheet-discord-worker \ + --timeout=10m diff --git a/Dockerfile b/Dockerfile index ab01513..7d060d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,11 @@ COPY . . RUN bun build scripts/outbox-worker.ts \ --target=bun \ --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 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/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/discord.js ./worker/discord.js COPY --from=worker-builder --chown=bun:bun /app/lib/catalog/assets ./lib/catalog/assets USER 1000:1000 EXPOSE 3000 diff --git a/Plan.md b/Plan.md index 7a5b6a1..567a373 100644 --- a/Plan.md +++ b/Plan.md @@ -9,7 +9,7 @@ Extra sections. Use the English terms “Build” and “Team” throughout the 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`. 3. Upload a required Guide Image and write the Overview. 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 -The Sync button enqueues a worker job. It fetches Lunaris `version.json` and -the versioned character, weapon, and artifact lists, validates them, filters -placeholder or missing-image records, mirrors unique WebP assets and normalized -JSON to Bun S3, then atomically activates the completed version. Failed jobs do -not replace the active catalog. There is no automatic sync. +The manual Sync button fetches Lunaris `version.json`. A dedicated Discord +worker instead extracts the exact version from followed announcements and +recovers missed announcements from recent channel history. Both paths share a +Redis lock, validate the versioned character, weapon, and artifact lists, filter +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_PUBLIC_URL=https://buzz-cdn.astrxl.dev` is the public read base. Browser diff --git a/README.md b/README.md index b0b6226..1909f4b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Production target: `https://guide.sudloh.com` retryable outbox. - Shared Redis cache invalidation and privacy-safe Server-Sent Events across replicas. +- Discord-triggered Lunaris catalog updates with exact-version synchronization. The five reference workbooks are represented only by sanitized structural and 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:migrate` | Apply committed Drizzle migrations | | `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 | ## 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 before publishing an opaque `{ type, id, version }` event. Connected clients 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 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 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 with the same identity. @@ -186,7 +194,7 @@ The runtime images do not contain local `.env` files. Kustomize resources live in `k8s/` and define: - 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`. - Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route. - 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, 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 during a rolling update. diff --git a/backend/discord.ts b/backend/discord.ts new file mode 100644 index 0000000..02c97d1 --- /dev/null +++ b/backend/discord.ts @@ -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 = {}): 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 { + 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 | null = null; + +async function syncWithRetry(version: string): Promise { + 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 { + 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((resolve) => { + stopWorker = resolve; +}); +const stop = () => { + shutdown.abort(); + stopWorker?.(); +}; +process.once("SIGINT", stop); +process.once("SIGTERM", stop); + +try { + const ready = new Promise((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"); +} diff --git a/bun.lock b/bun.lock index 740f851..83030f9 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "decimal.js": "^10.6.0", + "discord.js": "^14.27.0", "drizzle-orm": "^0.45.2", "embla-carousel-react": "^8.6.0", "ioredis": "^6.0.0", @@ -138,6 +139,18 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "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/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], + + "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="], + + "@discordjs/rest": ["@discordjs/rest@2.6.3", "", { "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/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="], + + "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "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/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "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/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], + + "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], + + "@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], @@ -510,6 +529,8 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "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/parser@8.68.0", "", { "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/utils@4.1.11", "", { "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/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -796,6 +819,10 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "discord-api-types": ["discord-api-types@0.38.55", "", {}, "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA=="], + + "discord.js": ["discord.js@14.27.0", "", { "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": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], @@ -1192,8 +1219,12 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "loose-envify": ["loose-envify@1.4.0", "", { "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": ["lucide-react@1.35.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg=="], + "magic-bytes.js": ["magic-bytes.js@1.13.1", "", {}, "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.4", "", { "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": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "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": ["unbox-primitive@1.1.0", "", { "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": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1614,6 +1649,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -1640,12 +1677,18 @@ "@base-ui/utils/reselect": ["reselect@5.3.0", "", {}, "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg=="], + "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + + "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "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": ["open@8.4.2", "", { "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": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "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": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "shadcn/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], diff --git a/k8s.md b/k8s.md index 6564d07..948ca97 100644 --- a/k8s.md +++ b/k8s.md @@ -27,8 +27,8 @@ kubectl create secret generic buzz-sheet-env \ --output=yaml | kubectl apply --filename=- ``` -Running pods do not reload Secret values automatically. Restart both the web -application and the outbox worker, then wait for each rollout: +Running pods do not reload Secret values automatically. Restart the web +application and both workers, then wait for each rollout: ```bash kubectl rollout restart deployment/buzz-sheet \ @@ -37,6 +37,9 @@ kubectl rollout restart deployment/buzz-sheet \ kubectl rollout restart deployment/buzz-sheet-worker \ --namespace buzz-sheet +kubectl rollout restart deployment/buzz-sheet-discord-worker \ + --namespace buzz-sheet + kubectl rollout status deployment/buzz-sheet \ --namespace buzz-sheet \ --timeout=10m @@ -44,6 +47,10 @@ kubectl rollout status deployment/buzz-sheet \ kubectl rollout status deployment/buzz-sheet-worker \ --namespace buzz-sheet \ --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: diff --git a/k8s/base/ci-rbac.yaml b/k8s/base/ci-rbac.yaml index d5439df..d1d26e0 100644 --- a/k8s/base/ci-rbac.yaml +++ b/k8s/base/ci-rbac.yaml @@ -26,7 +26,7 @@ rules: verbs: ["get"] - apiGroups: ["apps"] resources: ["deployments"] - resourceNames: ["buzz-sheet", "buzz-sheet-worker"] + resourceNames: ["buzz-sheet", "buzz-sheet-worker", "buzz-sheet-discord-worker"] verbs: ["get", "watch", "patch", "update"] - apiGroups: ["apps"] resources: ["replicasets"] diff --git a/k8s/base/discord-worker-deployment.yaml b/k8s/base/discord-worker-deployment.yaml new file mode 100644 index 0000000..a570670 --- /dev/null +++ b/k8s/base/discord-worker-deployment.yaml @@ -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: {} diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml index 03bfc99..b0d11b9 100644 --- a/k8s/base/kustomization.yaml +++ b/k8s/base/kustomization.yaml @@ -6,6 +6,7 @@ resources: - configmap.yaml - deployment.yaml - worker-deployment.yaml + - discord-worker-deployment.yaml - service.yaml - ingress.yaml - hpa.yaml diff --git a/lib/catalog/sync-lock.ts b/lib/catalog/sync-lock.ts new file mode 100644 index 0000000..2f96495 --- /dev/null +++ b/lib/catalog/sync-lock.ts @@ -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( + task: () => Promise, +): Promise { + 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); + } +} diff --git a/lib/catalog/sync.ts b/lib/catalog/sync.ts index fc11af3..0982a6d 100644 --- a/lib/catalog/sync.ts +++ b/lib/catalog/sync.ts @@ -21,15 +21,21 @@ import type { CatalogTalent, } from "@/lib/catalog/lunaris"; 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"; const VERSION_URL = "https://api.lunaris.moe/data/version.json"; const API_ROOT = "https://api.lunaris.moe/data"; const versionSchema = z.object({ - version: z.string().regex(/^\d+(?:\.\d+)+$/u), + version: lunarisVersionSchema, timestamp: z.number().int().optional(), }); +const currentVersionSchema = z.object({ version: lunarisVersionSchema }); const characterSchema = z.record( z.string(), z.object({ @@ -179,8 +185,7 @@ async function uploadAssets( return missing; } -export async function processCatalogSync(): Promise { - const version = await getLatestLunarisVersion(); +async function syncCatalogVersion(version: string): Promise { const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([ getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`), @@ -491,3 +496,32 @@ export async function processCatalogSync(): Promise { acl: "public-read", }); } + +export type CatalogSyncResult = "synced" | "unchanged"; + +export async function getCurrentCatalogVersion(): Promise { + 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 { + 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"; + }); +} diff --git a/lib/catalog/version.test.ts b/lib/catalog/version.test.ts new file mode 100644 index 0000000..cceee25 --- /dev/null +++ b/lib/catalog/version.test.ts @@ -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); + }); +}); diff --git a/lib/catalog/version.ts b/lib/catalog/version.ts new file mode 100644 index 0000000..2cfb375 --- /dev/null +++ b/lib/catalog/version.ts @@ -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; +} diff --git a/package.json b/package.json index df49266..9f16bed 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "test": "vitest run", "test:watch": "vitest", "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:migrate": "bun scripts/migrate.ts", "db:studio": "drizzle-kit studio", "db:seed": "bun scripts/seed-templates.ts", + "worker:discord": "bun backend/discord.ts", "worker:outbox": "bun scripts/outbox-worker.ts" }, "dependencies": { @@ -28,6 +29,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "decimal.js": "^10.6.0", + "discord.js": "^14.27.0", "drizzle-orm": "^0.45.2", "embla-carousel-react": "^8.6.0", "ioredis": "^6.0.0", diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts index ab83507..f5f2053 100644 --- a/tests/deployment-contract.test.ts +++ b/tests/deployment-contract.test.ts @@ -62,6 +62,7 @@ describe("production deployment contract", () => { "k8s/base/configmap.yaml", "k8s/base/deployment.yaml", "k8s/base/worker-deployment.yaml", + "k8s/base/discord-worker-deployment.yaml", "k8s/base/service.yaml", "k8s/base/ingress.yaml", "k8s/base/hpa.yaml", @@ -88,6 +89,8 @@ describe("production deployment contract", () => { expect(dockerfile.match(/^USER 1000:1000$/gmu)).toHaveLength(2); expect(dockerfile).toContain('ENTRYPOINT ["bun", "scripts/migrate.ts"]'); 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 () => { @@ -106,6 +109,7 @@ describe("production deployment contract", () => { "registry.neko-piranha.ts.net/astral/buzz-sheet", ); expect(workflow).toContain("migrate-${{ gitea.sha }}"); + expect(workflow).toContain("deployment/buzz-sheet-discord-worker"); const deleteMigrationJob = workflow.indexOf( 'delete job buzz-sheet-migrate --ignore-not-found', ); @@ -150,6 +154,8 @@ describe("environment template contract", () => { "S3_SECRET_ACCESS_KEY", "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY", "NEXT_DEPLOYMENT_ID", + "DISCORD_BOT_TOKEN", + "DISCORD_CHANNEL_ID", ]; for (const key of requiredKeys) { @@ -157,4 +163,19 @@ describe("environment template contract", () => { } 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"); + }); });