Data & sync
Every table in Dawnlet belongs to exactly one account, and the database enforces that rather than the app. This page covers how that's set up, what signing up creates, and the one operation that genuinely needs privileged code.
§The model
One table per concept, and a join table wherever something can carry several tags. Categories and tags are shared across modules rather than duplicated per module — a movie, a transaction and a todo all reference the same category rows.
| Area | Tables |
|---|---|
| Taxonomy | categories, tags, and a *_tags join table per module |
| Day-to-day | events, recurring_events, todos, todo_schedules, todo_occurrences, habits, habit_entries |
| Bucket list | movies, books, places, plus their tag and contact joins |
| Money | transactions, budgets, recurring_transactions, recurring_transaction_schedules |
| Time | activities, time_sessions |
| People & small things | contacts, meals, gratitudes |
| Journal | journal_entries, journal_entry_tags |
| Planning | plan_my_week_completions, plan_my_month_completions |
| Notifications | notification_log, notification_preferences |
| Settings | user_settings — one row per account, one column per scalar preference |
The schema is managed as an ordered set of migration files, applied in sequence. There's no hand-editing of a live database: a change is a new migration, which is what makes it possible to rebuild the whole schema from scratch and get the same result.
1Row-level security
Every table follows the same template, without exception:
- A
user_idcolumn referencing the authenticated user, with cascade delete — so removing the account removes everything pointing at it, with no cleanup script to forget to run. - Row-level security enabled on the table.
- One policy per operation — select, insert, update, delete — each scoped to
"
user_idequals the currently authenticated user". - An explicit grant of those four operations to the authenticated role.
The effect is that authorization isn't a thing the app does. A query with no session, or one from the wrong account, doesn't return someone else's data and doesn't need the client to filter it out — the rows simply aren't visible.
Every table here holds private, sign-in-required data. There is no anonymous read case at all, so the anonymous role is granted nothing anywhere. A public-read table would be a deliberate, separate decision — not something a new table inherits by default.
2The grant that bites
Point 4 above looks redundant next to correct policies, and it isn't. Postgres checks table privileges before row-level security gets a chance to filter anything. Without the grant, a perfectly-scoped policy never runs, and every request fails with a permission error instead.
This project hit exactly that: policies written correctly, every request failing, the policies looking innocent because they were. It took a follow-up migration adding the missing grants to fix, and it's now part of the template rather than something to remember.
The general lesson is worth more than the specific fix: when a security mechanism has two independent layers, verifying one of them proves nothing about the other.
3Authentication
Sign in with Apple only. Apple's identity token is handed straight to Supabase's native ID-token exchange — no backend of ours sits in the middle, and no token is minted by us. Supabase verifies the token's audience claim against the app's bundle identifier.
The session lives in the Keychain, managed by the auth client rather than a hand-rolled token store. A background loop watches for session changes and keeps the database client's credentials in step, so every query is authenticated as the current account without any call site thinking about it.
Two pieces of identity are stored, and no more. The email arrives in Apple's identity token and Supabase Auth records it — if you used Hide My Email it's a private relay address, which the app has no way to see past. The name is only offered by Apple on the very first authorization for an app, ever; when it's offered, it's written to the auth user's metadata so it survives a reinstall, rather than living only on the device where it would be lost for good.
Neither is used to identify you — user.id is the identifier — and neither is used
for email, marketing or analytics, of which there is none. That leaves the consequence stated in
the terms intact: lose the Apple ID, lose the data. Holding a relay address
is not a recovery path, and Dawnlet doesn't pretend otherwise.
4What signing up creates
An empty app is a bad first impression, and a set of default categories is genuinely useful. So a database trigger fires when an account is created and seeds it — categories, tags, a few starter habits, and a short list of todos, several of which walk you through setting the app up.
Two details that make this less trivial than it sounds:
- It runs with elevated rights, because it writes to application tables from a trigger on the account table. That's a deliberate, tightly-scoped exception, not a general pattern.
- Seeded dates are computed carefully. Using the server's idea of "today" means someone signing up in a timezone behind the server can be handed todos due "yesterday". The seed anchors to the earliest timezone on earth so that never happens.
5Deleting an account
This is the one operation that can't happen client-side. Deleting the underlying account record requires a privileged key — precisely the key that must never ship inside an app, because anyone can extract strings from a binary.
So it runs as a small server-side function instead. It verifies the caller's own session, establishes which account that session belongs to, and deletes that account. It takes no account identifier as input, which is what stops it from being a way to delete someone else's data.
Everything else follows from the cascade delete on every table: remove the account row and all content referencing it goes with it. There's no per-table cleanup list to keep current as modules are added.
No soft delete, no thirty-day grace period, no recovery. The app confirms first, twice, and then it's gone.
6Offline, and what syncs
Dawnlet is honest about this rather than claiming offline support it doesn't have: creating and editing data needs a connection, because the database is the single source of truth and the app doesn't keep a local mirror to reconcile later.
Three things deliberately don't depend on the network:
What stays on the device entirely, and never reaches the database at all: Apple Calendar events, Reminders, and your address book. Those are read and written through the system frameworks on your phone. The one exception is a contact's name when you tag it onto something — at that point it's content you typed into an item, and it's stored with that item.