Skip to main content

max / makenotwork

Drop create_item redundant slug pre-check loop create_item ran a pre-insert SELECT-EXISTS loop to find a free slug, then a separate insert-retry loop that handles the same collision via the 23505 unique violation. The pre-check was redundant (a wasted round-trip on the common no-collision path) and a TOCTOU on the racy one — the insert-retry is the real dedup. Removed it; the unique index + suffix retry produce the same naming (base, base-2, base-3, ...). item_slug_exists stays (still used by bulk duplicate). Regression test: two same-title items in a project get distinct slugs (base, base-2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-18 02:27 UTC
Commit: 1fb0a75d905483b509192f6b68d1d8a2d81ed750
Parent: 5342e16
2 files changed, +47 insertions, -14 deletions
@@ -74,20 +74,11 @@ pub async fn create_item(
74 74 ) -> Result<DbItem> {
75 75 let mut slug = crate::helpers::slugify(title);
76 76
77 - // Check for collision and append counter if needed
78 - if item_slug_exists(pool, project_id, &slug).await? {
79 - let base = slug.clone();
80 - let mut counter = 2u32;
81 - loop {
82 - slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
83 - if !item_slug_exists(pool, project_id, &slug).await? {
84 - break;
85 - }
86 - counter += 1;
87 - }
88 - }
89 -
90 - // Retry loop for TOCTOU race on slug uniqueness
77 + // Slug uniqueness is enforced by the per-project unique index: insert the
78 + // bare slug, and on a 23505 collision append an incrementing suffix and
79 + // retry (`base`, `base-2`, `base-3`, ...). The retry IS the dedup — a
80 + // pre-insert `SELECT EXISTS` check would just be a redundant round-trip on
81 + // the common (no-collision) path and a TOCTOU on the racy one.
91 82 let base_slug = slug.clone();
92 83 let mut suffix = 1u32;
93 84 let item = loop {
@@ -387,3 +387,45 @@ async fn text_content_word_count() {
387 387 let data: Value = resp.json();
388 388 assert_eq!(data["word_count"].as_u64(), Some(10), "Should count 10 words");
389 389 }
390 +
391 + // create_item dedups slugs via the insert-retry on the per-project unique index
392 + // (the redundant pre-insert SELECT was removed). Two items with the same title in
393 + // one project must still get distinct slugs (base, base-2).
394 + #[tokio::test]
395 + async fn items_with_same_title_get_distinct_slugs() {
396 + let mut h = TestHarness::new().await;
397 + let user_id = h.signup("slugdedup", "slugdedup@test.com", "password123").await;
398 + h.grant_creator(user_id).await;
399 + h.client.post_form("/logout", "").await;
400 + h.login("slugdedup", "password123").await;
401 +
402 + let resp = h.client.post_form("/api/projects", "slug=slugdedup-proj&title=Project").await;
403 + assert!(resp.status.is_success(), "create project: {}", resp.text);
404 + let project: Value = resp.json();
405 + let project_id = project["id"].as_str().unwrap().to_string();
406 +
407 + for _ in 0..2 {
408 + let resp = h
409 + .client
410 + .post_form(
411 + &format!("/api/projects/{}/items", project_id),
412 + "title=Same+Title&price_cents=0&item_type=digital",
413 + )
414 + .await;
415 + assert!(resp.status.is_success(), "create item: {}", resp.text);
416 + }
417 +
418 + let mut slugs: Vec<String> = sqlx::query_scalar(
419 + "SELECT slug FROM items WHERE project_id = $1::uuid",
420 + )
421 + .bind(&project_id)
422 + .fetch_all(&h.db)
423 + .await
424 + .unwrap();
425 + slugs.sort();
426 + assert_eq!(
427 + slugs,
428 + vec!["same-title".to_string(), "same-title-2".to_string()],
429 + "same-title items must get distinct slugs via the insert-retry dedup"
430 + );
431 + }