sourcebot/packages/backend/src/utils.ts
Brendan Kellam 22d548e171
fix(search-contexts): Fix issue where a repository would not appear in a search context if it was created after the search context was created (#354)
## Problem

If a repository is added **after** a search context (e.g., a new repository is synced from the code host), then it will never be added to the context even if it should be included. The workaround is to restart the instance.

## Solution

This PR adds a call to re-sync all search contexts whenever a connection is successfully synced. This PR adds the `@sourcebot/shared` package that contains `syncSearchContexts.ts` (previously in web) and it's dependencies (namely the entitlements system).

## Why another package?

Because the `syncSearchContexts` call is now called from:
1. `initialize.ts` in **web** - handles syncing search contexts on startup and whenever the config is modified in watch mode. This is the same as before.
2. `connectionManager.ts` in **backend** - syncs the search contexts whenever a connection is successfully synced.

## Follow-up devex work
Two things:
1. We have several very thin shared packages (i.e., `crypto`, `error`, and `logger`) that we can probably fold into this "general" shared package. `schemas` and `db` _feels_ like they should remain separate (mostly because they are "code-gen" packages).
2. When running `yarn dev`, any changes made to the shared package will only get picked if you `ctrl+c` and restart the instance. Would be nice if we have watch mode work across package dependencies in the monorepo.
2025-06-17 14:04:25 -07:00

119 lines
No EOL
3.8 KiB
TypeScript

import { Logger } from "winston";
import { AppContext } from "./types.js";
import path from 'path';
import { PrismaClient, Repo } from "@sourcebot/db";
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node";
export const measure = async <T>(cb: () => Promise<T>) => {
const start = Date.now();
const data = await cb();
const durationMs = Date.now() - start;
return {
data,
durationMs
}
}
export const marshalBool = (value?: boolean) => {
return !!value ? '1' : '0';
}
export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => {
try {
return await getTokenFromConfigBase(token, orgId, db);
} catch (error: unknown) {
if (error instanceof Error) {
const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
message: error.message,
});
Sentry.captureException(e);
logger?.error(error.message);
throw e;
}
throw error;
}
};
export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => {
let absolutePath = localPath;
if (!path.isAbsolute(absolutePath)) {
if (absolutePath.startsWith('~')) {
absolutePath = path.join(process.env.HOME ?? '', absolutePath.slice(1));
}
absolutePath = path.resolve(path.dirname(configPath), absolutePath);
}
return absolutePath;
}
export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
if (a === b) return true;
if (a === undefined || b === undefined) return false;
if (a.length !== b.length) return false;
const aSorted = a.toSorted();
const bSorted = b.toSorted();
for (let i = 0; i < aSorted.length; i++) {
if (aSorted[i] !== bSorted[i]) {
return false;
}
}
return true;
}
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
// @todo: we should move this to a shared package.
export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => {
// If we are dealing with a local repository, then use that as the path.
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
const cloneUrl = new URL(repo.cloneUrl);
if (repo.external_codeHostType === 'generic-git-host' && cloneUrl.protocol === 'file:') {
return {
path: cloneUrl.pathname,
isReadOnly: true,
}
}
return {
path: path.join(ctx.reposPath, repo.id.toString()),
isReadOnly: false,
}
}
export const getShardPrefix = (orgId: number, repoId: number) => {
return `${orgId}_${repoId}`;
}
export const fetchWithRetry = async <T>(
fetchFn: () => Promise<T>,
identifier: string,
logger: Logger,
maxAttempts: number = 3
): Promise<T> => {
let attempts = 0;
while (true) {
try {
return await fetchFn();
} catch (e: any) {
Sentry.captureException(e);
attempts++;
if ((e.status === 403 || e.status === 429 || e.status === 443) && attempts < maxAttempts) {
const computedWaitTime = 3000 * Math.pow(2, attempts - 1);
const resetTime = e.response?.headers?.['x-ratelimit-reset'] ? parseInt(e.response.headers['x-ratelimit-reset']) * 1000 : Date.now() + computedWaitTime;
const waitTime = resetTime - Date.now();
logger.warn(`Rate limit exceeded for ${identifier}. Waiting ${waitTime}ms before retry ${attempts}/${maxAttempts}...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
continue;
}
throw e;
}
}
}