Skip to content

Conversation

@jasonpetro
Copy link
Collaborator

@jasonpetro jasonpetro commented Dec 6, 2025

Overview

This PR introduces an optimistic concurrency control mechanism to project updates, helping to prevent accidental overwrites when multiple users might be editing simultaneously.

Changes

  • Added an optional lastUpdatedAt timestamp to the updateProject action in src/actions/projects/updateProject/action.ts.
  • If lastUpdatedAt is provided, the update will only succeed if the project's current updatedAt field matches the provided timestamp.
  • Ensured backwards compatibility: if lastUpdatedAt is not provided, the action defaults to the existing update behavior without the optimistic check.

Summary by CodeRabbit

  • New Features

    • Project updates now support optimistic locking (prevents accidental overwrites when edits collide).
  • Bug Fixes

    • More precise conflict reporting on concurrent update attempts.
    • Streamlined user notifications—reduces redundant error toasts; shows clearer conflict/not-found responses.
  • Tests

    • Added tests covering optimistic locking, conflict cases, and error propagation.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 6, 2025

Walkthrough

Adds optimistic locking to project updates via an optional lastUpdatedAt input, changes logic to use conditional updateMany + findUnique, maps Prisma P2025 to CONFLICT, and removes user-facing toast notifications in the action layer. (No exported function signatures changed.)

Changes

Cohort / File(s) Summary
Schema
src/actions/projects/updateProject/schema.ts
Added optional lastUpdatedAt (string, datetime-validated) to the update schema, extending UpdateProjectInput.
Core Update Logic
src/actions/projects/updateProject/logic.ts
Implemented optimistic locking: when lastUpdatedAt is present use updateMany with id and updatedAt; if zero rows affected return CONFLICT; follow up with findUnique to retrieve updated record. Catch Prisma P2025 and map to CONFLICT; rethrow unexpected errors.
Action / Error Surface
src/actions/projects/updateProject/action.ts
Removed user-facing toast notifications for not-found and update failures; return NOT_FOUND when project missing; propagate error codes from update results; log and map exceptions without global toasts.
Tests
src/actions/projects/updateProject/logic.test.ts
Added tests covering success paths (with/without lastUpdatedAt), optimistic-locking conflict (zero affected rows), Prisma P2025 mapping, and unexpected error propagation; mocks for prisma.project.update, updateMany, and findUnique.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Action
  participant Logic
  participant PrismaDB

  Client->>Action: updateProject(input {id, changes, lastUpdatedAt?})
  Action->>Logic: call updateProject(input, userId)
  alt lastUpdatedAt provided
    Logic->>PrismaDB: updateMany(where: {id, updatedAt: lastUpdatedAt}, data: changes)
    PrismaDB-->>Logic: {count: n}
    alt n == 0
      Logic-->>Action: return CONFLICT (stale/modified)
    else n > 0
      Logic->>PrismaDB: findUnique(where: {id})
      PrismaDB-->>Logic: updated project record
      Logic-->>Action: return Success(project)
    end
  else no lastUpdatedAt
    Logic->>PrismaDB: update(where: {id}, data: changes)
    PrismaDB-->>Logic: updated project record or throw P2025
    alt throws P2025
      Logic-->>Action: return CONFLICT (mapped)
    else
      Logic-->>Action: return Success(project)
    end
  end
  Action-->>Client: result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Areas to focus:
    • updateMany where-clause correctness (uses updatedAt vs lastUpdatedAt mapping and time parsing).
    • Correct detection of zero affected rows → CONFLICT behavior.
    • Proper mapping of Prisma P2025 to CONFLICT and that other errors are rethrown/logged appropriately.
    • Action layer: verify removed toasts don't suppress necessary error context and that propagated error codes surface correctly.

Possibly related PRs

Suggested reviewers

  • fehranbit

Poem

🐰
I hop through timestamps, soft and spry,
Locking updates as conflicts fly.
No noisy toast; just quiet guard—
Timestamps whisper, systems stay hard. ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature being added: optimistic concurrency control for project updates, which aligns with the core changes across all modified files.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/optimistic-project-update

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/actions/projects/updateProject/action.ts (1)

31-36: Propagate errorCode instead of non-existent code from Result

updateProjectResult is a Result<Project>, whose error variant exposes error and errorCode. Using updateProjectResult.code will always be undefined, so you lose the specific error code from updateProject (e.g., CONFLICT) and fall back to the default in error(...).

You likely want:

-      return error(updateProjectResult.error, updateProjectResult.code);
+      return error(updateProjectResult.error, updateProjectResult.errorCode);

so optimistic-lock conflicts and other specific error codes are correctly propagated.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b88ddd1 and d5d2886.

📒 Files selected for processing (3)
  • src/actions/projects/updateProject/action.ts (1 hunks)
  • src/actions/projects/updateProject/logic.ts (1 hunks)
  • src/actions/projects/updateProject/schema.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/actions/projects/updateProject/action.ts (1)
src/lib/result.ts (1)
  • error (38-44)
src/actions/projects/updateProject/logic.ts (3)
src/actions/projects/updateProject/schema.ts (1)
  • UpdateProjectInput (9-9)
src/lib/result.ts (3)
  • Result (18-18)
  • success (25-30)
  • error (38-44)
src/lib/prisma.ts (1)
  • prisma (14-18)
🔇 Additional comments (1)
src/actions/projects/updateProject/schema.ts (1)

5-6: Schema extension for optimistic locking looks good

lastUpdatedAt as an optional datetime string aligns with the optimistic-locking logic, and the title normalization is reasonable and backwards compatible. No issues from my side.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d5d2886 and 86e511a.

