Skip to content

Add foreign key for profile default category#36

Merged
gentamura merged 3 commits intomainfrom
feature-add-default-category-fk
Oct 26, 2025
Merged

Add foreign key for profile default category#36
gentamura merged 3 commits intomainfrom
feature-add-default-category-fk

Conversation

@gentamura
Copy link
Member

@gentamura gentamura commented Oct 26, 2025

Summary

  • enforce a foreign key constraint on profiles.default_category_id
  • ensure the account provisioner seeds and updates the default category id consistently

Testing

  • bun run build

Summary by CodeRabbit

  • Bug Fixes

    • Account provisioning now reliably links profiles to a resolved default category, updates profile email on conflict, enforces validation, and surfaces errors to prevent inconsistent or orphaned profiles.
  • Database Schema

    • Referential integrity enforced between profiles and categories with a delete restriction on the default-category relationship to ensure data consistency.

@coderabbitai
Copy link

coderabbitai bot commented Oct 26, 2025

Walkthrough

Provisioning now upserts a default category (returning row when possible), falls back to SELECT by creator/name/kind if needed, resolves the category ID or throws, then upserts/updates the profile (updates email on conflict) and assigns the resolved defaultCategoryId (FK) within the same transaction.

Changes

Cohort / File(s) Summary
Provisioning Logic
packages/auth/src/account/provision-account.ts
Replaced category insert onConflictDoNothing with insert that returns row and fallback SELECT by createdBy/name/kind; added explicit error when category cannot be resolved; changed profile upsert to onConflictDoUpdate to set email, then update defaultCategoryId (uses and operator in predicates). Ensures updates occur inside the transaction.
Schema Constraints
packages/db/src/schema/index.ts
Converted profiles.defaultCategoryId from a plain uuid("default_category_id") to a foreign-key reference: uuid("default_category_id").references(() => categories.id, { onDelete: "restrict" }).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Provision as ProvisionAccount
    participant DB as Database

    Client->>Provision: provisionAccount(req)
    rect #eef7ff
      Note over Provision,DB: Try insert default category (returning row)
      Provision->>DB: INSERT INTO categories (...) RETURNING id
      DB-->>Provision: insertedCategoryId or conflict (no row)
    end

    rect #f7fff0
      Note over Provision,DB: Fallback resolve
      alt inserted id returned
        Provision-->>Provision: use insertedCategoryId
      else no row returned
        Provision->>DB: SELECT id FROM categories WHERE created_by = ... AND name = ... AND kind = ...
        DB-->>Provision: existingCategoryId or none
      end
      alt no categoryId
        Provision-->>Provision: throw Error("default category unresolved")
      end
    end

    rect #fff7ee
      Note over Provision,DB: Upsert profile and set FK
      Provision->>DB: INSERT INTO profiles (...) ON CONFLICT(id) DO UPDATE SET email = ... RETURNING id
      DB-->>Provision: profile inserted/updated
      Provision->>DB: UPDATE profiles SET default_category_id = <resolvedId> WHERE id = ...
      DB-->>Provision: updated / constraint error
    end

    Provision-->>Client: success / error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Review transaction boundaries and concurrency between category upsert/SELECT and profile update.
  • Verify correctness of the ON CONFLICT DO UPDATE (email update) and conflict target.
  • Validate FK change and confirm migrations and delete-restrict behavior won't break existing data.
  • Inspect error paths where category resolution or profile update can fail and ensure proper rollback.

Possibly related PRs

Poem

🐰 I hopped through inserts, found a row or two,
If conflict stood firm, I searched what’s true.
Then I stitched profile to a category bright,
With a tidy FK holding it tight.
Hooray — I nibble bugs out of sight! 🥕

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 pull request title "Add foreign key for profile default category" directly and accurately describes the primary structural change in the changeset—specifically, the conversion of profiles.defaultCategoryId from a plain UUID column to a foreign key referencing categories.id with delete constraints in the schema file. While the changeset also includes behavioral updates to the account provisioner to ensure proper seeding and updating of the default category id, the title appropriately captures the main architectural change. The title is concise, specific, and clearly distinguishable from other potential changes, meeting the expectation that titles don't need to cover every detail of the changeset.
✨ 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 feature-add-default-category-fk

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.

@gentamura gentamura self-assigned this Oct 26, 2025
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: 2

Caution

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

⚠️ Outside diff range comments (1)
packages/auth/src/account/provision-account.ts (1)

77-90: ON CONFLICT predicate will not match the partial unique index if defaultCategoryKind is overridden.

