refactor: break monorepo into 3 standalone services (gateway, backend, frontend)
Build & Deploy / build-and-push (backend) (push) Failing after 35s
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 25s
Build & Deploy / build-and-push (proxy) (push) Failing after 25s

- Remove pnpm workspace, moon repo, and all monorepo tooling
- Delete packages/shared/, embed shared code directly into each service
- Copy packages/shared/src/* -> services/backend/src/shared/ and services/discord-gateway/src/shared/
- Replace all @bete/shared imports with @/shared/ path alias
- Remove @bete/shared workspace dependency from both services
- Update root package.json scripts from --filter to --prefix
- Rewrite Dockerfiles to build each service standalone
- Clean up biome.json, .gitignore, remove root drizzle.config.ts
This commit is contained in:
Developer
2026-07-30 11:50:48 +07:00
parent 8eb7fa49e4
commit dcd13482c2
167 changed files with 1886 additions and 533 deletions
@@ -0,0 +1,112 @@
// Utility functions shared across services
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export * from "./pagination.js";
// ---------------------------------------------------------------------------
// Centralized AbortController with guaranteed cleanup
// ---------------------------------------------------------------------------
/**
* Creates an AbortController with a timeout that is ALWAYS cleaned up,
* even if the caller throws or returns early without calling clear().
*
* Returns both the controller and a cleanup handle.
*
* Usage:
* const { controller, clear } = createAbortControllerWithTimeout(8000);
* try {
* const res = await fetch(url, { signal: controller.signal });
* // ... work ...
* } finally {
* clear(); // guaranteed to clear the timeout
* }
*/
export function createAbortControllerWithTimeout(timeoutMs: number): {
controller: AbortController;
clear: () => void;
} {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// Unref so the timeout doesn't keep the process alive
timeoutId?.unref?.();
return {
controller,
clear: () => {
clearTimeout(timeoutId);
},
};
}
// ---------------------------------------------------------------------------
// Retry with exponential backoff
// ---------------------------------------------------------------------------
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: {
/** Number of retry attempts (default: 3) */
retries?: number;
/** Initial delay in ms (default: 1000) */
minTimeout?: number;
/** Maximum delay in ms (default: 30000) */
maxTimeout?: number;
/** Multiplication factor for each retry (default: 2) */
factor?: number;
/** Optional AbortSignal to cancel retries */
signal?: AbortSignal;
} = {},
): Promise<T> {
const {
retries = 3,
minTimeout = 1_000,
maxTimeout = 30_000,
factor = 2,
signal,
} = options;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= retries; attempt++) {
if (signal?.aborted) {
const err = new Error("Aborted");
err.name = "AbortError";
throw err;
}
try {
return await fn();
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (lastError.name === "AbortError") {
throw lastError;
}
if (attempt === retries) break;
const backoff = Math.min(
minTimeout * factor ** attempt + Math.random() * 100,
maxTimeout,
);
await new Promise<void>((resolve, reject) => {
let timeoutId: NodeJS.Timeout;
const onAbort = () => {
clearTimeout(timeoutId);
const abortErr = new Error("Aborted");
abortErr.name = "AbortError";
reject(abortErr);
};
if (signal?.aborted) return onAbort();
timeoutId = setTimeout(() => {
if (signal) signal.removeEventListener("abort", onAbort);
resolve();
}, backoff);
if (signal) signal.addEventListener("abort", onAbort, { once: true });
});
}
}
throw lastError!;
}
@@ -0,0 +1,65 @@
// Shared cursor-based pagination utilities
export interface CursorData {
created_at: number;
id: string;
}
/**
* Encode a cursor to a base64 string.
*/
export function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString("base64");
}
/**
* Decode a cursor from a base64 string. Returns null on invalid input.
*/
export function decodeCursor(cursor?: string): CursorData | null {
if (!cursor) return null;
try {
const data = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
if (typeof data.created_at === "number" && typeof data.id === "string") {
return data;
}
return null;
} catch {
return null;
}
}
/**
* Build a `PageResult` from a slice of rows (limit + 1) using cursor-based pagination.
*/
export function pageResult<T extends { created_at: number; id: string }>(
rows: unknown[],
limit: number,
): { data: T[]; nextCursor: string | null } {
const hasMore = rows.length > limit;
const data = rows.slice(0, limit) as T[];
const lastItem = data[data.length - 1];
const nextCursor =
hasMore && lastItem
? encodeCursor({ created_at: lastItem.created_at, id: lastItem.id })
: null;
return { data, nextCursor };
}
/**
* Build a Drizzle cursor condition expression.
* Used in WHERE clauses: `(created_at < cursor.created_at OR (created_at = cursor.created_at AND id < cursor.id))`
*
* Returns the SQL expression or undefined when cursor is absent.
*/
import { type SQL, sql } from "drizzle-orm";
export function buildCursorCondition(
created_at_col: SQL | unknown,
id_col: SQL | unknown,
cursor?: string,
): SQL | undefined {
const data = decodeCursor(cursor);
if (!data) return undefined;
return sql`(${created_at_col} < ${data.created_at} or (${created_at_col} = ${data.created_at} and ${id_col} < ${data.id}))`;
}