Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ jobs:

- name: Build
# Service clients initialize lazily, so `next build` needs no
# credentials or config placeholders. Firestore reads made during
# prerendering resolve empty (offline) when no project config is
# present, which is the intended CI behavior.
# credentials or config placeholders. Firestore reads during
# prerendering see no project config via hasFirebaseConfig() and
# return their empty fallbacks without touching Firebase — the
# intended CI behavior.
run: npm run build

- name: Install Playwright Chromium
Expand Down
40 changes: 22 additions & 18 deletions docs/TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,22 @@ client SDK writes (content management) or through Admin-SDK-backed API routes
with `dynamicParams = false`: only slugs known at build time exist, and
unknown slugs 404 without a render. A newly published beer becomes
reachable after the normal deploy-hook rebuild.
- **Static generation reads live Firestore.** `getBeers()` / `getVenues()` use
the Firebase *client* SDK, so `next build` performs real Firestore reads
against the configured project during prerendering. If reads fail (denied,
or no project configured at all — as in CI, which provides no env), the SDK
logs errors, falls back to offline mode, and the pages still build with
empty states — see `lib/beers.ts` and `lib/venues.ts` and the Firestore
`INVALID_ARGUMENT`/`permission-denied` warnings observed in build logs.
Beer detail pages add one safeguard (`getBeerStaticParams`): slug
enumeration uses `getDocsFromServer`, which never resolves from the
offline cache — a real backend failure (unavailable, permission-denied)
throws and fails the build rather than deploying zero beer pages, while a
genuinely empty public catalog is a valid successful result. Builds
without Firebase config skip the read entirely and generate no params.
- **Static generation reads live Firestore.** `getBeers()` / `getVenues()` /
`getBeerBySlug()` / `getBeerStaticParams()` (in `lib/beers.ts` and
`lib/venues.ts`) use the Firebase *client* SDK, so `next build` performs
real Firestore reads during prerendering. All of them share one read
contract, gated by `hasFirebaseConfig()` (`lib/firebase.ts`):
- **Unconfigured build** (CI, local without `.env.local`): the helper
returns its empty fallback (`[]` or `null`) without initializing
Firebase — credential-free builds are intentional.
- **Configured build:** the read uses `getDocsFromServer`, which never
resolves from the offline cache — plain `getDocs` can silently return
an empty cached result on backend failure, indistinguishable from a
legitimately empty collection. A genuinely empty public catalog is a
valid successful result, while a real backend failure (unavailable,
permission-denied) throws and fails the build rather than deploying an
empty catalog, an empty venue list, altered FAQ copy, or beer pages
that 404 until the next rebuild.
- **Node runtime.** The repository is normalized on **Node 24** (active LTS):
`.nvmrc` declares `24` and is the single source of truth — CI reads it via
`actions/setup-node`'s `node-version-file`, `package.json` declares
Expand Down Expand Up @@ -662,11 +665,12 @@ variables at all** — verified experimentally after the lazy-initialization
refactor (issue #18). Resend is only constructed at send time via
`getResendClient()` and Firebase client services only via `getFirebase*()`
first-use getters, so page-data collection evaluates no service clients.
Static generation still invokes the Firestore reads in `getBeers()` /
`getVenues()`, which fail fast against an unconfigured project
(`INVALID_ARGUMENT` warnings), fall back to offline mode, and resolve empty —
pages build with fallback/empty states. Typecheck/lint/tests need no env
either. No secrets or placeholder values exist anywhere in CI.
Static generation's Firestore reads (`getBeers()` / `getVenues()` /
`getBeerBySlug()` / `getBeerStaticParams()`) see no config via
`hasFirebaseConfig()` and return their empty fallbacks without
initializing Firebase, so pages build with empty states and no SDK
warnings. Typecheck/lint/tests need no env either. No secrets or
placeholder values exist anywhere in CI.

## 14. Testing and verification strategy

Expand Down
27 changes: 24 additions & 3 deletions lib/beers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { cache } from "react";
import {
collection,
getDocs,
getDocsFromServer,
query,
where,
Expand All @@ -18,22 +17,44 @@ function publicBeersQuery() {
);
}

/**
* Public beer catalog for build-time renders (homepage, /beers,
* /where-to-buy, sitemap). Same read contract as `getBeerStaticParams`:
* an unconfigured build returns `[]` without initializing Firebase, while
* a configured build reads via `getDocsFromServer` — never `getDocs`,
* which silently resolves from the offline cache on backend failure,
* indistinguishable from a legitimately empty catalog. A server read
* either returns the true result (a valid empty catalog included) or
* throws, failing the build rather than deploying an empty one.
*/
export async function getBeers(): Promise<Beer[]> {
const snapshot = await getDocs(publicBeersQuery());
if (!hasFirebaseConfig()) {
return [];
}
const snapshot = await getDocsFromServer(publicBeersQuery());
return snapshot.docs.map((doc) => doc.data() as Beer);
}

// React cache() dedupes the generateMetadata + page render reads for the
// same slug within a single render, so each beer page costs one Firestore
// read instead of two.
//
// Same read contract as `getBeers`: unconfigured builds return `null`
// without initializing Firebase; configured builds read from the server,
// so a backend failure throws (failing the build) instead of surfacing as
// a missing beer. A genuinely absent public slug still resolves `null`,
// which `notFound()` turns into a legitimate 404.
export const getBeerBySlug = cache(
async (slug: string): Promise<Beer | null> => {
if (!hasFirebaseConfig()) {
return null;
}
const q = query(
collection(getFirebaseDb(), "beers"),
where("slug", "==", slug),
where("isPublic", "==", true)
);
const snapshot = await getDocs(q);
const snapshot = await getDocsFromServer(q);
if (snapshot.empty) return null;
return snapshot.docs[0].data() as Beer;
}
Expand Down
23 changes: 20 additions & 3 deletions lib/venues.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { collection, getDocs, query, where, orderBy } from "firebase/firestore";
import { getFirebaseDb } from "@/lib/firebase";
import {
collection,
getDocsFromServer,
query,
where,
orderBy,
} from "firebase/firestore";
import { getFirebaseDb, hasFirebaseConfig } from "@/lib/firebase";
import type { Venue } from "@/lib/types";

/**
* Public venue list for /where-to-buy. Same read contract as the beer
* helpers in `lib/beers.ts`: an unconfigured build returns `[]` without
* initializing Firebase, while a configured build reads via
* `getDocsFromServer` so a backend failure throws and fails the build
* rather than silently rendering "no partner locations". A legitimately
* empty public venue list remains a valid result.
*/
export async function getVenues(): Promise<Venue[]> {
if (!hasFirebaseConfig()) {
return [];
}
const q = query(
collection(getFirebaseDb(), "venues"),
where("isPublic", "==", true),
orderBy("sortOrder", "asc")
);
const snapshot = await getDocs(q);
const snapshot = await getDocsFromServer(q);
return snapshot.docs.map((doc) => doc.data() as Venue);
}
123 changes: 0 additions & 123 deletions tests/lib/beers-static-params.test.ts

This file was deleted.

Loading
Loading