The partial unique index categories_system_name_idx is hardcoded with WHERE kind = 'DEFAULT' (the literal DEFAULT_CATEGORY_KIND value). However, the ON CONFLICT clause uses where: eq(categories.kind, defaultCategoryKind), which can be any string if overridden via AccountProvisionerDependencies.defaultCategoryKind.

When defaultCategoryKind differs from the literal DEFAULT_CATEGORY_KIND, Postgres cannot match it to the partial index and will error: "there is no unique or exclusion constraint matching the ON CONFLICT specification".

Required fixes:

  • Use ON CONFLICT ON CONSTRAINT categories_system_name_idx if your query builder supports constraint-based conflict resolution, or
  • Remove the defaultCategoryKind override option from dependencies (enforce it as a constant), or
  • Add a guard rejecting non-default kinds in provisioning, or
  • Add a full unique constraint on (created_by, name, kind) instead of a partial index.
🧹 Nitpick comments (1)
packages/auth/src/account/provision-account.ts (1)

91-104: Category resolution LGTM.

Insert-or-select pattern with limit(1) is correct and RLS-safe for the owner.

Optionally: const [categoryRecord] = await tx.select(...).limit(1); improves readability.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d22e8f0 and f4be56a.

📒 Files selected for processing (2)
  • packages/auth/src/account/provision-account.ts (3 hunks)
  • packages/db/src/schema/index.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
packages/*/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

packages/*/src/**/*.{ts,tsx}: Avoid implicit any; TypeScript is run with strict enabled
Prefer type guards or the satisfies operator over as casts where appropriate
Prefer unknown for external inputs
Use PascalCase for types and enums
Use camelCase for variables and functions
Ensure source comments are written in English

Files:

  • packages/db/src/schema/index.ts
  • packages/auth/src/account/provision-account.ts
🧬 Code graph analysis (1)
packages/auth/src/account/provision-account.ts (2)
packages/db/src/schema/index.ts (2)
  • categories (63-111)
  • profiles (25-59)
packages/db/src/index.ts (2)
  • and (581-581)
  • eq (581-581)
🔇 Additional comments (1)
packages/auth/src/account/provision-account.ts (1)

8-8: Import looks correct.

and is used in the category lookup predicate; import is appropriate.

@gentamura gentamura force-pushed the feature-add-default-category-fk branch from f4be56a to e1fb18a Compare October 26, 2025 06:12
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: 0

🧹 Nitpick comments (1)
packages/auth/src/account/provision-account.ts (1)

124-135: Consider removing redundant email update.

The email field is already set during the initial profile upsert at line 86. Including it again at line 128 is harmless but unnecessary, as the email value hasn't changed between these operations.

Apply this diff to remove the redundant update:

       const shouldUpdateProfile = await tx
         .update(profiles)
         .set({
           defaultCategoryId,
-          email,
         })
         .where(eq(profiles.id, params.userId))
         .returning({ id: profiles.id });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e1fb18a and f42b105.

