Add free-tier Supabase backups (structure + data) on a daily launchd job - #25
Closed
PrernaWeb wants to merge 3 commits into
Closed
Add free-tier Supabase backups (structure + data) on a daily launchd job#25PrernaWeb wants to merge 3 commits into
PrernaWeb wants to merge 3 commits into
Conversation
The free plan has no managed backups — no daily snapshots, no PITR — but nothing prevents dumping the database ourselves. At 11 MB that is cheap. scripts/backup-supabase.sh runs pg_dump over the session pooler and writes per-run gzipped schema-public / data-public / auth-users, plus meta.txt and SHA256SUMS, to iCloud with 90-day retention. auth sessions, refresh tokens and one-time tokens are deliberately excluded: they are short-lived and restoring them into a new project would be actively wrong. Verified by restoring into a throwaway postgres:17 container rather than by reading the dump: row counts match production exactly (127/127/897/8), auth.users and auth.identities 8 each, RLS enabled on all four tables, the "own row" policy present, the FK to auth.users(id) ON DELETE CASCADE intact, and usage_events_id_seq preserved at 927. Two constraints worth recording, both hit during setup: - pg_dump needs the pooler in session mode (5432). Transaction mode (6543) does not work. - macOS TCC blocks launchd-spawned processes from reading ~/Documents, so a job pointed at the repo copy dies with exit 126 before running a line. The runnable script is installed to ~/Library/Application Support/CoachSage and logs to ~/Library/Logs/CoachSage. The repo copy stays the source of truth, so install-backup-launchd.sh must be re-run after editing the script. Backups contain user PII (emails, password hashes, auth identities) and sync to iCloud by design. Also records a backlog item to replace this with environment-wired Supabase DBs and managed backups once we move to a paid plan, and notes that public.usage_events exists in production with no file in backend/sql/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The launchd job was installed and verified before the PR existed, so its lifecycle is independent of the merge. Recording that explicitly, along with the uninstall path, so nobody assumes reverting the PR stops the backups. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The table has been live since the S6 log-drain fix with no file in backend/sql/, while both rate_limit_events.sql and analytics_events.sql describe themselves as matching "the existing usage_events table's access model" — referring to a file that did not exist. DDL recovered from the verified pg_dump rather than reconstructed from memory, then checked: applied to a clean postgres:17 container and diffed information_schema.columns against production. All 8 columns match on type, nullability, default and identity; RLS on, PK on id, no other index. Nothing changed in production — this is documentation catching up. Two properties recorded rather than silently corrected: - user_id is text, not a uuid FK to auth.users. Account deletion anonymizes these rows to a shared deleted:<uuid> to preserve the aggregate billing/abuse trail; an ON DELETE CASCADE FK would delete exactly the evidence the table exists to keep. - Production has no index here, unlike the other two event tables. Noted with the obvious fix if it grows, but adding one is a schema change and belongs against the database first, per user_state.sql's rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PrernaWeb
added a commit
that referenced
this pull request
Jul 29, 2026
…en, and reprice (#26) * Add free-tier Supabase backups (structure + data) on a daily launchd job The free plan has no managed backups — no daily snapshots, no PITR — but nothing prevents dumping the database ourselves. At 11 MB that is cheap. scripts/backup-supabase.sh runs pg_dump over the session pooler and writes per-run gzipped schema-public / data-public / auth-users, plus meta.txt and SHA256SUMS, to iCloud with 90-day retention. auth sessions, refresh tokens and one-time tokens are deliberately excluded: they are short-lived and restoring them into a new project would be actively wrong. Verified by restoring into a throwaway postgres:17 container rather than by reading the dump: row counts match production exactly (127/127/897/8), auth.users and auth.identities 8 each, RLS enabled on all four tables, the "own row" policy present, the FK to auth.users(id) ON DELETE CASCADE intact, and usage_events_id_seq preserved at 927. Two constraints worth recording, both hit during setup: - pg_dump needs the pooler in session mode (5432). Transaction mode (6543) does not work. - macOS TCC blocks launchd-spawned processes from reading ~/Documents, so a job pointed at the repo copy dies with exit 126 before running a line. The runnable script is installed to ~/Library/Application Support/CoachSage and logs to ~/Library/Logs/CoachSage. The repo copy stays the source of truth, so install-backup-launchd.sh must be re-run after editing the script. Backups contain user PII (emails, password hashes, auth identities) and sync to iCloud by design. Also records a backlog item to replace this with environment-wired Supabase DBs and managed backups once we move to a paid plan, and notes that public.usage_events exists in production with no file in backend/sql/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: link the backup work to PR #25, note the job is live regardless The launchd job was installed and verified before the PR existed, so its lifecycle is independent of the merge. Recording that explicitly, along with the uninstall path, so nobody assumes reverting the PR stops the backups. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Write down the usage_events schema, which only existed in production The table has been live since the S6 log-drain fix with no file in backend/sql/, while both rate_limit_events.sql and analytics_events.sql describe themselves as matching "the existing usage_events table's access model" — referring to a file that did not exist. DDL recovered from the verified pg_dump rather than reconstructed from memory, then checked: applied to a clean postgres:17 container and diffed information_schema.columns against production. All 8 columns match on type, nullability, default and identity; RLS on, PK on id, no other index. Nothing changed in production — this is documentation catching up. Two properties recorded rather than silently corrected: - user_id is text, not a uuid FK to auth.users. Account deletion anonymizes these rows to a shared deleted:<uuid> to preserve the aggregate billing/abuse trail; an ON DELETE CASCADE FK would delete exactly the evidence the table exists to keep. - Production has no index here, unlike the other two event tables. Noted with the obvious fix if it grows, but adding one is a schema change and belongs against the database first, per user_state.sql's rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Stop the founding offer firing for every new customer, and floor plan regen Two independent problems, both found while pricing the app against real usage_events data, and both invisible from the running app. FoundingOfferEligibility counted AccountManager.shared.isSignedIn as evidence of pre-paywall use. But RunCoachApp.rootView renders WelcomeView, not ContentView, unless the account is signed in — and ContentView is the only caller of evaluateOnce(). The clause was unconditionally true in production: every new App Store customer would have signed in, finished onboarding, been declared a founding member and handed lifetime Premium for $0.00. The paywall could never have earned a cent. testFreshUserIsNotEligible passed the whole time only because nothing is signed in under XCTest. The suite had a hole exactly the shape of the branch production always took. The gate is now AppTransaction.originalAppVersion <= 82, parsed as a leading integer so TestFlight and sandbox's "1.0" passes while a real download's "83" does not, and failing closed on an unreadable receipt — handing lifetime Premium to a paying customer is the expensive direction to be wrong in, and a genuine founder caught by it is repaired with a server-side tier grant. lastPrePaywallBuild must be checked at every archive; it is correct only while the next App Store build is 83. Separately, refreshWeeklyPlanIfNeeded had a cooldown on failure and none on success, so any condition that made isStoredWeeklyPlanFresh return false on every pass regenerated the plan on every loadData() — unbounded, at Sonnet prices, and silently, because each call succeeded. Measured: 222 generations from ~5 real accounts over 16 days, worst account-day 33, 79% of all Anthropic spend in the window against three paid routes costing $1.04 between them. HomePolicy.mayGenerateWeeklyPlanAutomatically adds a 30-minute floor; manual regeneration is exempt by design. Daily rate limits drop 60->15 (free) and 500->60 (Premium). They exist as a spend brake and at the old numbers did not brake: a capped-out free user cost ~$54/month and a Premium user ~$450/month, both multiples of the subscription. The heaviest genuine account averaged ~8 calls/day. Prices move to USD $12.99 / $79.99 (ASC base is USD, confirmed). Full reasoning, market comparison and margin maths in backlog.md. Verified: 468/468 iOS tests, backend suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the abandoned screenshot capture and the re-run command Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: correct the screenshot note — the capture worked, only the price is stale Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: prices live in ASC at USD 12.99/79.99, verified across 175 territories Applied via iris. Two traps worth keeping: POST /subscriptionPrices sets one territory and does not re-derive the rest, and /equalizations excludes the base territory, so a check built from it reported 0 mismatches while the US annual was still at $59.99. AU derives at 19.99/129.99 (x1.50/x1.625), not the x1.3 the plan estimated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Promise founders Premium explicitly, and upload the repriced review screenshots The founding sheet said Premium was theirs forever but not that Premium is what it is — pro.lifetime maps to .premium, not to every tier we might ship. Saying so before anyone claims turns a future Pro launch into an announcement rather than an argument about what a founder was promised. Changed in both the view and Localizable.xcstrings: the catalog carries the same literal and wins at runtime, so a source-only edit would have been invisible. Review screenshots for pro.monthly and pro.annual replaced in ASC with the capture at the live $12.99/$79.99 prices, both COMPLETE. 468/468 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix the founder cutoff at 1 so it can never be bumped into the bug again The cutoff means "first download <= N => founder". The previous value (82) was correct but carried a warning to re-check it at every archive, which is exactly the wrong instinct: raising it to the build being released makes every launch-day customer a founding member — the bug the gate exists to close. 1 is not a placeholder, it is the correct value and needs no maintenance. TestFlight and the StoreKit sandbox report originalAppVersion as "1.0", never a build number, so the whole genuine cohort parses to 1; the app has never shipped to the App Store, so no legitimate pre-paywall App Store download exists; and a real download reports its actual CFBundleVersion, which can never fall under 1. Both directions are now pinned. testNoRealAppStoreBuildNumberCanEverQualify fails with a message naming the consequence if anyone raises it; testTheRealFoundingCohortStillQualifies stops it being lowered into uselessness. 470/470 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: map why the IAP review attach needs the app version first Traced through iris: reviewSubmissionItems rejects IAP relationships, inAppPurchaseSubmissions and subscriptionSubmissions both 409 with a state error, submitWithNextAppStoreVersion is read-only, and appStoreVersions has no IAP relationship at all. Products associate with a version by co-membership in the same reviewSubmission, and the flag derives from that. Stopped before staging 1.0 into the submission — that is a release decision, not a side effect of re-attaching an IAP. Also marks the founder grant and screenshot re-capture done, and renumbers the open-actions list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: sandbox round-trip runbook, and the founder-grant trap it has to avoid setTier overwrites subscription_tier AND expires_at unconditionally, so running the round-trip on any of the 8 founder accounts replaces the permanent grant with the sandbox subscription's expiry — silently demoting a founder to free minutes later. The runbook leads with that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Draft build 83 tester notes, and stop rewriting build 66's The price edit earlier today landed inside the build 66 'What to Test' block, retroactively changing a record of what testers were actually told at the time. Reverted, with a note on the block saying not to update it — historical notes are a record, not a live document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Owner
Author
|
Superseded — these commits landed via #26. |
PrernaWeb
added a commit
that referenced
this pull request
Jul 29, 2026
…ator (#28) * docs: the flake discriminator was wrong again — same-SHA is the only airtight one 'Same test failing = real' produced a false alarm within hours of being written: testSwitchingToCoachTabShowsChatScreen failed twice on PR #27, but ebd726b passed 6/6 under push and failed under pull_request. Identical SHA, both outcomes — code cannot be simultaneously broken and not broken. The two failures also failed differently (infrastructure error vs assertion), where a real bug fails the same way. Both heuristics recorded here have now produced false alarms. The only rule that holds is: does the same SHA produce different results? Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Automate the attached-build check; the standing note did not work Uploading a build does not repoint the app version at it — separate objects, nothing reconciles them, silent until review. It has fired three times: 1.0 on build 65 from before the paywall existed, then 1.0 still on 82 after 83 was uploaded the same evening the standing 'remember to check' note was written. A note that needs a human to look, at the point in a release where attention is lowest, is not a control. scripts/check-attached-build.mjs asserts every editable version points at the newest VALID unexpired build and prints the remediation PATCH when it does not. Deliberately not on push/pull_request: it reads external ASC state, so it would go red during the normal window between upload and repoint, and no commit can change the answer. workflow_dispatch for after an upload, plus a daily schedule for when the manual run is what gets forgotten. Three things verified rather than assumed: the ES256 signature must be raw R||S via dsaEncoding ieee-p1363 (64 bytes; node's default DER is 71 and is silently rejected), build comparison must be numeric or 9 sorts above 83, and main() has to be guarded behind a direct-execution check or importing the module to test it runs the whole thing. Needs ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY as repo secrets before the schedule can pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: 83 is live to testers; record what the automation still needs State block for 2026-07-30. Both of 2026-07-29's time-boxed risks are closed (the build-82 regeneration window, and PR #25), and the tester notes are marked issued rather than drafted. Two things flagged explicitly because they can rot silently: the two unpushed commits on this branch, and the three ASC secrets the new scheduled workflow needs. Without those secrets it goes red every night, which makes it exactly as useless as the standing note it replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: gitignore App Store Connect .p8 keys The new attached-build check needs an ASC API private key on the machine that runs it. Apple lets you download the .p8 exactly once and it grants API access to the whole team, so a stray copy in this working tree would be both unrecoverable and public. Belt and braces before anyone downloads one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The Supabase free plan has no managed backups — no daily snapshots, no PITR. It does not prevent dumping the database ourselves, and at 11 MB that is cheap. Right now there is exactly one database and it is production: every migration and eval run lands on the same 8 real users' data, with no way back.
What
scripts/backup-supabase.shrunspg_dumpover the session pooler and writes, per run, gzippedschema-public/data-public/auth-usersplusmeta.txtandSHA256SUMSto iCloud, with 90-day retention (~52 KB per run).scripts/install-backup-launchd.shinstalls the daily 03:30 launchd job. It is installed and currently exiting 0.auth sessions, refresh tokens and one-time tokens are deliberately excluded — they are short-lived, and restoring them into a new project would be actively wrong.
Verification
Restored into a throwaway
postgres:17container rather than eyeballing the dump (auth schema scaffolded first, as a real target would have it):auth.usersandauth.identities, 8 eachown rowpolicy presentauth.users(id) ON DELETE CASCADEintactusage_events_id_seqpreserved at 927Two constraints worth knowing
pg_dumpneeds the pooler in session mode (5432). Transaction mode (6543) does not work.~/Documents, so a job pointed at the repo copy dies with exit 126 before running a line. The runnable script is installed to~/Library/Application Support/CoachSage, logs to~/Library/Logs/CoachSage. The repo copy stays the source of truth — re-runinstall-backup-launchd.shafter editing the script, or the job keeps running the old version.Notes
brew install supabase/tap/supabase libpq; libpq is keg-only, so the plist setsPATHexplicitly.public.usage_events(897 rows) exists in production with no file inbackend/sql/. Not fixed here.🤖 Generated with Claude Code