Here are Horsemate's non-merge commits per month since February: 82, 63, 0, 0, 0, 19, 0, 318. The zeroes cover April, May and June, and August after a short July. September is the month I sat down and fixed the database. It's not a pretty graph.
The gap has an explanation, and I've already written it: a breakup, a move back to my hometown, and the horses sold and moved four hours north. Horsemate kept running through all of that without me touching it, and I said so at the time. What I didn't say was that "running" and "working correctly" turned out to be two different things.
Horsemate's own blog has the customer version of this post (in Swedish). It covers the new features, the pricing, and a tidy "under the hood" section with rounded numbers. This is the other version: what shipped, and then a longer look at why so much of the work since July went into fixing a database I designed.
What shipped
February: finishing the rewrite
February carried on from the modular rewrite I did in November 2025. Analytics moved from Mixpanel to PostHog. There was an auth hardening pass with Redis-backed rate limiting and a Content Security Policy, which was enforced by the end of the month. Stables got a digital stable board in kiosk mode, protected with a PIN. Horse import and export, economy reports per month, quarter, year and horse, document uploads in the health journal, and an internal admin and support dashboard.
On February 6th I also added a table linking horses to their owners. Remember that one. It comes back in September.
March: a new look
In March Horsemate got a new design language built around a mascot: a cartoon version of Olius, my former horse. The whole palette comes straight off him. The coat became the primary amber, the mane the gold accent, the blaze the cream background, the muzzle the pink for small details, and the hooves the dark brown used for borders and shadows.

Add hard borders, offset shadows and a bit more personality, and that's the design language. All public pages were redone first, then the app, then every email Horsemate sends. The landing page and SEO pages became fully static, and I built a newsletter system on top of Resend. If the database picture further down looks familiar, that's why: it uses the same cream background, and the lines are drawn in Olius's coat colour.
Then nothing for three months.
July: equipment, weather and health reminders
When I came back in July, the first thing I built was an equipment register per horse, with weather rules for blankets. You tell Horsemate which blankets a horse has and at what temperature, wind and rain each one should go on. Every morning the stable dashboard shows what applies today, based on SMHI's forecast for the stable's address. Today, on the last day of September, I added an afternoon notice about which rain blanket each horse needs for the night.
The same week I shipped proper health reminders: a daily job that emails and notifies seven days and one day before the next vaccination, deworming or farrier visit. The commit message says the health_reminders table was "previously unused". The table had been there since the rewrite. The feature it was built for hadn't been.
Late July was the first real sign of what was coming: an audit of who could get paid features without paying, and a round of work making scheduled jobs safe to run twice. More on both below.
September: 318 commits
September is where most of the work landed:
- A free feed ration calculator, open to everyone without an account, based on SLU's feeding recommendations, with a two-page PDF.
- QR check-in is back. Put a QR code on the stall door, scan it, check the horse in or out. This was the feature old customers asked for most after the December 2025 rewrite dropped it.
- One invoice. Add-on modules used to be separate Stripe subscriptions. Now they're items on the same subscription as the base plan, with proration, and capacity add-ons for more horses or helpers without changing plan.
- A horse owner role. Boarding stables can invite the owner of a horse, who sees their own horses and nothing else. This was the biggest single piece of work in the whole period, and it forced the database rework I'm about to describe.
- A move to Stockholm. The functions were running in Washington D.C. (iad1) while the database sits in Stockholm (eu-north-1). Every query crossed the Atlantic and back, around 90 ms per round trip. A page that makes eight queries in sequence pays that eight times. Pinning the functions to arn1 was a small change. Noticing it was the hard part.
- Smaller things: a live stable board via Supabase Realtime, HEIC uploads from iPhones with in-browser downscaling, Swedish error messages instead of raw Postgres ones, and session recording in PostHog turned off entirely.
The database

