Skip to content

Fixes #12507: Allow renaming a tag to the title of an orphaned tag#14728

Closed
windsnow1025 wants to merge 1 commit intolaurent22:devfrom
windsnow1025:dev
Closed

Fixes #12507: Allow renaming a tag to the title of an orphaned tag#14728
windsnow1025 wants to merge 1 commit intolaurent22:devfrom
windsnow1025:dev

Conversation

@windsnow1025
Copy link
Copy Markdown

When a note with an exclusive tag is permanently deleted, the tag record remains in the database but becomes invisible in the UI (no active notes reference it). Attempting to rename another tag to this orphaned tag's name fails with "The tag already exists", even though the user cannot see or interact with the orphaned tag.

This fix checks whether the conflicting tag is an orphan (has no associated active notes) during rename validation. If so, it removes the orphaned tag and its stale note_tags records, allowing the rename to proceed. If the conflicting tag is still in use, the existing error is thrown as before.

Previous PRs (#13628, #14616) were closed due to concerns about sync complexity. #13628 cleaned up note_tags during permanent deletion; #14616 both cleaned up orphaned tags during deletion and handled rename conflicts. This PR takes a narrower approach — it only handles the rename conflict case without modifying the deletion flow, minimizing the impact on sync behavior.

A test case has been added to verify that renaming to an orphaned tag name succeeds.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 13, 2026

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

The changes modify the Tag model to permit renaming a tag to match an orphaned tag's name by first deleting the unused tag. A test case is added to validate this renaming scenario without throwing an error.

Changes

Cohort / File(s) Summary
Test Coverage
packages/lib/models/Tag.test.ts
Added test case validating tag renaming to an orphaned tag's name, confirming no exception is thrown and the tag title is updated correctly.
Tag Rename Logic
packages/lib/models/Tag.ts
Modified Tag.save method to check if a conflicting tag has associated notes; if the existing tag is orphaned (no notes), it is untagged and deleted to allow name reuse; otherwise, an error is thrown as before.

Suggested labels

bug, tags

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and specifically describes the main change: allowing tag renaming to orphaned tag titles, which directly matches the changeset modifications.
Description check ✅ Passed The pull request description comprehensively explains the problem, the solution approach, and how it differs from previous attempts, which directly relates to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Pr Description Must Follow Guidelines ✅ Passed The PR is well-scoped to address orphaned tag handling during rename operations with appropriate test coverage added.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

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.

Tip

You can get early access to new features in CodeRabbit.

Enable the early_access setting to enable early access features such as new models, tools, and more.

@coderabbitai coderabbitai Bot added bug It's a bug tags tag related issue labels Mar 13, 2026
@windsnow1025
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Mar 13, 2026
Copy link
Copy Markdown
Contributor

@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

🧹 Nitpick comments (1)
packages/lib/models/Tag.test.ts (1)

226-232: Strengthen this regression test to assert de-duplication explicitly.

This currently proves “rename succeeds”, but it can still pass if two tags named 'un' remain. Please also assert that only one 'un' tag exists and that it is tagDeux.id (with note2 still associated).

Suggested assertion extension
 		const renamedTag = await Tag.load(tagDeux.id);
 		expect(renamedTag.title).toBe('un');
+		const tagsNamedUn = await Tag.modelSelectAll('SELECT id FROM tags WHERE title = ?', ['un']);
+		expect(tagsNamedUn).toHaveLength(1);
+		expect(tagsNamedUn[0].id).toBe(tagDeux.id);
+		expect(await Tag.noteIds(tagDeux.id)).toEqual([note2.id]);
 	});

As per coding guidelines, "Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/lib/models/Tag.test.ts` around lines 226 - 232, Add explicit
de-duplication assertions after the rename: load the tag by title (const unTag =
await Tag.loadByTitle('un')) and assert it exists and its id equals tagDeux.id;
assert there is only one tag named 'un' (e.g. await Tag.findAll() then filter by
t.title==='un' and expect length === 1); and assert note2 still refers to that
tag (e.g. const reloadedNote2 = await Note.load(note2.id) and
expect(reloadedNote2.tagIds/includes(tagDeux.id))).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/lib/models/Tag.ts`:
- Around line 237-240: The orphan-resolution logic around
Tag.noteIds(existingTag.id) and Tag.untagAll(existingTag.id) must be made atomic
to avoid races: wrap the conflict check, potential untag/delete and the
subsequent rename into a single DB transaction (or obtain a row-level lock for
the existingTag) so you re-check the current note count under that same
transaction/lock before calling Tag.untagAll and performing the rename; if your
ORM supports transactions use Tag.transaction(...) (or the equivalent) and
re-run Tag.noteIds(existingTag.id) or a SELECT ... FOR UPDATE inside it, then
proceed with Tag.untagAll(existingTag.id) and rename only when the re-checked
count is zero.

---

Nitpick comments:
In `@packages/lib/models/Tag.test.ts`:
- Around line 226-232: Add explicit de-duplication assertions after the rename:
load the tag by title (const unTag = await Tag.loadByTitle('un')) and assert it
exists and its id equals tagDeux.id; assert there is only one tag named 'un'
(e.g. await Tag.findAll() then filter by t.title==='un' and expect length ===
1); and assert note2 still refers to that tag (e.g. const reloadedNote2 = await
Note.load(note2.id) and expect(reloadedNote2.tagIds/includes(tagDeux.id))).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 455ab493-4bc3-4910-b92c-70c9ff980cae

📥 Commits

Reviewing files that changed from the base of the PR and between 364ea03 and 7854cbf.

📒 Files selected for processing (2)
  • packages/lib/models/Tag.test.ts
  • packages/lib/models/Tag.ts

Comment on lines +237 to +240
const noteIds = await Tag.noteIds(existingTag.id);
if (noteIds.length === 0) {
await Tag.untagAll(existingTag.id);
} else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make orphan resolution atomic to avoid data loss.

The orphan check and deletion are split across separate async steps. A concurrent note-tag association in between can delete a tag that has become in-use. Please execute conflict check + orphan deletion + rename in a single transaction (or re-check under the same lock) before deleting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/lib/models/Tag.ts` around lines 237 - 240, The orphan-resolution
logic around Tag.noteIds(existingTag.id) and Tag.untagAll(existingTag.id) must
be made atomic to avoid races: wrap the conflict check, potential untag/delete
and the subsequent rename into a single DB transaction (or obtain a row-level
lock for the existingTag) so you re-check the current note count under that same
transaction/lock before calling Tag.untagAll and performing the rename; if your
ORM supports transactions use Tag.transaction(...) (or the equivalent) and
re-run Tag.noteIds(existingTag.id) or a SELECT ... FOR UPDATE inside it, then
proceed with Tag.untagAll(existingTag.id) and rename only when the re-checked
count is zero.

@mrjo118
Copy link
Copy Markdown
Collaborator

mrjo118 commented Mar 13, 2026

This PR takes a narrower approach — it only handles the rename conflict case without modifying the deletion flow, minimizing the impact on sync behavior

There's an important point which was made on one of the comments on my closed PR: #13628 (comment)

It's no good checking if a tag has been orphaned locally, because you can't guarantee that it's not still in use by another client which is not in sync. So your approach still has the same fundamental problem that the original change on my PR did

@laurent22
Copy link
Copy Markdown
Owner

Please confirm you'd like to complete this PR

@windsnow1025
Copy link
Copy Markdown
Author

Please confirm you'd like to complete this PR

I think I don't have a clean solution for the sync problem at this point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug It's a bug tags tag related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants