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 |
| 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.
Dawnlet never stores your name or email. Apple offers them on the very first authorization for an app, and only then; the name is kept on-device purely to say "Good morning, X" and is never sent anywhere. That has a real consequence, stated in the terms: lose the Apple ID, lose the data, because there's no other identifier to recover an account by.
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.