feat: native foundation (shell, auth, SQLite, sync engine) #1

Merged
eliott merged 17 commits from feat/foundation into main 2026-10-15 19:33:47 +02:00
Owner

Native foundation

Turns the scaffold into an app that signs in, restores its session offline, owns a local SQLite mirror of the synced entities, and runs a sync engine that converges with rollnote-api's /sync/* endpoints (API branch feat/sync-support).

Spec: docs/superpowers/specs/2026-10-13-native-foundation-design.md. Plan: docs/superpowers/plans/2026-10-13-native-foundation.md.

What is in

  • Environments, router and native HttpClient providers, three-tab shell (Shoots, Settings, Account).
  • Better-Auth client with the Expo plugin, bearer interceptor (one sign-out per burst of 401s), offline session restore, login and 2FA screens (TOTP and email OTP), route guards, forgot-password link to the web.
  • Drizzle schema for the 11 synced entities plus sync_outbox, sync_meta, sync_error, changelog_cache; migration 0000 bundled through Metro and applied synchronously before the first render; the same bundle runs in tests on better-sqlite3.
  • Entity repositories that stamp updatedAt from the server-aligned clock and write the outbox in one transaction; outbox coalescing, sentAt tracking, stamp-guarded resolve, orphan pruning.
  • Typed /sync client with URL-encoded ISO cursors and a 60 s timeout.
  • Sync engine: status, clock offset, resync with a keep-set for pending rows, push in batches of 200 with every result status handled atomically, pull per entity with last-write-wins and the pending-edit guard, tombstones, backoff, follow-up runs for writes made mid-cycle, session-generation abandonment.
  • Triggers (start, foreground, network reachable, 2 s after a local write, manual), sync indicator with an error sheet, debug screen with table counts and a test insert, sign-out that wipes the database with an owner check and a confirm when changes are pending.
  • CI workflow (lint, format check, typecheck, tests on the docker runner, run-ci label gate), README, version 0.2.0.

Verification

  • 157 Vitest tests, lint, typecheck and format:check clean.
  • Device acceptance on the Pixel 7 AVD against the sync-branch API: fresh install offline opens on login; sign in with username, password and TOTP; relaunch keeps the session; seeded gear and shoot land in SQLite after one cycle (16 tables); a folder inserted in airplane mode is pushed about 20 s after reconnect and visible through GET /folder; sign out asks before dropping a pending change, wipes every table and the secure store, and re-sign-in repopulates.

Notes

  • Migration 0000 was regenerated during this branch; a dev device that installed a build before e4de58f must clear app data once. From this merge on, migrations are append-only.
  • Deferred to later sub-projects: theme tokens and Lucide icons, the indicator's placement, release gating of the debug screen, usesCleartextTraffic per build profile, Android build in CI and the Maestro flow.
  • Dev builds load the JS bundle from Metro; the offline cold start works through adb reverse (release builds embed the bundle).

https://claude.ai/code/session_013Ew5mX388EkuWC6ytSKkdc

## Native foundation Turns the scaffold into an app that signs in, restores its session offline, owns a local SQLite mirror of the synced entities, and runs a sync engine that converges with `rollnote-api`'s `/sync/*` endpoints (API branch `feat/sync-support`). Spec: `docs/superpowers/specs/2026-10-13-native-foundation-design.md`. Plan: `docs/superpowers/plans/2026-10-13-native-foundation.md`. ### What is in - Environments, router and native HttpClient providers, three-tab shell (Shoots, Settings, Account). - Better-Auth client with the Expo plugin, bearer interceptor (one sign-out per burst of 401s), offline session restore, login and 2FA screens (TOTP and email OTP), route guards, forgot-password link to the web. - Drizzle schema for the 11 synced entities plus `sync_outbox`, `sync_meta`, `sync_error`, `changelog_cache`; migration 0000 bundled through Metro and applied synchronously before the first render; the same bundle runs in tests on better-sqlite3. - Entity repositories that stamp `updatedAt` from the server-aligned clock and write the outbox in one transaction; outbox coalescing, `sentAt` tracking, stamp-guarded resolve, orphan pruning. - Typed `/sync` client with URL-encoded ISO cursors and a 60 s timeout. - Sync engine: status, clock offset, resync with a keep-set for pending rows, push in batches of 200 with every result status handled atomically, pull per entity with last-write-wins and the pending-edit guard, tombstones, backoff, follow-up runs for writes made mid-cycle, session-generation abandonment. - Triggers (start, foreground, network reachable, 2 s after a local write, manual), sync indicator with an error sheet, debug screen with table counts and a test insert, sign-out that wipes the database with an owner check and a confirm when changes are pending. - CI workflow (lint, format check, typecheck, tests on the `docker` runner, `run-ci` label gate), README, version 0.2.0. ### Verification - 157 Vitest tests, lint, typecheck and `format:check` clean. - Device acceptance on the Pixel 7 AVD against the sync-branch API: fresh install offline opens on login; sign in with username, password and TOTP; relaunch keeps the session; seeded gear and shoot land in SQLite after one cycle (16 tables); a folder inserted in airplane mode is pushed about 20 s after reconnect and visible through `GET /folder`; sign out asks before dropping a pending change, wipes every table and the secure store, and re-sign-in repopulates. ### Notes - Migration 0000 was regenerated during this branch; a dev device that installed a build before e4de58f must clear app data once. From this merge on, migrations are append-only. - Deferred to later sub-projects: theme tokens and Lucide icons, the indicator's placement, release gating of the debug screen, `usesCleartextTraffic` per build profile, Android build in CI and the Maestro flow. - Dev builds load the JS bundle from Metro; the offline cold start works through `adb reverse` (release builds embed the bundle). https://claude.ai/code/session_013Ew5mX388EkuWC6ytSKkdc
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Login (Signal Forms) with a forgot-password link to the web, a 2FA
screen taking an authenticator or emailed code, authGuard on the shell
and anonymousGuard on login and login/2fa. The session is restored by an
app initializer, and by the guards while it is still unknown. A 401
signs out with an expired reason the login screen shows, and the shell
returns to login when the session ends. Adds AuthService.sendOtp and
@angular/forms 22.2.2.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
SyncService runs one guarded cycle: /sync/status (clock offset, resync
wipe when the cursor is older than resyncBefore), push of the outbox in
batches with per-status handling, paged pull of every entity and of the
tombstones with last-write-wins guards, then the cursor moves. Network
errors leave everything for the next trigger, a 401 signs out quietly,
5xx and other failures set the error state and back off (30 s, 2 min,
10 min) unless a cycle is forced. FakeSyncApi implements the contract
in memory for the tests.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
The resync wipe now keeps every row with a queued upsert and the parents
it needs (the shoot of a frame, the frame and shoot of a location), so
unpushed writes stay visible and their ops never become orphans that a
later remove() prunes.

