actualbudget/actual · warning

Access already exists

Error message

Access already exists

What it means

addUserAccess inserts into user_access, which has a UNIQUE constraint on (user_id, file_id). When the INSERT violates it, SQLite raises a UNIQUE constraint error which this function detects and rethrows as 'Access already exists' — meaning the user already has access to the file. It is an idempotency signal, not corruption.

Source

Thrown at packages/sync-server/src/services/user-service.ts:199

}

export function addUserAccess(userId, fileId) {
  if (!userId || !fileId) {
    throw new Error('Invalid parameters');
  }
  try {
    const userExists = getUserById(userId);
    const fileExists = getFileById(fileId);
    if (!userExists || !fileExists) {
      throw new Error('User or file not found');
    }
    getAccountDb().mutate(
      'INSERT INTO user_access (user_id, file_id) VALUES (?, ?)',
      [userId, fileId],
    );
  } catch (error) {
    if (error.message.includes('UNIQUE constraint')) {
      throw new Error('Access already exists');
    }
    throw new Error(`Failed to add user access: ${error.message}`);
  }
}

export function deleteUserAccessByFileId(userIds, fileId) {
  if (!Array.isArray(userIds) || userIds.length === 0) {
    throw new Error('The provided userIds must be a non-empty array.');
  }

  const CHUNK_SIZE = 999;
  let totalChanges = 0;

  try {
    getAccountDb().transaction(() => {
      for (let i = 0; i < userIds.length; i += CHUNK_SIZE) {
        const chunk = userIds.slice(i, i + CHUNK_SIZE);
        const placeholders = chunk.map(() => '?').join(',');

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Check existing access first (getUserAccess or countUserAccess) and skip the insert if it already returns a row
  2. Treat this error as success/no-op in callers that only need the access to exist (idempotent handling)
  3. Debounce/disable the share action in the UI after the first submission
  4. Use INSERT OR IGNORE semantics at the call site pattern: check-then-insert or catch-and-continue

Example fix

// before
await addUserAccess(userId, fileId); // throws on retry
// after
const existing = countUserAccess(fileId, userId);
if (!existing) {
  await addUserAccess(userId, fileId);
}
Defensive patterns

Strategy: validation

Validate before calling

import { countUserAccess } from './services/user-service';
async function grantAccessOnce(userId, fileId) {
  if (countUserAccess(fileId, userId) > 0) return; // already shared
  await addUserAccess(userId, fileId);
}

Type guard

function hasAccess(userId, fileId) {
  return countUserAccess(fileId, userId) > 0;
}

Try / catch

try {
  addUserAccess(userId, fileId);
} catch (e) {
  if (e.message.includes('Access already exists')) {
    return; // idempotent success
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling addUserAccess twice with the same (userId, fileId) pair — double-clicking a share button, retrying a request that actually succeeded, replaying a share operation, or concurrent requests sharing the same file with the same user simultaneously.

Common situations: Front-end share forms submitting twice without disabling the button; automation scripts re-running without checking current access; race conditions between two admins sharing the same file at once; re-running a migration/import script.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/4f9ac9026fb3a50a. Report an issue: GitHub.