Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f47f384
docs: add FastAPI Realtime Rule Engine design spec
grdsdev Jun 23, 2026
8228020
docs: add FastAPI Realtime Rule Engine implementation plan
grdsdev Jun 23, 2026
6221e3d
feat(examples): scaffold FastAPI realtime rule engine
grdsdev Jun 23, 2026
bf9f43e
feat(examples): add SQL migration for tasks, rules, rule_events
grdsdev Jun 23, 2026
7d10fdf
feat(examples): add per-request JWT client dependency
grdsdev Jun 23, 2026
2a7247e
feat(examples): add auth router (signup, signin, signout)
grdsdev Jun 23, 2026
ef4656f
feat(examples): add tasks router (list, create, update status)
grdsdev Jun 23, 2026
98e8b0a
feat(examples): add rules router (list, create, delete)
grdsdev Jun 23, 2026
6275415
feat(examples): add Realtime engine with Postgres Changes + broadcast
grdsdev Jun 23, 2026
c8b8804
fix(examples): use get_running_loop and remove unused import in engine
grdsdev Jun 23, 2026
7bc0f51
feat(examples): add Jinja2 templates with JS realtime subscriptions a…
grdsdev Jun 23, 2026
cfd5527
docs(examples): add README and complete friction log for FastAPI example
grdsdev Jun 23, 2026
4bfe363
fix(examples): correct engine rule evaluation, RLS policy, auth guard…
grdsdev Jun 23, 2026
781caf8
test(examples): add E2E test suite and GitHub Actions workflow for Fa…
grdsdev Jun 23, 2026
87a183d
chore: remove superpowers plans and specs from repo
grdsdev Jun 23, 2026
f9d9834
fix(examples): correct E2E startup detection — use /health, capture s…
grdsdev Jun 23, 2026
0484de2
fix(examples): fix E2E suite — grants, payload key, teardown
grdsdev Jun 23, 2026
36efd05
fix(examples): Python 3.9 compat, unit test payload format, ruff B008
grdsdev Jun 24, 2026
f74577b
Merge main into PR 1519 and fix lint for new ruff rules
grdsdev Nov 3, 2026
5e0c9c9
fix(examples): rename inbucket to local_smtp in supabase config
grdsdev Nov 3, 2026
0b8317a
test(examples): retrigger update in engine E2E to avoid subscription …
grdsdev Nov 3, 2026
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
71 changes: 71 additions & 0 deletions .github/workflows/e2e-fastapi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: E2E – FastAPI Example

on:
push:
paths:
- 'examples/fastapi/**'
- '.github/workflows/e2e-fastapi.yml'
branches:
- main
pull_request:
paths:
- 'examples/fastapi/**'
- '.github/workflows/e2e-fastapi.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
e2e:
name: FastAPI E2E (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]

steps:
- name: Clone Repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Install Supabase CLI
uses: supabase/setup-cli@3c2f5e2ae34c34e428e8e206e2c4d21fa2d20fbf # v2.1.1
with:
version: "latest"
github-token: ${{ github.token }}

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.8.2"
python-version: ${{ matrix.python-version }}

- name: Install workspace dependencies
run: uv sync --frozen

# Migrations in supabase/migrations/ are applied automatically by supabase start.
- name: Start local Supabase
run: supabase start -x studio,mailpit,edge-runtime,logflare,vector,supavisor,imgproxy,storage-api
working-directory: examples/fastapi

- name: Export Supabase credentials
id: creds
run: |
eval "$(supabase status -o env)"
echo "url=${API_URL}" >> "$GITHUB_OUTPUT"
echo "anon_key=${ANON_KEY}" >> "$GITHUB_OUTPUT"
echo "service_key=${SERVICE_ROLE_KEY}" >> "$GITHUB_OUTPUT"
working-directory: examples/fastapi

- name: Run E2E tests
run: uv run --package fastapi-realtime-rule-engine pytest tests/e2e/ -v --tb=short
working-directory: examples/fastapi
env:
SUPABASE_URL: ${{ steps.creds.outputs.url }}
SUPABASE_SERVICE_KEY: ${{ steps.creds.outputs.service_key }}
SUPABASE_ANON_KEY: ${{ steps.creds.outputs.anon_key }}

