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_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
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
"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/[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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"lodash": ["[email protected]", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1614,6 +1649,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -1640,12 +1677,18 @@
|
||||
|
||||
"@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/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/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=="],
|
||||
|
||||
"@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/undici": ["[email protected]", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"shadcn/zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"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=-
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
- deployment.yaml
|
||||
- worker-deployment.yaml
|
||||
- discord-worker-deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.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,
|
||||
} 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<void> {
|
||||
const version = await getLatestLunarisVersion();
|
||||
async function syncCatalogVersion(version: string): Promise<void> {
|
||||
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<void> {
|
||||
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: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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user