Skip to content

feat(api-effect): Effect v4/Bun mirror of the api - #54

Merged
xirothedev merged 7 commits into
mainfrom
feat/api-effect-mirror
Sep 4, 2026
Merged

feat(api-effect): Effect v4/Bun mirror of the api#54
xirothedev merged 7 commits into
mainfrom
feat/api-effect-mirror

Conversation

@xirothedev

Copy link
Copy Markdown
Owner

What

New lab-stack Mirror (apps/api-effect): the 9 route groups of api-elysia re-implemented on effect@4.0.0-rc.112 + @effect/platform-bun HttpRouter. Shares the Prisma 7 multi-file schema; serves no real traffic (see root CONTEXT.mdMirror, Lab stack).

Design (grilling session, all decisions confirmed)

  • @effect/platform-bun on Bun, dev port 4003, bun --watch loop; no Docker/compose/nginx (Mirrors run locally).
  • Prisma 7 shared schema + generated client committed (same layout as api-elysia).
  • Byte-for-byte response parity with api-elysia: Nest-shaped error bodies, cookie attrs, status codes; ported bindBody validation (not Effect Schema), same lib deps.
  • Effect Config for PORT/CORS_ORIGIN; CORS predicate accepting single origin / comma-list / *.
  • Exact 4.0.0-rc.112 pins, no ^ — recorded in apps/api-effect/docs/adr/0001-pin-effect-4-rc.md.

Effect v4 wiring notes

  • HttpRouter.add is itself the route Layer; scope held with Effect.never.
  • CSRF + throttle guards keyed on the declared route pattern; 404s are wildcard routes registered unguarded (serve middleware cannot rewrite responses; matches Elysia where NOT_FOUND short-circuited before onBeforeHandle).
  • CORS middleware answers all OPTIONS preflights before routing, so preflights never burn strict-path throttle quota.

Verification

  • bun test ./src in app: 39/39; pre-commit hook ran the full turbo matrix green (8/8 tasks, all mirrors + prod api + infra).
  • Live curl matrix vs shared-schema Postgres: 400/401/403/404/409/501 bodies, csrf cookie round-trip (Max-Age, HttpOnly), unknown-path POST → 404 not 403, CORS comma-list echo + reject.
  • Two-axis review (Standards + Spec) findings all resolved before commit.

Dev loop

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/webdevstudios bun run dev   # :4003

New lab-stack Mirror (CONTEXT-MAP): api-elysia's 9 route groups ported to
effect@4.0.0-rc.112 + @effect/platform-bun HttpRouter, pinned exactly per
ADR-0001. Shared Prisma 7 schema, byte-parity responses/cookies/Nest-shaped
errors, ported bindBody validation and bun test suite (39 tests).

Effect v4 wiring: HttpRouter.add layers per route, route-pattern-keyed
CSRF+throttle guards, wildcard-unguarded 404 catch-alls (serve middleware
cannot rewrite responses), Config for PORT/CORS_ORIGIN, CORS predicate
accepting comma-lists and '*'.

Verified: tsc clean, oxlint clean (only pre-existing copied-lib warnings),
39/39 tests, live curl matrix against shared-schema Postgres on :5439.
…ckage

Per rules/libraries.md #11: import { cn } from 'cn' directly, no
lib/utils re-export indirection. Delete both twMerge(clsx()) wrappers,
rewrite 45 cn imports, drop clsx/tailwind-merge deps, point
components.json utils alias at cn.
bun remove re-resolved @tiptap/core to 3.30.6 while @tiptap/react
stayed at 3.23.4, splitting peer types and failing apps/web build.
Update react so the whole graph resolves one version; net -140 lock
lines of duplicates.
…fig-driven Db, scheduled sweep

Framework pass, not a behavior change: parity matrix unchanged (400/401/403/404/409/501,
cookie attrs, CORS echo/reject, 39/39 tests).

- HttpApi + HttpApiBuilder: all 52 endpoints declared in src/api.ts, handlers via
  builder groups; raw HttpServerResponse is the seam for cookies/status (success:
  Schema.Unknown — validation stays bindBody for byte parity with the other mirrors).
- Db is a Context.Service (Layer.effect + acquireRelease, DATABASE_URL via Config)
  provided to handlers through HttpRouter.provideRequest; routes use ctx.db.
