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
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:
@@ -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}))`;
|
||||
}
|
||||
Reference in New Issue
Block a user