Planet4.Me docs
Open the app

#Data access (layer 2)

src/firebase/ initializes the SDK exactly once: app.ts reads VITE_FIREBASE_* (with emulator defaults so VITE_USE_EMULATORS=true is the only variable needed locally; authDomain is the page's own hostname on any hosted origin — several domains serve this app, and the OAuth handshake must stay first-party on each — falling back to the configured value on localhost, which serves no /__/auth/* helpers), firestore.ts is the single initializeFirestore call, storage.ts is the single Storage handle (null when no bucket is configured, and every photo affordance is then absent rather than dead), and auth.ts holds Google sign-in.

Persistence is persistentLocalCache with the multi-tab manager, so writes queue in IndexedDB and flush on reconnect, and CACHE_SIZE_UNLIMITED: a household's text is a few megabytes at the outside, and the only thing the default LRU could ever evict is the offline copy of something about to be needed in a shop with no signal. Against the emulator it drops to memoryLocalCache, because IndexedDB outlives the emulator and would resurrect deleted documents on the next run — VITE_EMULATOR_PERSISTENCE=true turns it back on for tests/e2e/offline-boot.mjs, the one test whose subject is the cache itself, which runs its own dev server to get it.

The transport is experimentalAutoDetectLongPolling by default and switches to experimentalForceLongPolling once a sticky localStorage latch is set (src/firebase/longPolling.ts, its own Firebase-free module so the set-once behavior is unit-testable). The bootstrap sets the latch after two consecutive hung reads — some phone networks hold a half-open stream the SDK's own detection never gives up on — and reloads exactly once; enableLongPollingFallback answers true only when the latch is NEWLY set, which is what makes a reload loop impossible on a storage that throws or drops writes.

src/data/ takes a Firestore as its first argument everywhere, so the same functions run in the app and under the emulator test suite. read.ts converts snapshots to the plain types in src/lib/types.ts, defensively: a document written by an older build still yields a usable object rather than crashing a screen someone is looking at in a shop. paths.ts holds every collection path. src/data/notes.ts keeps its own reader rather than adding to read.ts, following the newer collections (stores.ts, sessions.ts, suggestions.ts).

#Households

Signing in for the first time creates one household with one member — the solo household, first in users/{uid}.householdIds and marked shared: false. There is no onboarding. An account may belong to more: createSharedHousehold makes a second one from Settings (shared: true, under the name the person gave it), and joinHousehold enrolls this account in someone else's by invite (see "Security rules" for the branch that admits it). Both append to the back-pointer, and the back-pointer is only ever appended to: ensureHouseholds returns the whole list in join order, and subscribeHouseholdIds keeps it live so a household started or joined on another device arrives as a new card rather than at the next cold start. ensureHousehold is the solo id alone — the shape every emulator suite boots with. The user document also carries tones, this account's colour per workspace (readTones, total; see "A workspace's colour is the account's own" under Screens). The household document also carries the invite (inviteCode, inviteExpiresAt, both null when none is live), minted by createInvite from sixteen random bytes and cleared by revokeInvite; the code's shape and the link that carries it live in src/lib/households/invite.ts. households/{hid}/members/{uid} ({displayName, joinedAt}) is written by every way in — bootstrap, starting, joining — and read by subscribeMembers for the Members screen; memberUids on the household stays the authorization record. The ways out: leaveHousehold (a joiner; one batch — the member document first, while the subcollection rule still sees a member, then arrayRemove on the household, then the person's own back-pointer and colour) and removeMember (the owner; the household and the member document). The removed person's user document is theirs alone, so subscribeHouseholds reports a household that refuses this account (permission-denied) and the hook answers with forgetHousehold on its own record, which flows back through the live listener; the refusal is membership, not the path, so forgiveListenerErrors clears the watchdog when the household on screen changes. The back-pointer therefore grows and shrinks; the solo household is never left, so index 0 never moves. An assistant thread left behind stays unreadable.

The lookup reads users/{uid}.householdIds rather than querying households by membership: a collection query is rejected by the rules, because membership is a property of each document and the rules cannot evaluate a query that might return one the caller may not read. The back-pointer exists so this is a single document the user always owns.

Creation is two writes, not one batch. The subcollection rule calls isMember(), which get()s the household — and inside a batch that read sees the pre-batch state, where the household does not exist yet, so the member document is denied on a null dereference and takes the whole batch with it. The household lands first; the member document and the back-pointer follow together.

The boot reads run under a 12-second deadline (useHousehold): a read that REJECTS as unavailable retries every 5s as it always did, while a read that HANGS counts a strike and retries after 1s — two consecutive strikes while the browser reports online set the long-polling latch above and reload once. An abandoned timed-out attempt keeps running; ensureHouseholds takes a superseded guard, checked before each create write, so a late success can never fork a second household under a live attempt. The fresh users/{uid} write merges and omits householdIds entirely — only arrayUnion ever creates the field, so a wrongly-empty read can never erase the back-pointer, and the live listener ignores an empty snapshot for the same reason. useHousehold returns the list (hids), the household on screen (hid: the one the shell asked for when the account belongs to it, else the solo one) and every household document that has answered (households, for the door cards); hid stays null until the list is known, so the boot screen holds and nothing downstream can act on a guess. While hid is null the boot screen explains itself: on unreachable it names the likely phone causes and offers "Try again now"; offline it says setup will continue on its own.

Both boot reads go to disk first (src/data/cacheFirst.ts). The user document and the household ids it names are facts that do not go stale once written — the list is only ever appended to — and they gate every subscription in the app — so a returning phone answers them from IndexedDB and opens at once, instead of holding the whole app shut behind a round trip it does not need. getDoc is not that read: its cache fallback waits until the SDK concludes it cannot reach the server, which is fast when a request is refused and slow — the full twelve-second deadline, then again on every retry — when requests hang, which is what a captive portal, a filtering proxy and a half-open mobile connection all look like. It has to be BOTH reads: ensureUserDoc awaits in front of ensureHouseholds, so one of them alone still leaves the boot shut. The server read still runs, awaited by nothing, purely so the watchdog hears an answer.