📒 Files selected for processing (1)
  • packages/auth/src/account/provision-account.ts (3 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
packages/*/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

packages/*/src/**/*.{ts,tsx}: Avoid implicit any; TypeScript is run with strict enabled
Prefer type guards or the satisfies operator over as casts where appropriate
Prefer unknown for external inputs
Use PascalCase for types and enums
Use camelCase for variables and functions
Ensure source comments are written in English

Files:

  • packages/auth/src/account/provision-account.ts
🧬 Code graph analysis (1)
packages/auth/src/account/provision-account.ts (2)
packages/db/src/schema/index.ts (2)
  • profiles (25-60)
  • categories (64-112)
packages/db/src/index.ts (2)
  • and (581-581)
  • eq (581-581)
🔇 Additional comments (5)
packages/auth/src/account/provision-account.ts (5)

8-8: LGTM: Import added for compound WHERE clause.

The and import is correctly added and used at line 110 for the category resolution query.


77-88: Excellent: Profile insertion reordered to resolve circular FK constraint.

Inserting the profile first without defaultCategoryId correctly breaks the circular dependency. The category can now reference profiles.id via createdBy, and the profile FK is set later after category resolution.


90-102: LGTM: Category insert with proper returning clause.

The .returning() clause correctly captures the inserted category ID. When a conflict occurs with onConflictDoNothing, the returned array will be empty, which is handled by the fallback query.


104-116: LGTM: Fallback query correctly resolves existing category.

The fallback SELECT properly handles the case where onConflictDoNothing returns an empty array. The compound WHERE clause matches the unique constraint conditions, ensuring the correct category is retrieved.


118-120: LGTM: Appropriate error handling for category resolution failure.

The guard correctly catches the unlikely case where the category cannot be resolved, providing a clear error message for debugging.

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: 0

Caution

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

⚠️ Outside diff range comments (1)
packages/auth/src/account/provision-account.ts (1)

90-102: Fix concurrency race: ON CONFLICT DO NOTHING + SELECT can fail under parallel provisioning.

If another tx inserts the category and hasn’t committed yet, the fallback SELECT may not see it, causing a spurious “Failed to resolve default category” error. Use ON CONFLICT DO UPDATE ... RETURNING to always get the id from a single statement.

Apply this diff to eliminate the race and drop the fallback:

-      const insertedCategories = await tx
-        .insert(categories)
-        .values({
-          name: defaultCategoryName,
-          kind: defaultCategoryKind,
-          createdBy: params.userId,
-          updatedBy: params.userId,
-        })
-        .onConflictDoNothing({
-          target: [categories.createdBy, categories.name],
-          where: eq(categories.kind, defaultCategoryKind),
-        })
-        .returning({ categoryId: categories.id });
-
-      const categoryRecord =
-        insertedCategories[0] ??
-        (
-          await tx
-            .select({ categoryId: categories.id })
-            .from(categories)
-            .where(
-              and(
-                eq(categories.createdBy, params.userId),
-                eq(categories.name, defaultCategoryName),
-                eq(categories.kind, defaultCategoryKind),
-              ),
-            )
-            .limit(1)
-        )[0];
+      const [categoryRecord] = await tx
+        .insert(categories)
+        .values({
+          name: defaultCategoryName,
+          kind: defaultCategoryKind,
+          createdBy: params.userId,
+          updatedBy: params.userId,
+        })
+        .onConflictDoUpdate({
+          target: [categories.createdBy, categories.name],
+          where: eq(categories.kind, defaultCategoryKind),
+          // Touch a column to ensure UPDATE path and RETURNING always yield a row.
+          set: { updatedBy: params.userId },
+        })
+        .returning({ categoryId: categories.id });

If you adopt this, remove the now-unused and import. For example:

-import {
-  and,
-  categories,
+import {
+  categories,
   createRlsClient,
   DEFAULT_CATEGORY_KIND,
   DEFAULT_CATEGORY_NAME,
   eq,
   profiles,
 } from "@listee/db";

Also applies to: 104-118

🧹 Nitpick comments (2)
packages/auth/src/account/provision-account.ts (2)

83-88: Good: upsert profile email on conflict.

Prevents stale emails on re-provision. Consider normalizing to lowercase when setting email to avoid future unique collisions from differing cases.


126-133: Rename result variable and (optionally) avoid no-op updates.

  • shouldUpdateProfile is an array of updated rows; rename for clarity.
  • Optional: guard the UPDATE so you only write when email or defaultCategoryId changes.

Apply this minimal rename:

-      const shouldUpdateProfile = await tx
+      const updatedProfiles = await tx
         .update(profiles)
         .set({
           defaultCategoryId,
           email,
         })
         .where(eq(profiles.id, params.userId))
         .returning({ id: profiles.id });
 
-      if (shouldUpdateProfile.length === 0) {
+      if (updatedProfiles.length === 0) {
         throw new Error("Failed to update profile with default category");
       }

Optional guard idea (pseudo with intent): add extra predicates so the UPDATE runs only when values differ.

Also applies to: 135-137

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f42b105 and 2197501.

📒 Files selected for processing (1)
  • packages/auth/src/account/provision-account.ts (3 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
packages/*/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

packages/*/src/**/*.{ts,tsx}: Avoid implicit any; TypeScript is run with strict enabled
Prefer type guards or the satisfies operator over as casts where appropriate
Prefer unknown for external inputs
Use PascalCase for types and enums
Use camelCase for variables and functions
Ensure source comments are written in English

Files:

  • packages/auth/src/account/provision-account.ts
🧬 Code graph analysis (1)
packages/auth/src/account/provision-account.ts (2)
packages/db/src/schema/index.ts (2)
  • profiles (25-60)
  • categories (64-112)
packages/db/src/index.ts (2)
  • and (581-581)
  • eq (581-581)

@gentamura gentamura merged commit bd885a4 into main Oct 26, 2025
5 checks passed
@gentamura gentamura deleted the feature-add-default-category-fk branch October 26, 2025 07:04
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.

1 participant