📒 Files selected for processing (2)
  • src/actions/projects/updateProject/logic.test.ts (1 hunks)
  • src/actions/projects/updateProject/logic.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/actions/projects/updateProject/logic.ts (3)
src/actions/projects/updateProject/schema.ts (1)
  • UpdateProjectInput (9-9)
src/lib/result.ts (3)
  • Result (18-18)
  • error (38-44)
  • success (25-30)
src/lib/prisma.ts (1)
  • prisma (14-18)
src/actions/projects/updateProject/logic.test.ts (2)
src/lib/prisma.ts (1)
  • prisma (14-18)
src/actions/projects/updateProject/logic.ts (1)
  • updateProject (10-60)
🔇 Additional comments (10)
src/actions/projects/updateProject/logic.test.ts (6)

1-16: LGTM! Clean test setup.

The imports and Jest mock configuration properly cover all Prisma methods used in the implementation (update, updateMany, findUnique).


18-29: LGTM! Proper test setup.

The mock project data is well-structured, and beforeEach ensures test isolation by clearing mocks.


31-47: LGTM! Validates backwards compatibility.

This test correctly verifies the standard update path when lastUpdatedAt is not provided, ensuring existing behavior is preserved.


49-66: LGTM! Proper error handling test.

This test correctly validates that Prisma's P2025 error (record not found) is mapped to a CONFLICT error with an appropriate user-friendly message.


68-92: LGTM! Validates optimistic locking success path.

This test correctly verifies the optimistic locking mechanism when lastUpdatedAt matches, including the two-step process (updateMany + findUnique) and the updated timestamp.


116-124: LGTM! Validates error propagation.

This test correctly ensures that unexpected errors (non-P2025) are re-thrown rather than being silently handled, which is important for debugging and error monitoring.

src/actions/projects/updateProject/logic.ts (4)

8-8: LGTM! Required import for error handling.

The import of error and ErrorCodes is necessary for the new error handling logic in the optimistic locking implementation.


11-12: LGTM! Properly extracts the new parameter.

Correctly destructures the optional lastUpdatedAt parameter from the input for use in the optimistic locking logic.


13-48: LGTM! Well-implemented optimistic locking with proper fallback.

The implementation correctly:

  • Uses updateMany with both id and updatedAt for optimistic locking (avoiding the past review's type-checking issue)
  • Checks count === 0 to detect conflicts
  • Follows up with findUnique to retrieve the updated record (necessary since updateMany doesn't return it)
  • Includes a defensive null check for the unlikely race condition scenario
  • Preserves backwards compatibility with the standard update path when lastUpdatedAt is omitted

The two-database-call approach in the optimistic path is necessary and well-documented.


51-59: LGTM! Proper error handling for not-found scenarios.

The catch block correctly:

  • Maps Prisma's P2025 error (record not found) to a user-friendly CONFLICT message
  • Handles the case where the project ID doesn't exist in the standard update path
  • Re-throws unexpected errors to ensure they're properly surfaced for debugging and monitoring

Comment on lines +94 to +114
it('should return CONFLICT error if lastUpdatedAt does not match (optimistic locking)', async () => {
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 0 });

const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: new Date('2025-01-01T09:00:00.000Z').toISOString() };
const result = await updateProject(input);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-100',
updatedAt: new Date('2025-01-01T09:00:00.000Z'),
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).not.toHaveBeenCalled();
expect(prisma.project.update).not.toHaveBeenCalled();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix the project ID mismatch in the test assertion.

The test input uses id: 'project-123' (line 97), but the assertion checks for id: 'project-100' (line 107). This inconsistency could cause the test to validate incorrect behavior.

Apply this diff to fix the mismatch:

     expect(prisma.project.updateMany).toHaveBeenCalledWith({
       where: {
-        id: 'project-100',
+        id: 'project-123',
         updatedAt: new Date('2025-01-01T09:00:00.000Z'),
       },
       data: { title: 'New Title' },
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should return CONFLICT error if lastUpdatedAt does not match (optimistic locking)', async () => {
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: new Date('2025-01-01T09:00:00.000Z').toISOString() };
const result = await updateProject(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-100',
updatedAt: new Date('2025-01-01T09:00:00.000Z'),
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).not.toHaveBeenCalled();
expect(prisma.project.update).not.toHaveBeenCalled();
});
it('should return CONFLICT error if lastUpdatedAt does not match (optimistic locking)', async () => {
(prisma.project.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
const input = { id: 'project-123', title: 'New Title', lastUpdatedAt: new Date('2025-01-01T09:00:00.000Z').toISOString() };
const result = await updateProject(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Project was modified by another user. Please refresh and try again.');
expect(result.errorCode).toBe(ErrorCodes.CONFLICT);
}
expect(prisma.project.updateMany).toHaveBeenCalledWith({
where: {
id: 'project-123',
updatedAt: new Date('2025-01-01T09:00:00.000Z'),
},
data: { title: 'New Title' },
});
expect(prisma.project.findUnique).not.toHaveBeenCalled();
expect(prisma.project.update).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
In src/actions/projects/updateProject/logic.test.ts around lines 94 to 114, the
test input uses id: 'project-123' but the updateMany assertion checks for id:
'project-100'; update the assertion to use id: 'project-123' so the mocked
prisma.project.updateMany call expectation matches the test input (leave the
updatedAt and data checks unchanged).

@fehranbit
Copy link
Member

the optimistic locking addition in updateProject is smart; it'll save a lot of headaches.

@fehranbit fehranbit merged commit fea0e50 into main Dec 6, 2025
1 of 2 checks passed
@fehranbit fehranbit deleted the feat/optimistic-project-update branch December 6, 2025 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants