fix: prefer active config when reading user settings (fixes #271)

Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.

In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.

Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.

Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.

Updates the mirror-repo test mock to match the new orderBy().limit()
chain.

Closes #271
This commit is contained in:
Arunavo Ray
2026-04-22 08:01:22 +05:30
parent c1712bc670
commit 2ea250f081
18 changed files with 126 additions and 70 deletions
+8 -3
View File
@@ -4,7 +4,7 @@
*/
import { db, configs, users } from '@/lib/db';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { encrypt } from '@/lib/utils/encryption';
@@ -224,10 +224,12 @@ export async function initializeConfigFromEnv(): Promise<void> {
console.log('[ENV Config Loader] Found environment configuration, initializing...');
// Get the first user (admin user)
// Get the first user (admin user) — deterministic order so we always pick the
// same row across restarts even if multiple users exist.
const firstUser = await db
.select()
.from(users)
.orderBy(sql`${users.createdAt} ASC`)
.limit(1);
if (firstUser.length === 0) {
@@ -237,11 +239,14 @@ export async function initializeConfigFromEnv(): Promise<void> {
const userId = firstUser[0].id;
// Check if config already exists for this user
// Check if config already exists for this user — prefer the active config and
// fall back to most-recently-updated so we never write env values into a stale
// inactive stub while the populated active row sits untouched (see issue #271).
const existingConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
// Determine mirror strategy based on environment variables or use explicit value
+4 -2
View File
@@ -3,7 +3,7 @@ import type { NotificationEvent } from "./providers/ntfy";
import { sendNtfyNotification } from "./providers/ntfy";
import { sendAppriseNotification } from "./providers/apprise";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { decrypt } from "@/lib/utils/encryption";
function sanitizeTestNotificationError(error: unknown): string {
@@ -120,11 +120,13 @@ export async function triggerJobNotification({
return;
}
// Fetch user's config from database
// Fetch user's config from database — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const configResults = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (configResults.length === 0) {
+7 -3
View File
@@ -5,7 +5,7 @@
import { findInterruptedJobs, resumeInterruptedJob } from './helpers';
import { db, repositories, organizations, mirrorJobs, configs } from './db';
import { eq, and, lt, inArray } from 'drizzle-orm';
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
import { mirrorGithubRepoToGitea, mirrorGitHubOrgRepoToGiteaOrg, syncGiteaRepo } from './gitea';
import { createGitHubClient } from './github';
import { processWithResilience } from './utils/concurrency';
@@ -216,11 +216,13 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
console.log(`Recovering mirror job ${job.id} with ${remainingItemIds.length} remaining items`);
try {
// Get the config for this user with better error handling
// Get the config for this user — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const userConfigs = await db
.select()
.from(configs)
.where(eq(configs.userId, job.userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (userConfigs.length === 0) {
@@ -347,11 +349,13 @@ async function recoverSyncJob(job: any, remainingItemIds: string[]) {
console.log(`Recovering sync job ${job.id} with ${remainingItemIds.length} remaining items`);
try {
// Get the config for this user with better error handling
// Get the config for this user — prefer active and most-recently-updated
// to avoid picking a stale inactive stub when multiple rows exist (see issue #271).
const userConfigs = await db
.select()
.from(configs)
.where(eq(configs.userId, job.userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (userConfigs.length === 0) {
+4 -2
View File
@@ -1,5 +1,5 @@
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { encrypt } from "@/lib/utils/encryption";
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
@@ -25,11 +25,13 @@ export interface DefaultConfigOptions {
* Environment variables can override these defaults
*/
export async function createDefaultConfig({ userId, envOverrides = {} }: DefaultConfigOptions) {
// Check if config already exists
// Check if config already exists — prefer active and most-recently-updated
// to avoid returning a stale inactive stub when multiple rows exist (see issue #271).
const existingConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (existingConfig.length > 0) {
+8 -3
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import {
mapUiToDbConfig,
@@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
}
// Fetch existing config
// Fetch existing config — prefer the active config; fall back to most-recently-updated
// so a stale inactive stub never wins over a populated active row (see issue #271).
const existingConfigResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const existingConfig = existingConfigResult[0];
@@ -255,11 +257,14 @@ export const GET: APIRoute = async ({ request, locals }) => {
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Fetch the configuration for the user
// Fetch the configuration for the user — prefer the active config; fall back to
// most-recently-updated so a stale inactive stub never wins over a populated
// active row (see issue #271).
const config = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (config.length === 0) {
+6 -1
View File
@@ -38,7 +38,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
.limit(10),
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1),
db
.select({ value: count() })
.from(repositories)
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import {
createGitHubClient,
getGithubStarredListNames,
@@ -15,10 +15,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
if ("response" in authResult) return authResult.response;
const userId = authResult.userId;
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepoEnhanced } from "@/lib/gitea-enhanced";
import { createSecureErrorResponse } from "@/lib/utils";
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
import { db, configs, organizations } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { createGitHubClient } from "@/lib/github";
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
import { repoStatusEnum } from "@/types/Repository";
@@ -41,11 +41,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+45 -38
View File
@@ -3,6 +3,46 @@ import type { MirrorRepoRequest } from "@/types/mirror";
import { POST } from "./mirror-repo";
// Mock the database module
const mockConfigRow = [{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}];
const mockRepoRows = [
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
];
const mockDb = {
select: mock(() => ({
from: mock((table: any) => ({
@@ -10,47 +50,14 @@ const mockDb = {
// Return config for configs table
if (table === mockConfigs) {
return {
limit: mock(() => Promise.resolve([{
id: "config-id",
userId: "user-id",
githubConfig: {
token: "github-token",
preserveOrgStructure: false,
mirrorIssues: false
},
giteaConfig: {
url: "https://gitea.example.com",
token: "gitea-token",
username: "giteauser"
}
}]))
orderBy: mock(() => ({
limit: mock(() => Promise.resolve(mockConfigRow))
})),
limit: mock(() => Promise.resolve(mockConfigRow))
};
}
// Return repositories for repositories table
return Promise.resolve([
{
id: "repo-id-1",
name: "test-repo-1",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
},
{
id: "repo-id-2",
name: "test-repo-2",
visibility: "public",
status: "pending",
organization: null,
lastMirrored: null,
errorMessage: null,
forkedFrom: null,
mirroredLocation: ""
}
]);
return Promise.resolve(mockRepoRows);
})
}))
}))
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import {
mirrorGithubRepoToGitea,
@@ -43,11 +43,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -1
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db, configs, repositories } from "@/lib/db";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata";
@@ -35,10 +35,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
import {
mirrorGithubRepoToGitea,
@@ -45,11 +45,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch user config
// Fetch user config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, or } from "drizzle-orm";
import { and, eq, or, sql } from "drizzle-orm";
import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
import { isRepoPresentInGitea, syncGiteaRepo } from "@/lib/gitea";
import type {
@@ -19,11 +19,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
await request.json().catch(() => ({} as ScheduleSyncRepoRequest));
// Fetch config for the user
// Fetch config for the user — prefer active and most-recently-updated to avoid
// picking a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -2
View File
@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepo } from "@/lib/gitea";
import type { SyncRepoResponse } from "@/types/sync";
@@ -38,11 +38,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
);
}
// Fetch config
// Fetch config — prefer active and most-recently-updated to avoid picking
// a stale inactive stub when multiple rows exist (see issue #271).
const configResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
const config = configResult[0];
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, rateLimits } from "@/lib/db";
import { eq, and, desc } from "drizzle-orm";
import { eq, and, desc, sql } from "drizzle-orm";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import { RateLimitManager } from "@/lib/rate-limit-manager";
import { createGitHubClient } from "@/lib/github";
@@ -19,10 +19,13 @@ export const GET: APIRoute = async ({ request, locals }) => {
try {
// If refresh is requested, fetch current rate limit from GitHub
if (refresh) {
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (config && config.githubConfig?.token) {
+4 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
import { db, organizations, repositories, configs } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { createMirrorJob } from "@/lib/helpers";
import {
@@ -21,10 +21,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
const userId = authResult.userId;
try {
// Prefer active and most-recently-updated config to avoid picking a stale
// inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {
+4 -2
View File
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { configs, db, repositories } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { type Repository } from "@/lib/db/schema";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
import type {
@@ -72,11 +72,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
// Get user's active config
// Get user's active config — prefer active and most-recently-updated to avoid
// picking a stale inactive stub when multiple rows exist (see issue #271).
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.orderBy(sql`${configs.isActive} DESC`, sql`${configs.updatedAt} DESC`)
.limit(1);
if (!config) {