Skip to content

Renaming or deleting a category is all or nothing, and names the app uses are refused (#100, #117) - #253

Open
smilne3 wants to merge 6 commits into
mainfrom
fix/100-category-rename-delete
Open

smilne3 wants to merge 6 commits into
mainfrom
fix/100-category-rename-delete

Conversation

@smilne3

@smilne3 smilne3 commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

This fixes #100, #117, #243 and #246. Renaming or deleting a category can no longer be left half done, and Settings refuses category names the app already uses for something else. Its messages now show where you're looking, on a phone too.

Migration 023 must be run in Supabase's SQL editor before this merges. The new code calls the two database functions it adds, and can't rename or delete a category without them.

What changes for you

  • Renaming a category moves its hand-picked transactions and its budget in the same step as the name. If any part fails, nothing changes, and Settings says to try again. Before, the name could change while its transactions stayed under the old one, counted as spending under a name no category had.
  • Deleting a category clears its picks, drops its budget and removes its merchant rules in one step. Before, a failed delete could clear its transactions and budget yet leave the category.
  • Adding or renaming to "Uncategorized", "Other" or "None", in any case, now says: “Other” is a name the app uses. Choose another. So do programming words such as "constructor" (A category named constructor, toString or __proto__ breaks its spending: summed as text, or lost #243). Before, these were accepted, and each quietly broke something:
  • A name another category has now says "That name is already a category." and reloads the list. Before, it showed the database's own words (Adding or renaming a category to a name that's taken shows the database's words: "duplicate key value violates unique constraint" #246).
  • A category that's already gone, deleted on the other phone say, now says "That category no longer exists. Showing the latest." instead of "not found".
  • An account in no household that adds a category is told "Your account isn't part of a household.", as other pages say, instead of "unauthorized".
  • A session that has ended is told "Your session ended. Sign in again.", as every other route says, instead of "unauthorized".
  • Every message in Settings → Categories shows under the box or row you used. Before, it showed at the top of the list. On a phone that's a long scroll above the Add box, so on the owner's iPhone check a refused "Other" looked like nothing happened. A deleted category's message stays where the category was.

How

  • db/migrations/023_category_rename_delete.sql adds rename_category(category_id, new_name) and delete_category(category_id). Each does its three writes in one transaction.
    • Both run as the signed-in person (security invoker), so row-level security applies as it did when the route made the writes itself. anon can't call them; signed-in people can.
    • A category that's gone, or can't be seen, raises P0002. Both return nothing otherwise, so any reply without an error means the change was made.
    • A rename takes over picks and a budget already stranded under the new name, so renaming back repairs an earlier failure. When both exist, the category's own budget replaces the stranded one.
    • A delete whose category row can't actually be deleted stops the whole call, so it can't clear the picks and budget of a category that stays. Today's policy can't cause this; one split per command without a delete policy could.
  • app/api/categories/route.ts. PATCH and DELETE make one .rpc() call each, and no longer read the category first. For all three writes:
    • P0002 answers 404, and 23505 answers 409.
    • A lost reply answers "may not have saved".
    • Every failed write is logged first, with its status and code, as the other write routes do. Before, a lost reply left no log line.
    • Any other refusal is answered in the route's words.
  • components/CategoryManager.tsx still keeps one message, and now shows it under the row or add box that sent the save. Once that row is gone, deleted here or on the other phone, the message keeps the row's place.
  • lib/category-names.ts holds the refused names and says what each is used for. tests/unit/category-names.test.ts ties the list to the names the app actually produces: the bank fallback, the Trends fold and Jev's "none".
  • scripts/check-invariants.mjs no longer lets the categories route write user_category. So the old three-request rename can't come back unnoticed.
  • db/seeds/023_category_rename_delete_checks.sql is one read-only query. It lists anything earlier failures left behind, and any category already using a refused name.

Checked

  • The functions, in real Postgres. I ran migrations 001 to 023 in PGlite (Postgres compiled to WebAssembly) on this Mac, with stand-ins for Supabase's roles and auth.uid(). Nothing ran against the real database. 27 checks pass, each calling the functions by argument name, as PostgREST does:
    • A rename or delete in one household leaves another household's same-named category, picks and budget alone. That holds even past row-level security, through each of the functions' six household filters.
    • When any later write is made to fail, the whole call is undone.
    • Another household's category, or a missing one, raises P0002 and changes nothing.
    • anon can't call either function, and both run with an empty search_path.
    • A delete whose row-level security lets the row be locked but not deleted changes nothing.
    • Running the migration again is safe. Its own check query, and the checks file, answer as their comments say.
  • Breaking the SQL on purpose. I broke the migration 29 ways and the checks query 5 ways, such as dropping a household filter, the not-found check, a grant or a parameter's name. A check failed every time.
  • The route. Its tests now pin each function's name, its arguments against 023's own parameters, every answer above and every log line. I broke the route, the names list, 023's signatures and locks, the checks query's names, the invariants check and CategoryManager's reload 41 ways, and a test failed every time.
  • Where messages show. Tests pin each message under the add box or its row, and a deleted row's message in that row's place. I broke the placement 9 ways, such as putting every message back at the top, and a test failed every time.
  • Reviews. Five reviews, of the SQL against PostgREST's and Supabase's docs, the code, its error handling, its tests and its comments. Their findings are fixed in this PR. The message placement came after them, from the owner's iPhone check.
  • Locally. vitest passes in both time zones. tsc, lint, check:secrets, check:invariants and next build pass.
  • Not checked yet: the functions in Supabase itself. The owner's steps cover that, before and after merging. CI doesn't run the SQL: The rename and delete functions in migration 023 are tested only by hand: run the migrations in CI with PGlite #255.

Left for later

Owner's steps

  1. Before merging, run the checks query, the migration, and the check at the migration's end, in Supabase's SQL editor.
  2. On the Vercel preview, on an iPhone: in Settings → Categories, add "Other". It should be refused with the words above, right under the Add box, and nothing is saved.
  3. After merging, on production: add a category "Test", rename it to "Test 2", then delete it. Each message shows under the box or row you used.

Closes #100. Closes #117. Closes #243. Closes #246.

🤖 Generated with Claude Code

smilne3 and others added 3 commits November 6, 2026 11:10
Migration 023 adds rename_category and delete_category. Each does a
category's three writes in one transaction, so a failure partway can no
longer leave picks under a name no category has. Both run as the
signed-in person, so RLS still applies, and only authenticated may call
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With nothing to return, any reply without an error means the change
was made, so an empty reply can't leave the outcome unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e app uses (#100, #117, #243, #246)

The categories route now renames and deletes with one call each, to
rename_category and delete_category, so all of a change lands or none
of it does. A category that's gone answers 404, and a name another
category has 409, both in words. Any other refusal is logged, never
shown in the database's words.

Uncategorized, Other and None, in any case, and the names every object
inherits, are refused on add and rename. A test ties the list to the
names the app produces.

The invariants check no longer lets the categories route write picks.
db/seeds/023_category_rename_delete_checks.sql finds anything earlier
failures left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
every-dollar-counts Ready Ready Preview Nov 11, 2026 2:38pm UTC

…e to 023's names (#100)

- A write whose reply was lost is now logged, with its status, as every
  other write route logs before deciding what to answer.
- delete_category stops the whole call if its delete matches nothing, so
  a policy that let the row be locked but not deleted can't clear the
  picks and budget of a category that stays.
- An ended session says "Your session ended. Sign in again.", as every
  other route does, instead of "unauthorized".
- Tests: the route's argument names and functions against 023's own,
  the row lock, the 400s for a missing id or name, and the checks
  query's name lists against the names the app refuses.
- Comments: the old route's failures described as they were, and the
  checks query's advice on leftovers, which a rename now repairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the test table (#100)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e top of the list (#100)

On a phone the top of the list is a long scroll above the Add box, so on the owner's iPhone check a
refused "Other" looked like nothing happened: each tap cleared the message and put it back off
screen.

CategoryManager keeps one message, and now renders it after the row or add box that sent the save.
A row that's gone, deleted here or on the other phone, leaves its message in the row's place, so
"Deleted …" shows where the row was. Tests pin each placement, and all 9 mutants of it are caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 97df5d9f Deployed Nov 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment