#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.