Skip to content

feat: scoped-routing resolution via resolve_route() in api middleware (Lane B)#1398

Merged
pyramation merged 3 commits into
mainfrom
feat/scoped-routing-middleware
Jul 20, 2026
Merged

feat: scoped-routing resolution via resolve_route() in api middleware (Lane B)#1398
pyramation merged 3 commits into
mainfrom
feat/scoped-routing-middleware

Conversation

@pyramation

@pyramation pyramation commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Lane B of constructive-planning#1214: the GraphQL server can resolve incoming hostnames through the compiled scoped-routing plane shipped in constructive-db (now the typed per-type catalog, constructive-db#2345), via the frozen DB↔server contract.

Host-only projection (per the routing boundary decision): Traefik/Ingress owns host→workload and L7 path/method routing; the server is a dumb Deployment that only maps host → tenant/api/db/role. So the server always calls the frozen SQL contract with the root path and no method:

SELECT * FROM <scopedRoutingSchema>.resolve_route(host, '/', NULL)

The resolve_route() function signature and return shape are unchanged — only how the server calls it. Server-side path/method matching is dropped from the scoped path (it was also inconsistent with the host-wide svc cache key).

Additive and opt-in — no behavior change by default:

  • ApiOptions.enableScopedRouting (default false) and ApiOptions.scopedRoutingSchema (default constructive_routing_public) in @constructive-io/graphql-types.
  • middleware/routing.ts: resolveRoute(pool, schema, host) (typed ResolvedRoute matching the contract row; treats the contract no-match row route_binding_id IS NULL, missing resolver 42883, or missing schema 3F000 as "no match") and routeToApiStructure() (maps an api-target route's resolved_config{dbname, role_name, anon_role, schemas, api_id, database_id, is_public} — onto the existing ApiStructure).
  • In getApiConfig domain-lookup mode: resolveScopedRoute(ctx) ?? resolveDomainLookup(ctx) — scoped plane first, then the legacy services_public.domains query remains the authoritative fallback. Module settings (RLS/auth/CORS/…) still resolve through the existing loader registry.

Full read-cutover (scoped plane authoritative, legacy queries removed) is a later phase per the migration plan in constructive-db docs/architecture/scoped-domain-routing.md.

Unit tests in routing.test.ts cover host-only invocation (resolve_route($1, '/', NULL) with [host] only), the contract no-match row, resolver-not-installed fallback, error rethrow, schema-name validation, ApiStructure mapping, and scoped-first/legacy-fallback resolution order in getApiConfig.

Link to Devin session: https://app.devin.ai/sessions/9a83c1e46fc744c487980aee5844f556
Requested by: @pyramation

@pyramation pyramation self-assigned this Jul 19, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Routing boundary research — findings & recommendation for #1398

TL;DR

The two-projection model is right, and it's already half-built: k8s Ingress objects are already generated from the DB by a reconciler (they are not hand-managed), and Traefik today matches host only and funnels everything to one gateway Service. The server's job is, and should remain, host → tenant (api/db/schemas/role) — which Traefik cannot do. #1398 is directionally correct as the Layer-2 consumer but is currently too wide (it feeds real path/method into resolve_route() while caching the result host-wide), so it should be narrowed to a host-only projection, then merged dormant — not closed, not merged as-is.

1. Current request path (end to end)

client → Traefik IngressRoute (host-only match, TLS) → Service constructive-public-server
       → graphql-server createApiMiddleware → getApiConfig (mode 'domain-lookup')
       → resolveDomainLookup → services_public.domains ⋈ apis (domain + subdomain only)
       → ApiStructure {dbname, role_name, anon_role, schemas, …} → PostGraphile render
  • Traefik layer: constructive-db functions/knative/src/handlers/http-route.ts — the http_route:reconcile handler (registered in functions/compute-service/src/index.ts:474) reads services_public.http_routes ⋈ domains ⋈ managed_domains and materializes one Traefik IngressRoute per active static route (target_kind IN ('site','api','service'), line 40) plus one cert-manager Certificate per cert-bearing root domain (lines 145–164, 227–240).
    • The Traefik match is host only: traefikMatch() emits Host(...)/HostRegexp(...) (lines 97–103); route.path and route.method are read from the DB (lines 183–184) but never projected into the match rule (ingressRouteFor, lines 105–143).
    • Every static route points at one Service: STATIC_INGRESS_SERVICE_NAME, default constructive-public-server (functions/compute-env/src/index.ts:258, consumed at http-route.ts:110–116). So Traefik today = host→(single gateway) + TLS termination; it does not pick per-target workloads or route paths.
  • Server layer: constructive graphql/server/src/middleware/api.tscreateApiMiddlewaregetApiConfig (lines 475–557) → mode domain-lookupresolveDomainLookup (line 402) → DOMAIN_LOOKUP_SQL (lines 31–49): match on d.domain = $1 AND d.subdomain = $2 only. Path and method play no role in api selection today. The result cache key (getSvcKey, lines 243–261) is likewise host-derived.
  • Path/method today are consumed only by the other gateways, not the graphql server:
    • the sync invocation gateway (compute-service sync role) routes via resolve_http_route for function targets only (functions/compute-service/src/index.ts:573–581);
    • the webhook gateway has its own per-route ingress reconciler (functions/knative/src/handlers/webhook-route.ts).

2. Is ingress DB-generated? YES

Layer 1 is already a projection of DB rows: http_route:reconcile (B1-A) bulk-applies/prunes managed IngressRoutes (label constructive.io/http-route-id) and emits cert-manager Certificates, mirroring the constructive-cloud operator's traefikMatch "so DB-driven routes and CR-driven routes stay byte-compatible" (http-route.ts:91–96). Ingress is not hand-managed.

Moreover, the scoped-routing migration plan already prescribes the two-projection cutover: "Read cutover — the GraphQL server switches host resolution … to resolve_route(); the knative ingress projector consumes hostname_bindings for Certificate/Ingress projection" (constructive-db docs/architecture/scoped-domain-routing.md:147–149).

3. The correct boundary

Same DB rows, two projections:

Concern Owner Mechanism
host → workload/Service, TLS termination, cert issuance Traefik (Layer 1) reconciler-generated IngressRoute + Certificate (today from services_public, post-cutover from hostname_bindings)
path-level target splitting on one host (/blog → site renderer, /api → graphql) Traefik (Layer 1) future: project route.path into PathPrefix(...) matchers routing to distinct Services — the columns exist, the projection doesn't yet
host → tenant (api/db/schemas/role) + module settings (RLS/auth/CORS) Server (Layer 2) resolve_route() as data source replacing services_public.domains — this is the part Traefik structurally cannot do
method-level routing neither belongs to the function dataplane (sync gateway) if anywhere

So Dan's "dumb Deployment" model holds: the server matches an api by host and renders it. The legit-sounding case for in-server path routing (website-builder /blog vs /api on one host) is better served by Layer 1, because those targets are different workloads (site renderer vs graphql server) — routing them inside the graphql server would force it to proxy/render non-api targets, which is exactly the scope creep to avoid.

4. What's wrong with #1398 as-is (concrete)

  1. Path/method leak into Layer 2: resolveScopedRoute passes the request's real path and method into resolve_route() (api.ts diff: host: req.get('host'), path: req.path, method: req.method). That makes api selection path/method-sensitive — Traefik's job.
  2. Cache correctness bug: getApiConfig caches the resolved ApiStructure under getSvcKey() — a host-only key (api.ts:243–261, svcCache.set(cacheKey, result) at line ~552). With feat: scoped-routing resolution via resolve_route() in api middleware (Lane B) #1398, a path-specific resolve_route hit (e.g. a /admin-scoped route) would be cached and served for every path on that host. Host-only resolution makes the cache correct by construction.
  3. Otherwise the PR is sound: additive, OFF by default, correct contract handling (no-match row, 42883/3F000 fallback), and routeToApiStructure already refuses non-api targets.

5. Recommendation for #1398: narrow, then merge dormant

Do not close; do not merge as-is. Keep the PR and make one small change before merge:

  • Narrow the server's consumption to a host-only projection: call resolve_route(host, '/', NULL) (or equivalently drop path/method from ResolveContext and hardwire the root path), so the server resolves the host's api binding and nothing else. Keep the frozen 3-arg DB contract untouched — the server just always asks for the root route.
  • Keep routeToApiStructure's api-target-only filter (already present).
  • Keep enableScopedRouting=false default (dormant until read-cutover phase).

Follow-ups (separate PRs, per the migration plan):

  • Reconciler read cutover: point http_route:reconcile at hostname_bindings/route_bindings instead of services_public (doc step 2).
  • Path-aware Layer 1: extend ingressRouteFor to project route.path into PathPrefix matchers and per-target-kind Services when multi-target hosts land.
  • Only then flip enableScopedRouting on.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Plan: narrow to host-only resolution (per the routing-boundary decision)

Traefik/Ingress owns host→workload and L7 path/method routing; the server is a dumb deployment that only needs host→tenant/api/db/role. So:

  • resolveRoute(pool, schema, host) — the server now calls the frozen SQL contract as resolve_route($1, '/', NULL) (host only; function signature/return shape untouched)
  • resolveScopedRoute in api.ts no longer feeds request path/method into resolution (which was also inconsistent with the host-wide svc cache key); server-side path/method matching is dropped from the scoped path
  • still additive + dormant: enableScopedRouting default false, legacy services_public.domains lookup remains the authoritative fallback
  • tests updated to assert host-only invocation

No cutover; this just aligns the dormant scoped path with the Traefik boundary before enabling.

@pyramation
pyramation merged commit b9fbb5b into main Jul 20, 2026
38 checks passed
@pyramation
pyramation deleted the feat/scoped-routing-middleware branch July 20, 2026 05:52
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