detached 8077f20

fix(favorites): harden folder tag migration collisions

commit
8077f20fb35eee004f311464fb51a948ff09d4fe
author
brice · · 2026-06-28 07:26 UTC
parent
ecab2f7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +105 -10
M internal/infrastructure/persistence/sqlite/favorites_migration_test.go +46 -0
@@ -86,6 +86,52 @@ func TestFavoritesTagsFirstMigrationFolderTagCollisionSuffix(t *testing.T) {
8686 }
8787 }
8888
89+func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsExistingTag(t *testing.T) {
90+ db := openMigrationTestDB(t)
91+ runMigrationsUpTo(t, db, 10)
92+
93+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111'), (2, 'foo-folder-3', '#222222')`)
94+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL)`)
95+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3)`)
96+
97+ runAllMigrations(t, db)
98+
99+ var tagID int64
100+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&tagID); err != nil {
101+ t.Fatalf("expected second-order suffixed collision tag: %v", err)
102+ }
103+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 {
104+ t.Fatalf("expected assignment to second-order suffixed tag, got %d", got)
105+ }
106+}
107+
108+func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsFolderNaturalSlug(t *testing.T) {
109+ db := openMigrationTestDB(t)
110+ runMigrationsUpTo(t, db, 10)
111+
112+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111')`)
113+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL), (4, 'foo-folder-3', NULL)`)
114+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3), (2, 'https://folder.example', 'Folder', 4)`)
115+
116+ runAllMigrations(t, db)
117+
118+ var folder3TagID int64
119+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&folder3TagID); err != nil {
120+ t.Fatalf("expected generated suffix to avoid folder natural slug: %v", err)
121+ }
122+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, folder3TagID); got != 1 {
123+ t.Fatalf("expected assignment to second-order suffixed tag, got %d", got)
124+ }
125+
126+ var folder4TagID int64
127+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3'`).Scan(&folder4TagID); err != nil {
128+ t.Fatalf("expected natural folder slug tag: %v", err)
129+ }
130+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 2 AND tag_id = ?`, folder4TagID); got != 1 {
131+ t.Fatalf("expected assignment to natural slug tag, got %d", got)
132+ }
133+}
134+
89135 func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) {
90136 db := openMigrationTestDB(t)
91137 runMigrationsUpTo(t, db, 10)
@@ -86,6 +86,52 @@ func TestFavoritesTagsFirstMigrationFolderTagCollisionSuffix(t *testing.T) {
86 }86 }
87 }87 }
88 88
89+func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsExistingTag(t *testing.T) {
90+ db := openMigrationTestDB(t)
91+ runMigrationsUpTo(t, db, 10)
92+
93+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111'), (2, 'foo-folder-3', '#222222')`)
94+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL)`)
95+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3)`)
96+
97+ runAllMigrations(t, db)
98+
99+ var tagID int64
100+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&tagID); err != nil {
101+ t.Fatalf("expected second-order suffixed collision tag: %v", err)
102+ }
103+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, tagID); got != 1 {
104+ t.Fatalf("expected assignment to second-order suffixed tag, got %d", got)
105+ }
106+}
107+
108+func TestFavoritesTagsFirstMigrationGeneratedSuffixAvoidsFolderNaturalSlug(t *testing.T) {
109+ db := openMigrationTestDB(t)
110+ runMigrationsUpTo(t, db, 10)
111+
112+ mustExec(t, db, `INSERT INTO favorite_tags(id, name, color) VALUES (1, 'foo', '#111111')`)
113+ mustExec(t, db, `INSERT INTO favorite_folders(id, name, parent_id) VALUES (3, 'Foo', NULL), (4, 'foo-folder-3', NULL)`)
114+ mustExec(t, db, `INSERT INTO favorites(id, url, title, folder_id) VALUES (1, 'https://foo.example', 'Foo', 3), (2, 'https://folder.example', 'Folder', 4)`)
115+
116+ runAllMigrations(t, db)
117+
118+ var folder3TagID int64
119+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3-2'`).Scan(&folder3TagID); err != nil {
120+ t.Fatalf("expected generated suffix to avoid folder natural slug: %v", err)
121+ }
122+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 1 AND tag_id = ?`, folder3TagID); got != 1 {
123+ t.Fatalf("expected assignment to second-order suffixed tag, got %d", got)
124+ }
125+
126+ var folder4TagID int64
127+ if err := db.QueryRow(`SELECT id FROM favorite_tags WHERE name = 'foo-folder-3'`).Scan(&folder4TagID); err != nil {
128+ t.Fatalf("expected natural folder slug tag: %v", err)
129+ }
130+ if got := countRows(t, db, `SELECT COUNT(*) FROM favorite_tag_assignments WHERE favorite_id = 2 AND tag_id = ?`, folder4TagID); got != 1 {
131+ t.Fatalf("expected assignment to natural slug tag, got %d", got)
132+ }
133+}
134+
89 func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) {135 func TestFavoritesTagsFirstMigrationMergesFoldedDuplicateTags(t *testing.T) {
90 db := openMigrationTestDB(t)136 db := openMigrationTestDB(t)
91 runMigrationsUpTo(t, db, 10)137 runMigrationsUpTo(t, db, 10)
M internal/infrastructure/persistence/sqlite/migrations/011_favorites_tags_first.sql +59 -10
@@ -49,22 +49,71 @@ WITH RECURSIVE folder_paths(id, parent_id, path) AS (
4949 )
5050 ) AS base_slug
5151 FROM folder_paths
52-), resolved AS (
52+), folder_names AS (
5353 SELECT
5454 id,
5555 CASE
5656 WHEN base_slug = '' THEN 'folder-' || id
57- WHEN EXISTS (
58- SELECT 1 FROM favorite_tags t
59- WHERE lower(trim(t.name)) = lower(trim(base_slug))
60- ) OR EXISTS (
61- SELECT 1 FROM folder_slugs earlier
62- WHERE earlier.id < folder_slugs.id
63- AND lower(trim(earlier.base_slug)) = lower(trim(folder_slugs.base_slug))
64- ) THEN base_slug || '-folder-' || id
6557 ELSE base_slug
66- END AS tag_name
58+ END AS natural_name
6759 FROM folder_slugs
60+), base_assignments AS (
61+ SELECT id, natural_name AS tag_name
62+ FROM folder_names current_folder
63+ WHERE NOT EXISTS (
64+ SELECT 1 FROM favorite_tags t
65+ WHERE lower(trim(t.name)) = lower(trim(current_folder.natural_name))
66+ )
67+ AND NOT EXISTS (
68+ SELECT 1 FROM folder_names earlier
69+ WHERE earlier.id < current_folder.id
70+ AND lower(trim(earlier.natural_name)) = lower(trim(current_folder.natural_name))
71+ )
72+), unresolved_folders AS (
73+ SELECT id, natural_name
74+ FROM folder_names
75+ WHERE id NOT IN (SELECT id FROM base_assignments)
76+), candidate_limit(max_attempt) AS (
77+ SELECT (SELECT COUNT(*) FROM favorite_tags) + (SELECT COUNT(*) FROM folder_names) + 3
78+), candidate_numbers(attempt) AS (
79+ SELECT 1
80+ UNION ALL
81+ SELECT attempt + 1
82+ FROM candidate_numbers, candidate_limit
83+ WHERE attempt < max_attempt
84+), suffix_candidates AS (
85+ SELECT
86+ unresolved_folders.id,
87+ candidate_numbers.attempt,
88+ CASE
89+ WHEN candidate_numbers.attempt = 1 THEN unresolved_folders.natural_name || '-folder-' || unresolved_folders.id
90+ ELSE unresolved_folders.natural_name || '-folder-' || unresolved_folders.id || '-' || candidate_numbers.attempt
91+ END AS tag_name
92+ FROM unresolved_folders
93+ CROSS JOIN candidate_numbers
94+), available_suffix_candidates AS (
95+ SELECT id, attempt, tag_name
96+ FROM suffix_candidates candidate
97+ WHERE NOT EXISTS (
98+ SELECT 1 FROM favorite_tags t
99+ WHERE lower(trim(t.name)) = lower(trim(candidate.tag_name))
100+ )
101+ AND NOT EXISTS (
102+ SELECT 1 FROM base_assignments assigned
103+ WHERE lower(trim(assigned.tag_name)) = lower(trim(candidate.tag_name))
104+ )
105+), suffix_assignments AS (
106+ SELECT id, tag_name
107+ FROM available_suffix_candidates candidate
108+ WHERE attempt = (
109+ SELECT MIN(attempt)
110+ FROM available_suffix_candidates earlier
111+ WHERE earlier.id = candidate.id
112+ )
113+), resolved AS (
114+ SELECT id, tag_name FROM base_assignments
115+ UNION ALL
116+ SELECT id, tag_name FROM suffix_assignments
68117 )
69118 INSERT INTO _folder_tag_map(folder_id, tag_name)
70119 SELECT id, tag_name FROM resolved;
@@ -49,22 +49,71 @@ WITH RECURSIVE folder_paths(id, parent_id, path) AS (
49 )49 )
50 ) AS base_slug50 ) AS base_slug
51 FROM folder_paths51 FROM folder_paths
52-), resolved AS (52+), folder_names AS (
53 SELECT53 SELECT
54 id,54 id,
55 CASE55 CASE
56 WHEN base_slug = '' THEN 'folder-' || id56 WHEN base_slug = '' THEN 'folder-' || id
57- WHEN EXISTS (
58- SELECT 1 FROM favorite_tags t
59- WHERE lower(trim(t.name)) = lower(trim(base_slug))
60- ) OR EXISTS (
61- SELECT 1 FROM folder_slugs earlier
62- WHERE earlier.id < folder_slugs.id
63- AND lower(trim(earlier.base_slug)) = lower(trim(folder_slugs.base_slug))
64- ) THEN base_slug || '-folder-' || id
65 ELSE base_slug57 ELSE base_slug
66- END AS tag_name58+ END AS natural_name
67 FROM folder_slugs59 FROM folder_slugs
60+), base_assignments AS (
61+ SELECT id, natural_name AS tag_name
62+ FROM folder_names current_folder
63+ WHERE NOT EXISTS (
64+ SELECT 1 FROM favorite_tags t
65+ WHERE lower(trim(t.name)) = lower(trim(current_folder.natural_name))
66+ )
67+ AND NOT EXISTS (
68+ SELECT 1 FROM folder_names earlier
69+ WHERE earlier.id < current_folder.id
70+ AND lower(trim(earlier.natural_name)) = lower(trim(current_folder.natural_name))
71+ )
72+), unresolved_folders AS (
73+ SELECT id, natural_name
74+ FROM folder_names
75+ WHERE id NOT IN (SELECT id FROM base_assignments)
76+), candidate_limit(max_attempt) AS (
77+ SELECT (SELECT COUNT(*) FROM favorite_tags) + (SELECT COUNT(*) FROM folder_names) + 3
78+), candidate_numbers(attempt) AS (
79+ SELECT 1
80+ UNION ALL
81+ SELECT attempt + 1
82+ FROM candidate_numbers, candidate_limit
83+ WHERE attempt < max_attempt
84+), suffix_candidates AS (
85+ SELECT
86+ unresolved_folders.id,
87+ candidate_numbers.attempt,
88+ CASE
89+ WHEN candidate_numbers.attempt = 1 THEN unresolved_folders.natural_name || '-folder-' || unresolved_folders.id
90+ ELSE unresolved_folders.natural_name || '-folder-' || unresolved_folders.id || '-' || candidate_numbers.attempt
91+ END AS tag_name
92+ FROM unresolved_folders
93+ CROSS JOIN candidate_numbers
94+), available_suffix_candidates AS (
95+ SELECT id, attempt, tag_name
96+ FROM suffix_candidates candidate
97+ WHERE NOT EXISTS (
98+ SELECT 1 FROM favorite_tags t
99+ WHERE lower(trim(t.name)) = lower(trim(candidate.tag_name))
100+ )
101+ AND NOT EXISTS (
102+ SELECT 1 FROM base_assignments assigned
103+ WHERE lower(trim(assigned.tag_name)) = lower(trim(candidate.tag_name))
104+ )
105+), suffix_assignments AS (
106+ SELECT id, tag_name
107+ FROM available_suffix_candidates candidate
108+ WHERE attempt = (
109+ SELECT MIN(attempt)
110+ FROM available_suffix_candidates earlier
111+ WHERE earlier.id = candidate.id
112+ )
113+), resolved AS (
114+ SELECT id, tag_name FROM base_assignments
115+ UNION ALL
116+ SELECT id, tag_name FROM suffix_assignments
68 )117 )
69 INSERT INTO _folder_tag_map(folder_id, tag_name)118 INSERT INTO _folder_tag_map(folder_id, tag_name)
70 SELECT id, tag_name FROM resolved;119 SELECT id, tag_name FROM resolved;

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close