- name: Stop local Supabase
if: always()
run: supabase stop
working-directory: examples/fastapi
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,12 @@ postgrest.%:
supabase.%:
@$(MAKE) -C src/supabase $*

fastapi.%:
@$(MAKE) -C examples/fastapi $*

help::
@echo
@echo "Package specific commands can be ran by prefixing the command with the package name. Examples:"
@echo " realtime.mypy -- runs relatime's mypy target"
@echo " supabase.build -- runs supabase's build target"
@echo " realtime.mypy -- runs realtime's mypy target"
@echo " supabase.build -- runs supabase's build target"
@echo " fastapi.e2e -- run FastAPI example E2E tests (requires Supabase running)"
3 changes: 3 additions & 0 deletions examples/fastapi/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-role-key
SUPABASE_ANON_KEY=your-anon-key
38 changes: 38 additions & 0 deletions examples/fastapi/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
help::
@echo "Available commands"
@echo " help -- (default) print this message"

# Exclude studio and heavyweight services; keep API, Auth, Realtime, DB.
EXCLUDE := studio,mailpit,edge-runtime,logflare,vector,supavisor,imgproxy,storage-api

start-infra:
supabase start -x $(EXCLUDE)
help::
@echo " start-infra -- start local Supabase (API, Auth, Realtime, DB)"

stop-infra:
supabase stop
help::
@echo " stop-infra -- stop local Supabase"

tests:
uv run --package fastapi-realtime-rule-engine pytest tests/ --ignore=tests/e2e -v
help::
@echo " tests -- run unit tests (no Supabase required)"

# E2E: requires Supabase to already be running (make start-infra first).
# Export credentials from supabase status and pass to pytest.
e2e:
@eval "$$(supabase status -o env)" && \
SUPABASE_URL=$$API_URL \
SUPABASE_SERVICE_KEY=$$SERVICE_ROLE_KEY \
SUPABASE_ANON_KEY=$$ANON_KEY \
uv run --package fastapi-realtime-rule-engine pytest tests/e2e/ -v
help::
@echo " e2e -- run E2E tests (requires: make start-infra first)"

clean:
rm -rf htmlcov .pytest_cache .mypy_cache .ruff_cache
rm -f .coverage coverage.xml
help::
@echo " clean -- delete cache files"
67 changes: 67 additions & 0 deletions examples/fastapi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# FastAPI Realtime Rule Engine

An example application that stress-tests `supabase-py`'s async Realtime path,
per-request JWT injection, Postgres Changes, and server-side broadcast from Python.

## What it does

Users create **watch rules**: "when `tasks.status` changes to `done`, broadcast to
`#team-alerts`." A background Python coroutine holds a persistent Realtime
connection, subscribes to Postgres Changes on the `tasks` table, evaluates rules,
and broadcasts enriched events. The browser subscribes to those channels via the
JS client and shows a live event feed.

## Prerequisites

- A Supabase project (create one free at supabase.com)
- uv installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
- Supabase CLI installed (`brew install supabase/tap/supabase`)
- Python 3.10+

## Setup

**1. Install workspace dependencies (from repo root):**
```bash
uv sync
```

**2. Apply the database migration:**
```bash
# Using Supabase CLI connected to your project
supabase db push --db-url "postgresql://..." supabase/migrations/001_initial.sql
```

Or copy-paste `supabase/migrations/001_initial.sql` into the Supabase SQL editor.

**3. Configure environment:**
```bash
cp .env.example .env
# Fill in SUPABASE_URL, SUPABASE_SERVICE_KEY, SUPABASE_ANON_KEY from your project settings
```

**4. Run the app:**
```bash
cd examples/fastapi
uv run uvicorn main:app --reload
```

Open http://localhost:8000/signin, sign up, create rules, update tasks, and watch
broadcasts appear in the live feed.

## Run tests

```bash
cd examples/fastapi
uv run pytest tests/ -v
```

Tests mock all Supabase I/O — no real project required.

## SDK friction log

See `friction_log.md` for every pain point discovered during implementation,
with severity ratings and recommended SDK improvements.

## Architecture