Each push result is settled in one transaction (resolve, renumber,
sync_error), and stale records are applied after the pull, when the
parents they reference are there. A stale record wins over the local
stamp but not over a pending edit or a later version the pull wrote.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
SyncTriggers, provided by the Shell so it lives only while signed in,
keeps SyncService.online on the network status and runs a cycle on
start, when the app returns to the foreground, when the network is
reachable again, and 2 s after the last local write (Outbox.version).
Every trigger is a no-op unless the session is signed in.

The sync indicator above the tabs shows the state and the pending
count; pressing it forces a cycle and, after a failure or with refused
ops, opens a sheet with the details and a Clear for sync_error rows.
The Account tab gets its own stack with a debug screen: row counts,
sync_meta, outbox and sync_error counts, Sync now, Insert test folder.

Sign-out now ends with SessionWipe: wait for the cycle in flight, delete
and recreate the database, then reset the sync service's state.
SyncService registers itself as the wipe hook, so AuthService does not
inject it, and no longer awaits the sign-out it starts on a 401, which
would wait on its own cycle.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
sync_meta gets an ownerId (migration 0000 regenerated, nothing has
shipped). Sign-in and restore claim the database for the user first:
one owned by another account, or holding data with no owner (an app
killed mid sign-out), is wiped before the session starts, so no one
sees or pushes another account's rows. Sign-in and the second factors
wait for a pending sign-out.

SyncService.cycle() does nothing unless signed in, and a cycle started
before a wipe is abandoned at its next step (a generation counter
bumped by the wipe), writing no cursor or state.

SessionWipe.run() never throws: failures land in lastError (shown on
the debug screen), the data stays, and the next sign-in's claim
finishes the wipe. The device provider reopens the database even when
its delete fails.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
- Account screen: a Sign out button, disabled while it runs. With
  unsynced changes it asks first (Dialogs.confirm, destructive); cancel
  keeps the session.
- endSession(): the API sign-out is aborted after 5 s through the
  Better-Auth client's fetchOptions.signal; the device sign-out goes on.
- expireSession(): a 401 during a deliberate sign-out no longer shows
  "Your session expired".
- signIn(): the token set by a twoFactorRedirect response (a session the
  API deletes) is forgotten, neither stored nor sent with the verify.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
- push: an applied or renumbered upsert stamps a never-synced row with
  the cycle's serverTime (a stale one with its record's syncedAt), so a
  later edit plus delete is pushed instead of cancelled as an offline
  create. The '' sentinel is now NEVER_SYNCED in schema.ts.
- SyncService: a cycle() call during a run schedules one more run when
  it ends, under the same online, backoff and signed-in rules. Ops left
  queued (retry, missing-parent) do not loop.
- SyncApi: status, pull, tombstones and push fail after 60 s without an
  answer, as a status-0 HttpErrorResponse: the engine goes offline and
  keeps the outbox and the cursor.
- pull: a tombstone of an entity the app does not know is skipped.

Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
chore: format check in CI, document guards and append-only migrations
All checks were successful
tests / test (pull_request) Successful in 1m21s
7e4c2f5e51
Claude-Session: https://claude.ai/code/session_011SaUaSqpvGKRqtNaT7DDQD
eliott merged commit 3a28d736dc into main 2026-10-15 19:33:47 +02:00
eliott deleted branch feat/foundation 2026-10-15 19:33:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
run-ci
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
RollNote/rollnote-app!1
No description provided.