Case study · 2026

DockTally

A commercial farm ran its packing floor on a paper form. Now it runs on a multi-tenant SaaS — and the farm is tenant #1, not the owner.

Role
Founder & sole developer
Timeline
Jun 2026 – present
Status
In daily production use
Type
Multi-tenant B2B SaaS
229
migrations

Every schema change versioned; none edited after it was applied.

62
edge functions

Billing, document ingest, notifications, and public endpoints.

75+
tables

All tenant-scoped, all row-level-security protected by default.

94k+
lines of TypeScript

iOS app and web portal, one shared type layer.

The problem

The record was a piece of paper.

Sweet Valley Produce logged every production day on Form 011 — a handwritten sheet where each row was a packing run and a cell like “9 / 8” meant two packers had made nine boxes and eight.

Form 011 · Daily packing logAug 12
RunField / lotBinsBoxesInit.
1N-1269 / 8M·J
2N-124 ¾11M
3S-033 ½7 / 5 / 6R·D·J
4S-032 ⅛4R
That cell is the entire per-packer record — María’s nine boxes and José’s eight, legible to whoever wrote it, invisible to every question asked later.

It worked, until someone needed to answer a question. Which field did this pallet come from? How much did that grower actually deliver last month? Which packer’s output is dropping? Every answer meant digging through a binder, and a buyer complaint had no traceable path back to a lot.

I spent a summer embedded with the operation as an F3 Innovate intern — on the dock, with the owners and the receiving staff — before writing the schema. That is why the data model mirrors the paper rather than replacing it with something tidier.

What shipped

The whole operation, end to end.

Five stages, one continuous chain of custody — a bin received in the morning is traceable through to the invoice it lands on.

  1. Receiving

    Inbound bins logged against a grower agreement — hauler, freight terms, quantity, quality, and the BOL number that ties it to the load.

  2. Packing

    One entry per row of the old paper form. Bins are a fraction picker (full, ¾, ½, ¼, ⅛) because that is how partial bins were actually recorded, and boxes are stored per packer so output-by-packer reporting is a group-by rather than a rewrite.

  3. Storage

    Lots tracked by bin with QR-coded tags, so a finished pallet resolves back to the field and the day it came from.

  4. Orders

    Public order intake, quote and negotiation, allocation against on-hand stock, dispatch notice, and proof of delivery.

  5. Invoicing

    Sequential document numbers, duplicate-invoice guards, Stripe billing, and an immutable audit log behind the whole chain.

How it’s built

Isolation is a database property, not a code convention.

The hard requirement in multi-tenant B2B software is that one customer can never see another’s data. Enforcing that in application code means every future query is a chance to get it wrong.

Row-level security is the boundary

Every domain row carries a farm_id, and Postgres row-level security policies — not the client — decide what a request can read or write. Reads filter to the caller’s farms; writes are additionally gated on their role. A query that forgets to scope itself returns nothing rather than someone else’s data.

New tables are locked by default

An ensure_rls event trigger enables row-level security in deny-all mode on any new table the moment it is created. A migration that adds a table without also adding policies produces a table nobody can read — the failure mode is a visibly broken feature instead of a silent data leak.

Isolation is proven, not assumed

supabase/tests/isolation_test.sqlstands up two farms and asserts that neither can read or write the other’s rows, that anonymous access is fully locked out, and that each role is held to its policy. A trimmed harness runs in CI, so a policy regression fails the build.

62 edge functions carry the side effects

Anything that touches the outside world runs server-side with a service role the client never holds: Stripe checkout, subscriptions, Connect onboarding and webhooks; document ingest, extraction and reconciliation through the Claude API; email via Resend, SMS, and push; and the public endpoints that let a buyer submit or track an order without an account.

The record is append-only where it matters

Compliance data is only worth keeping if it cannot be quietly rewritten. An immutable audit log, sequential document numbering, duplicate-invoice guards, edit markers, and a write guard on finalized production days mean corrections are recorded as corrections.

Stack

  • React Native (Expo)
  • TypeScript
  • PostgreSQL
  • Row-level security
  • Supabase
  • Deno edge functions
  • Stripe + Stripe Connect
  • Claude API
  • Resend
  • Expo push
  • Sentry
  • EAS build + OTA
  • GitHub Actions
Outcome

Sweet Valley Produce retired the handwritten log.

The operation runs its production days in DockTally from a phone at the dock. Yields, per-packer output, and grower settlements come out of the same records that used to live in a binder.

The architecture decision that mattered was refusing to build a single-farm app. Nothing in the schema treats Sweet Valley as special — it is simply the first tenant, which is why onboarding a second farm is a signup rather than a rewrite.