See the design spec at `docs/superpowers/specs/2026-06-23-fastapi-realtime-rule-engine-design.md`.
12 changes: 12 additions & 0 deletions examples/fastapi/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
supabase_url: str
supabase_service_key: str
supabase_anon_key: str

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")


settings = Settings()
35 changes: 35 additions & 0 deletions examples/fastapi/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from config import settings
from fastapi import HTTPException, Request
from supabase._async.client import AsyncClient

from supabase import AsyncClientOptions, acreate_client


async def get_client(request: Request) -> AsyncClient:
"""
Construct a fresh per-request AsyncClient using the caller's JWT.

FRICTION NOTE: There is no built-in helper for this pattern. We must
manually construct AsyncClientOptions with the Authorization header.
Passing headers={} to AsyncClientOptions REPLACES the defaults (losing
X-Client-Info), so we mutate the options object after construction.
See friction_log.md -- "Per-request JWT injection is not ergonomic".
"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(
status_code=401, detail="Missing or invalid Authorization header"
)

token = auth.removeprefix("Bearer ")
if not token:
raise HTTPException(
status_code=401, detail="Missing or invalid Authorization header"
)
# Mutate after construction to preserve DEFAULT_HEADERS (X-Client-Info etc.)
options = AsyncClientOptions()
options.headers["Authorization"] = f"Bearer {token}"

return await acreate_client(
settings.supabase_url, settings.supabase_anon_key, options=options
)
79 changes: 79 additions & 0 deletions examples/fastapi/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# examples/fastapi/engine.py
import asyncio

from supabase._async.client import AsyncClient


async def on_task_change(service_client: AsyncClient, payload: dict) -> None:
"""
Called by the Realtime subscription for each UPDATE on the tasks table.

FRICTION NOTE (1): The supabase-py Realtime callback is synchronous by design
in some versions — if channel.on_postgres_changes() does not accept an
async callback, wrap this function with asyncio.create_task() inside a
sync wrapper. See friction_log.md — "Realtime callback async support".

FRICTION NOTE (2): supabase-py does NOT normalize the Realtime payload to
`payload["new"]` / `payload["old"]` like the JS SDK does. The actual keys
are `payload["data"]["record"]` (new) and `payload["data"]["old_record"]`
(old). See friction_log.md — "Realtime payload key mismatch vs JS SDK".
"""
new_record = payload.get("data", {}).get("record", {})
if not new_record or not new_record.get("id"):
return

rules_response = await service_client.table("rules").select("*").execute()

for rule in rules_response.data:
if new_record.get(rule["watch_column"]) == rule["watch_value"]:
await (
service_client.table("rule_events")
.insert(
{
"rule_id": rule["id"],
"task_id": new_record["id"],
"payload": new_record,
}
)
.execute()
)

# FRICTION: channel.send() requires an active subscription first.
# Creating a new channel and subscribing before every broadcast is
# expensive. See friction_log.md — "Broadcast requires prior subscribe".
broadcast_channel = service_client.channel(rule["broadcast_channel"])
await broadcast_channel.subscribe()
await broadcast_channel.send(
type="broadcast",
event="rule_fired",
payload={"rule": rule, "task": new_record},
)


async def start_engine(service_client: AsyncClient) -> None:
"""
Background coroutine that holds the persistent Realtime connection.
Launched once at app startup via asyncio.create_task().
"""
channel = service_client.channel("tasks-watcher")

# FRICTION: on_postgres_changes may only accept a sync callable.
# If async callbacks are not supported, we wrap with asyncio.get_running_loop().
# Document the actual behavior in friction_log.md.
def _sync_wrapper(payload: dict) -> None:
asyncio.get_running_loop().create_task(on_task_change(service_client, payload))

channel.on_postgres_changes(
event="UPDATE",
schema="public",
table="tasks",
callback=_sync_wrapper,
)

# FRICTION: Manual connect() required before subscribe(). No auto-connect.
# See friction_log.md — "Manual connect() required before subscribe()".
await service_client.realtime.connect()
await channel.subscribe()

# Keep the coroutine alive; AsyncRealtimeClient handles WS keepalive internally.
await asyncio.Event().wait()
Loading
Loading