I like this picture. It's the first time I've looked at Horsemate's database as a whole instead of one table at a time, and it looks the way the codebase feels: a few dense hubs (horses, stables, members, tasks) with everything else hanging off them.
What it doesn't show is that a lot of those 169 policies were written or rewritten in the last three weeks. Counted across the 41 migrations since March: 134 create policy or alter policy statements, 87 revoke statements, and 57 database functions built or rebuilt. That's not new functionality. That's repair.
Horsemate runs on Supabase, which means the browser talks to Postgres through PostgREST, and row level security is the actual security boundary. Not the Next.js server actions, not the UI. If a policy says yes, the row is yours, no matter what the app thinks. I knew this. I didn't build like I knew it.
"The UI hides it, the database doesn't deny it"
That sentence is from my own planning doc for the horse owner role, and it sums up the whole problem. Before I could let a horse owner into a stable, I needed to know what they would be able to see. The answer was everything.
Nearly every SELECT policy asked one question: is this user a member of the stable? Only one policy in the entire public schema looked at the horse owner table I'd created back in February. Around 30 tables let any member write, with no role check at all: tasks, feed inventory, incidents, every racing table, notifications, weather settings. The role checks lived in the UI. A helper didn't see the button to delete a horse, so a helper couldn't delete a horse. As long as they used my buttons.
For a stable where everyone is family or a trusted helper, that was mostly fine in practice. For a boarding stable where you invite the people who pay you to keep their horse, it very much isn't. Vet costs on other people's horses were readable by every member.
The horse owner role shipped in five stages, each one a pair of migrations: one rewriting the rules, one adding the database functions that do the writes. Once I had a real role model in the policies, the next problem showed up immediately. The policies called helper functions once per row, and on a test stable three times the size of the largest real one, a horse owner counting their open tasks took 8.5 seconds, against 250 ms for a manager. The fix was helpers that compute "which stables do I manage" and "which horses do I own" once per query instead of once per row, with the visible rows verified identical before and after.
WITH CHECK is not optional
This one is my favourite, in the way that a scar can be your favourite. The policy on subscriptions, written in November 2025:
CREATE POLICY "Users can update own subscription" ON subscriptions
FOR UPDATE USING (auth.uid() = user_id);Reads fine. You can update your own subscription. The trouble is that USING decides which rows you're allowed to touch, not what you're allowed to write into them. Without a WITH CHECK, Postgres reuses the USING expression, which only says the row is still yours afterwards. Any signed-in user could change their own plan and status to whatever they liked. I confirmed it against production inside a transaction that I rolled back.
The fix wasn't adding a WITH CHECK. Subscription rows should only ever be written by the Stripe webhook, so clients lost write access to that table entirely. One detail from that migration worth passing on: dropping the policy alone isn't enough if the table-level grant is still there. The update doesn't fail. It quietly affects zero rows, and nothing tells you.
The same pattern, on a smaller scale, was on profiles. Users had table-level UPDATE on their own profile row, and the activity log copied the email address from there. So a helper could put another member's email on their own profile, and their entries in the log would look like that member's. Now the email comes from auth.users, and users get grants on specific columns only:
REVOKE INSERT, UPDATE ON public.profiles FROM anon, authenticated;
GRANT INSERT (id, full_name, phone, avatar_url, updated_at)
ON public.profiles TO authenticated;Which immediately broke saving your own profile, because PostgREST's upsert sets id on conflict, and users no longer had UPDATE on id. Every fix in this area came with a follow-up fix.
Row level security filters. It doesn't complain.
When RLS denies a SELECT, you get fewer rows. When it denies a DELETE, the delete matches nothing and affects zero rows. No error. My server actions checked for errors, didn't get one, and returned success.
So a helper removing a member, or a horse owner deleting a horse, got a green toast saying it worked while the row was still there. Deleting an invitation even wrote an entry to the activity log for a deletion that never happened. The fix is a small helper that checks the affected row count and treats zero as a failure. Something I should have had from the first delete.
Invitations had it worse, in the opposite direction. Accepting an invitation inserted a membership row with the invited user's own credentials, but the only INSERT policy on stable_members allowed the stable owner. The commit message that fixed it, on September 24th, says "no invitation was ever accepted" through that path. Acceptance now runs as a single database function that does the whole thing in one transaction. An earlier invitation bug, fixed in March, came from the same rewrite: when I rebuilt the schema in November 2025, the invitation token column lost its gen_random_uuid() default.
Enums, twice
Postgres enums are nice right up until they aren't in sync with your code.
The activity log categorises every action. The app mapped every racing action to the category 'racing'. The enum never had that value. Every racing log entry failed with an invalid input error, and the racing filter on the log page showed nothing, forever. Adding the value needed its own migration, because a new enum value can't be used in the same transaction that adds it, and you can never remove one again.
The second one hurt more. The equipment table from July created its type with a guard:
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'equipment_type') THEN
CREATE TYPE equipment_type AS ENUM (...);
END IF;Defensive, idempotent, very responsible. Except production already had an old enum called equipment_type from an earlier version, so the new values were never created. Saving a blanket, bridle or bit failed for every customer from July 7th until I found it on September 18th. Two and a half months, on the feature I'd built first when I came back. The guard that was supposed to make the migration safe was exactly what made it wrong.
Both fixes came with a test that rebuilds each enum from the migration files and compares it with the constants in the app. It's a dumb test. It would have caught both.
Two foreign keys, pointing at each other
In December 2025 I linked health entries and tasks, so a vet visit in the health journal could create a task and completing the task would update the journal. I did it with a foreign key in each direction: health_entries.linked_task_id pointing at tasks, and tasks.linked_health_entry_id pointing back.
PostgREST can't tell which relationship you mean when you embed one table in the other, so it refuses. Completing a health-linked task answered "task not found" and completed it as an ordinary task instead, without touching the journal. One of the two columns should never have existed. Now the embeds name the relationship explicitly, and a static test found nine other broken embeds of the same kind on the main branch.
Doing things twice
The rest falls under one heading: things that happen twice when they should happen once. A confirm dialog you could click again while the first request was still running. Completing a task a second time overwrote who had actually done it. Two people completing the same feeding task could both deduct the feed from inventory.
The pattern I ended up with is to move the write into a database function that takes a request id, with a unique index behind it:
ON CONFLICT (reported_by_user_id, create_request_id)
WHERE create_request_id IS NOT NULL DO NOTHINGScheduled jobs got the same treatment. In July I added a distributed lock in Redis around the cron routes, and nine of twelve use it today. But a lock in Redis is best effort: if Redis is down, the job runs without it. The line I wrote in a September migration is the one I should have started with: "That makes the database the guarantee, not withCronLock." The email queue now claims rows atomically in Postgres, and the lock is a second layer, not the only one.
The things I'm least proud of
Two findings from the September security pass belong in the category of "please don't do what I did". The storage bucket for horse photos could be listed with the public anon key, and the bucket for incident photos was public, with upload and delete open to any signed-in user in any stable. And sixteen SECURITY DEFINER functions could be called by anonymous users and never checked who was calling. One of them read from a table that no longer existed.
I wrote a verification matrix of 65 checks before touching anything: 33 holes and 31 things that must keep working. Before the fix, zero of the 33 holes were closed. After, all 33 were, and all 31 regressions still passed. Closing them took one migration. Finding them took finally looking.
How I got here
None of this was one bad decision. It was a series of reasonable-looking ones.
I treated the UI as the permission system. Every server action checked roles, so policies only needed to check membership. That's true until someone calls PostgREST directly, which on Supabase anyone with the anon key can. The database is the only layer that can't be skipped.
I rebuilt the schema in one go. The November 2025 rewrite was a reset-and-rebuild, and a rebuild quietly drops the things nobody wrote down: a column default, a policy nobody remembered, an old enum that survived in production but not in my head.
I optimised for the free tier. I've confessed this before. Fewer queries, fewer round trips, fewer functions. Some of the shortcuts that came with it, like trusting the client with a write instead of adding a database function for it, only looked cheap.
I didn't test as the user. The before and after matrices in September were the first time I ran every important write as each role against a real database and checked what actually happened to the rows. Most of the bugs in this post showed up the first time I did that.
What I'd do from day one
- Write every policy against a role, not just membership, even when there's only one role. Adding the second one is then a new policy, not an audit of fifty tables.
- Always write
WITH CHECKon UPDATE and INSERT policies, and ask whether the client should write the table at all. - Grant columns, not tables, on anything users can edit about themselves.
- Treat zero affected rows as an error.
- Put multi-step writes in database functions with an idempotency key. A double click is not an edge case.
- Test enums against the code, or skip enums and use a text column with a check constraint that's easier to change.
- Have a test that runs as each role and checks the rows, not the response.
- Put the functions next to the database.
Most of this is in Supabase's own documentation. I read it. What I didn't have was a reason to go back and check that I'd followed it, until a new role made me look at every table at once.
Horsemate is in better shape now than at any point since it started. The picture above has more lines than it should have, and there are still tables I'd merge if I were starting over. But the database now says no when it should, and that's the part that was missing.
Numbers come from Horsemate's git history and a production schema snapshot taken on September 28th, 2026. Counts of policies, revokes and functions are from the 41 migrations added since March 1st. The database image is anonymised: table and column names are replaced with bars, and no real data went into it.