- Sweep moved from setInterval to an Effect scheduled task (Schedule.spaced) that
  forks inside the app layer's scope and dies with the server; logs via Logger.
- CORS as chain middleware on serve/toWebHandler (same preflight-204 semantics).
- Deliberate keeps, per the mirror's library decisions: bindBody validation, ioredis
  throttle, PayOS fetch, bun test (repo standards override the Effect-native option).
- Two documented casts where rc-HttpApi's Request-marker requirements leak past
  toWebHandler/runPromise R constraints.
…for PayOS, boot-time Config validation

- lib/redis.ts: ioredis dependency removed; getRedis() now fronts BunRedis (an
  Effect service) via ManagedRuntime with an ioredis-shaped adapter, the same
  2s ping + degrade-to-null, and interrupt-driven cleanup on failure.
  Verified live: throttle 429 on the 11th strict-path request with Redis,
  all-200 degrade when Redis is unreachable.
- lib/payos.ts: raw fetch -> HttpClient with FetchHttpClient.layer.
- index.ts: DATABASE_URL + JWT_SECRET_KEY validated via Config.redacted at boot
  (fail fast before serving).
…dy, per-endpoint success schemas, zero route assertions

Route handlers are now proven-typed end to end:

- bindBody infers its result type from the field table (const generics + a
  JsonKey/FieldValue mapped type): required:true fields are non-null strings,
  enums come through as literals, optionals as T|null|undefined. All explicit
  <T> assertions and `in1.x!` non-nulls are gone from every route file.
- Every endpoint declares a success schema mirroring its exact response
  (nullable keys, permissive enums); wrap<A> checks each handler's return
  value against it at .handle time. Custom status/cookie responses render
  raw but are still type-checked as A.
- Path params via checked ctx.param() (no `!`); group endpoint declarations
  now live beside their handlers (routes/*.ts export <g>Group + a local
  single-group api for builders); api.ts just aggregates the groups.
- Enum casts (ProductSlug/EventType) replaced with type guards where the
  value is validated first.
- Real bug caught by the typing pass: create-link declared the field as
  OrderID, whose jsonKey('orderID') never matched what web sends
  ('orderId', per the Go struct tag). Renamed to OrderId.
- Remaining casts are bounded and intentional: validate.ts (the validation
  boundary itself), copied blueprint libs (Prisma enum query filters),
  prisma-fake test doubles, Array.includes widenings, and the one documented
  rc-HttpApi Request-marker cast in index.ts.

39/39 tests; live E2E: register 201 {userId}, unknown-key 400 Go message,
empty-body 400 message array, email-verify 400, 404/401/403/CORS parity.
…+ live auth E2E

- HttpApiSwagger.layer(api, { path: '/docs' }) pays out the DTO work: full
  OpenAPI 3.1.0 served inline (verified /v1 paths + {slug} params present).
  Mounted OUTSIDE HttpRouter.provideRequest — that wrapper only relieves
  Request-marker requirements and swallows plain-HttpRouter layers (rc quirk).
- validate.ts: shared isRecord predicate retires the JSON-object casts;
  assertValidStatus is now a TS assertion so listOrders/listAllOrders/
  adminUpdateStatus flow enum-narrowed values into Prisma with no casts.
- payos.ts: response handling narrows through isRecord/typeof — no casts.
- New lib/slugs.ts: single VALID_SLUGS + isProductSlug guard, shared by
  products/reviews (was two local copies).
- Remaining casts are the documented boundary set: prisma-fake test doubles,
  unvalidated enum pass-throughs to the DB (ProductSize/productSlug — matches
  Go's reject-at-DB behavior), validate.ts raw->BindResult proof, and the one
  rc-HttpApi marker cast.
- Verified live end-to-end against real Postgres+Redis: register 201 ->
  email-verify -> login (2 Set-Cookie) -> /users/me (codec, goTime format) ->
  sessions (device rows) -> refresh (rotation + re-set cookie) -> logout
  (2 cleared cookies); 2fa-without-CSRF correctly 403s.

tsc 0 errors, oxlint clean, 39/39 tests.
@xirothedev
xirothedev merged commit 939b183 into main Sep 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant