Compare commits

..
4 Commits
Author SHA1 Message Date
kpcto 2d5c77e21d Merge pull request '[E01-S01-T05] ADR: Kysely containment' (#411) from feature/192 into main
CI / Stage 4 — Unit tests (E00-S05-T01) (push) Successful in 1m35s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (push) Successful in 1m1s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (push) Successful in 45s
CI / Stage 5 — Architecture tests (E00-S05-T01) (push) Successful in 3m8s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (push) Successful in 1m8s
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (push) Successful in 45s
CI / Stage 2 — Typecheck (E00-S05-T01) (push) Successful in 1m9s
Reviewed-on: #411
2026-08-31 01:11:59 +00:00
kpcto 893707604d Merge pull request '[E01-S01-T06] ADR: React SSR' (#412) from feature/193 into main
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (push) Successful in 45s
CI / Stage 5 — Architecture tests (E00-S05-T01) (push) Successful in 3m8s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (push) Successful in 1m8s
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (push) Successful in 46s
CI / Stage 2 — Typecheck (E00-S05-T01) (push) Successful in 1m9s
CI / Stage 4 — Unit tests (E00-S05-T01) (push) Successful in 1m35s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (push) Successful in 1m11s
Reviewed-on: #412
2026-08-31 01:11:44 +00:00
implementer 693976c289 docs(adr): record Kysely containment decision as ADR-006
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 44s
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Successful in 1m9s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 1m34s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Successful in 1m2s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m9s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Successful in 3m8s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 45s
2026-08-31 00:55:38 +00:00
implementer b6c3fd9a28 docs(adr): record React SSR for public rendering decision as ADR-007
CI / Stage 6 — PostgreSQL integration tests (E00-S05-T01) (pull_request) Successful in 1m14s
CI / Stage 4 — Unit tests (E00-S05-T01) (pull_request) Successful in 1m37s
CI / Stage 7 — Build the admin and server applications (E00-S05-T01) (pull_request) Successful in 1m3s
CI / Stage 5 — Architecture tests (E00-S05-T01) (pull_request) Successful in 3m14s
CI / Stage 2 — Typecheck (E00-S05-T01) (pull_request) Successful in 1m20s
CI / Stage 1 — Frozen lockfile install (E00-S05-T01) (pull_request) Successful in 51s
CI / Stage 3 — Formatting/lint policy (E00-S05-T01) (pull_request) Successful in 45s
2026-08-31 00:51:41 +00:00
2 changed files with 230 additions and 0 deletions
@@ -0,0 +1,118 @@
# ADR-006: Kysely contained inside DB adapter
- Status: Accepted
- Date: 2026-08-31
- Deciders: platform stream
- References: ADR-001, ADR-005, Architecture wiki (sections 1, 4, 9),
Engineering-Standards wiki (sections 24, 25, 57)
## Context
EPPP is a modular monolith (ADR-001) that keeps every module boundary
explicit, versioned and CI-enforced. ADR-005 already records the database
choice — PostgreSQL 18 as the sole canonical database — and with it the rule
that the `pg` driver and the Kysely query builder are isolated to
`packages/database-postgres`, the adapter boundary every database access
passes through. This ADR is the focused record of where the query builder
itself may live: the containment decision.
Kysely is a type-safe SQL query builder, not an ORM. It gives typed queries
and composable expressions on top of `pg`, but it still speaks SQL and a
dialect, and every package that imports it is coupled to that dialect and to
the adapter's implementation choices. The workspace already depends on the
containment: `packages/database-postgres` declares `kysely` 0.29.4 and `pg`
8.22.0 as its exact-pinned runtime dependencies (its only ones), imports them
in its source and re-exports the pieces the adapter is built on (E00-S03-T02),
and is the single workspace package whose source may import the driver — a
rule locked in by `tests/database-postgres-imports.test.mjs` in the
`database-postgres-imports` CI job and by the architecture fitness tests
FIT-010 (browser bundles cannot import server/DB packages) and FIT-011 (UI
cannot import Kysely/pg).
The dependency direction is already fixed: `database-postgres → core ports →
Kysely/pg` (Architecture wiki §9.1, Engineering-Standards §24). Domain and
extension packages talk to the database through core repository/port
interfaces, never through the query builder. What remains to be recorded is
that this is a standing architectural decision, not a temporary arrangement:
Kysely stays inside the adapter, and no other package may grow a Kysely or
`pg` import surface.
## Decision
EPPP keeps **Kysely contained inside DB adapter**. Kysely is an
implementation detail of `packages/database-postgres` — the single workspace
package allowed to import `pg`/Kysely (E00-S03-T02) — and the rest of the
workspace reaches the database exclusively through the adapter's and core
ports' APIs. No other workspace package may declare `kysely` or `pg` in its
manifest or import them directly in its source; the `database-postgres-imports`
CI gate plus FIT-010/FIT-011 enforce this on every PR. Kysely exists only
where the adapter lives — it is contained, not distributed, and the containment
is a package-level rule, not a type-level one.
## Alternatives
- **Kysely imported directly by domain/extension packages** — rejected: it
would leak SQL-dialect and query-builder concerns into domain and extension
code, defeat the single-owner boundary (E00-S03-T02), make FIT-011
impossible to honour, and couple business logic to the adapter's
implementation.
- **A dedicated shared query-layer package** — rejected: it would create a
second import surface for the driver stack, split ownership of the dialect,
and add a package whose only purpose is to widen the boundary; the adapter
already owns the typed surface callers need.
- **Full ORM (e.g. Prisma/Drizzle) instead of a query builder** — rejected:
schema is owned by the adapter's migration chains (ADR-005,
Engineering-Standards §25), and an ORM adds code generation and a schema
file that would couple domain models to storage; Kysely's typed builder is
sufficient and stays contained.
- **Raw `pg` everywhere / no query builder** — rejected: hand-written SQL
loses type safety and composability while still requiring the driver;
containing a query builder is strictly better than containing SQL strings.
- **Adapter re-exports Kysely for other packages to build queries** — rejected
(variant of the first alternative): even when routed through the adapter,
letting other packages compose Kysely queries would leak the dialect and
bypass the port/repository API that keeps domain code storage-agnostic.
## Consequences
- Positive: one owner for the whole driver/query-builder stack — upgrade,
dialect and typing decisions live in `packages/database-postgres` and are
exact-pinned single-manifest changes, so replacing or upgrading Kysely and
`pg` touches exactly one manifest; domain and extension code stays
driver-free, so FIT-010/FIT-011 hold; replacing Kysely or the dialect later
is a contained change behind the adapter boundary (consistent with the
ADR-005 encapsulation).
- Negative: the adapter's port/repository API must be designed well enough
that domain code never needs the query builder — the port API is mandatory,
not optional; Kysely conveniences (expression builders, plugin features) are
not available to callers outside the adapter; the adapter package grows as
the query surface grows.
- Neutral: Kysely remains a dependency of the adapter package only; contracts
between core ports and the adapter are plain TypeScript interfaces, so the
containment stays a package-level rule and never becomes a type-level one.
## Operational impact
- `kysely` and `pg` appear in exactly one manifest
(`packages/database-postgres/package.json`) and in exactly one package's
source; a static scan in `tests/database-postgres-imports.test.mjs`
(`database-postgres-imports` CI job) proves it on every PR, including a
mutation probe that fails when a driver import is injected elsewhere.
- Upgrading Kysely or `pg` is a single-manifest change inside the adapter,
reviewed under the workspace's dependency/upgrade lane; no other package's
manifest or code moves.
- No new runtime services, no schema changes, no deployment or configuration
impact: this ADR is documentation of an already-enforced boundary.
## Revisit trigger
- Revisit this ADR when a non-adapter package demonstrably needs
query-builder features that cannot be expressed through the adapter/port
API, and there is evidence (per the architecture review gates) that the
boundary costs more than containment saves.
- Revisit if the platform grows additional data stores or engines (shared with
the ADR-005 revisit trigger): a second engine may need its own contained
adapter, and the query-builder containment rule would extend per adapter.
- Revisit if the dependency-boundary enforcement changes — for example if
FIT-010/FIT-011 or the `database-postgres-imports` gate are relaxed or
re-scoped.
@@ -0,0 +1,112 @@
# ADR-007: React SSR for public rendering
- Status: Accepted
- Date: 2026-08-31
- Deciders: platform stream
- References: ADR index (section 70), ADR-001, Architecture wiki (sections 1, 19, 42),
Technology-Stack wiki (sections 5.2, 6, 7, 8)
## Context
EPPP is a modular monolith (ADR-001) whose single application image contains the
public server, admin API, rendering pipeline, extension runtime and core services.
The public rendering pipeline is already documented: Fastify route → SiteResolver →
VisitorPreferenceResolver → ContentService → PageComposition + BlockRegistry →
ThemeResolver → React DOM server renderer → HTML (Architecture wiki §19), and the
v0.1 public surface exposes `GET /`, `GET /posts/:slug`, `GET /assets/*`,
`GET /media/*`, `GET /health/live`, `GET /health/ready` and `GET /admin/*` (§42).
This ADR is the focused record of the rendering choice at the end of that pipeline.
React is already the pinned rendering technology in the golden compatibility tuple:
React / React DOM 19.2.8, class C, in the runtime stack and golden tuple
(Technology-Stack §5.2, §7), and ADR-001 fixes the runtime as "Node.js 24 LTS with
Fastify 5, PostgreSQL 18, React 19 for server-rendered public components and a Vite
admin". The LTS strategy keeps React exact-pinned at 19.2.8, keeps React Server
Components out of v1, and keeps React out of persisted content formats
(Technology-Stack §6).
The workspace does not implement the public renderer yet — `apps/server` is the bare
Fastify health shell — so this ADR records the decision ahead of the code that
implements it, formalising what the Architecture and Technology-Stack wikis already
mandate: public pages are server-rendered first, React is a server-rendering detail,
and the site is not hydrating (§19, §19.1).
## Decision
EPPP renders all public pages with **React SSR for public rendering**: server-side
rendering through the React DOM server renderer, React 19.2.8 exact-pinned (class C,
golden tuple). Public pages are server-rendered first (§19); the pipeline terminates
in the React DOM server renderer producing HTML, with no client-side hydration of
core public pages — React is a server-rendering detail and the site is not hydrating
(§19.1). Core public Home/article pages target 0 bytes of EPPP JavaScript, and only
truly interactive features register client islands with an explicit activation mode
(§19.2). React Server Components are out of scope for v1, and React stays out of
persisted content formats (Technology-Stack §6).
The decision text — **React SSR for public rendering** — matches the ADR index entry
(ADR-007, section 70).
## Alternatives
- **Client-side rendering (SPA)** — rejected: public pages must work with JavaScript
disabled (§19.1 zero-JavaScript baseline); a browser-rendered SPA delivers no HTML
to first paint and contradicts the server-rendered-first pipeline (§19).
- **Static site generation (build-time SSG)** — rejected: content is connection-backed
PostgreSQL 18 (ADR-005) resolved per request with visitor preferences and
extension-owned content; build-time HTML would go stale against the canonical DB and
add a rebuild/redeploy cycle per content change.
- **SSR with full client hydration** — rejected for v1: core public pages target
0 bytes of EPPP JavaScript (§19.1); hydration would ship a client bundle to every
visitor when only a few interactive features need JS (§19.2 islands).
- **React Server Components (RSC)** — rejected for v1: the LTS strategy keeps RSC out
of v1 and out of persisted content formats (Technology-Stack §6); under the
zero-JavaScript baseline there is no client component tree to serve.
- **Template engine / string templating (e.g. Pug or Handlebars via @fastify/view)** —
rejected: the pipeline already terminates in the React DOM server renderer (§19),
themes resolve through the ThemeResolver into that renderer, and the golden tuple
pins React 19.2.8; a second rendering technology would duplicate work with no
benefit.
## Consequences
- Positive: server-rendered HTML works with JavaScript disabled, meeting the
zero-JavaScript baseline (§19.1); public pages get a fast first paint with no client
bootstrapping; metadata/SEO output is plain HTML; React is already in the golden
tuple (class C) so no new dependency or stack choice; one rendering path serves all
public pages, and themes/extensions compose through the documented pipeline
(PageComposition, BlockRegistry, ThemeResolver).
- Negative: SSR costs per-request CPU in the Node.js process; the React runtime must
load in the server process; rendering errors surface at request time rather than at
build time; public pages have no client interactivity without registered islands.
- Neutral: React stays a server-rendering detail — the site is not hydrating (§19.1);
browser JavaScript exists only on registered islands (§19.2); the decision constrains
the later client-side decisions (zero-JS public baseline and the client-island model,
ADR-009 and ADR-010).
## Operational impact
- One Node.js 24 process renders public HTML in-process via the React DOM server
renderer inside the single application image (ADR-001); no separate rendering
service or runtime build step.
- Public routes in the v0.1 surface (§42) return server-rendered HTML; core
Home/article pages ship 0 bytes of EPPP JavaScript (§19.1) — a measurable CI
invariant once the renderer lands.
- Rendering is stateless: horizontal scaling means more instances of the same image
behind the optional edge proxy; rendering load is part of the application process.
- React 19.2.8 is exact-pinned and class C (Technology-Stack §5.2, §7); upgrades flow
through the dependency lanes (ADR-026), and a React major is an ADR-recorded upgrade
program item (§7/§8).
- The renderer is not implemented yet — `apps/server` is the Fastify health shell — so
today rollback is reverting this documentation commit; once the renderer lands,
rollback is redeploying the previous image.
## Revisit trigger
- Revisit when a v0.1 public page cannot meet the zero-JavaScript baseline (§19.1)
with server rendering alone, or a required feature needs browser-side rendering at
scale.
- Revisit when React Server Components or full hydration is proposed for v1 —
Technology-Stack §6 explicitly keeps RSC out of v1.
- Revisit when the golden tuple's React pin moves to a new major (an ADR-recorded
upgrade program item, §7/§8) or React's support class changes.
- Revisit when visitor preferences or the v1.1 boundary contracts (ADR-027 to ADR-032)
demand a different rendering model — consistent with the Architecture §19 gates.