Skip to content

# SSE Subscriptions

Provisa supports real-time push over Server-Sent Events (SSE). Clients receive a stream of change events without polling. (REQ-258)

## Sources

Subscriptions target a registered table:

Source | `strategy` values available
--- | ---
Table (PostgreSQL) | `native` (LISTEN/NOTIFY), `poll`
Table (non-PG RDBMS with a source `cdc` block) | `debezium`, `kafka`, `poll`
Table (federated view / any other source) | `poll` only

### PostgreSQL trigger auto-installation

Provisa automatically installs `AFTER INSERT OR UPDATE OR DELETE` triggers on all pre-approved PostgreSQL tables at startup. (REQ-565) These triggers call `pg_notify('provisa_{table}', ...)` so that raw DML (not just Provisa mutations) is picked up by subscriptions. (REQ-565)

If trigger installation fails (e.g. insufficient privilege — the database role must own the table), Provisa falls back to watermark polling for that table, provided a `watermark_column` is configured. (REQ-566) A warning is logged. (REQ-566)

### Cross-datasource view subscriptions

For views that join multiple datasources via the federation engine, add a `watermark_column` to the table registration. (REQ-260, REQ-283) The column must exist in the view SQL (it need not appear in the GraphQL schema):

    -- Example: federated view with derived watermark
    CREATE OR REPLACE VIEW orders_with_segments AS
    SELECT o.*, s.name AS segment_name,
           GREATEST(o.updated_at, s.updated_at) AS _watermark
    FROM postgresql.public.orders o
    JOIN mysql.crm.customer_segments s ON o.customer_id = s.customer_id;

Register with `watermark_column: _watermark`. Provisa polls using `WHERE _watermark > <last_seen>`. (REQ-260)

### Nested relationship subscriptions

When the subscription field selects fields from joined tables (via registered relationships), Provisa watches all involved physical tables simultaneously. (REQ-567) A change to any joined table re-fires the subscription query. (REQ-567)

## Endpoint

Subscribe to a table:

    GET /data/subscribe/{table}
    Accept: text/event-stream

The connection stays open and emits one JSON event per change: (REQ-258, REQ-568)

    data: {"event":"insert","table":"orders","row":{"id":43,"amount":55.00,"region":"east"}}

    data: {"event":"update","table":"orders","row":{"id":42,"amount":199.00,"region":"west"}}

## Delivery Modes

Delivery is selected by `live.strategy` on the table config: (REQ-813, REQ-814)

`strategy` | Mechanism | Available for | Requires
--- | --- | --- | ---
`native` | PostgreSQL `LISTEN`/`NOTIFY`, MongoDB Change Streams | PG, MongoDB | Nothing extra
`debezium` | Kafka topic from Debezium connector | Non-PG RDBMS tables | Source-level `cdc` block (Debezium + Kafka)
`kafka` | Arbitrary Kafka delta topic | Any Kafka-fed table | Source-level `cdc` block
`poll` | Watermark-based polling | Any table with a watermark | `watermark_column`

### LISTEN/NOTIFY

Provisa issues `LISTEN <channel>` on a persistent PG connection. (REQ-258) Provisa mutations fire `NOTIFY` automatically. (REQ-565) External writers must call `NOTIFY <channel>, '<payload>'` after writes. No additional infrastructure required.

### Polling

Provisa re-executes the source query periodically, selecting only rows where `watermark_column > last_watermark`. (REQ-260) Diffs are emitted as SSE events. Poll cannot see hard deletes — a removed row leaves no advancing watermark. To make a delete visible, use a soft delete (e.g. set a `deleted_at` flag) that bumps the watermark column; the delete then arrives as an update event carrying the soft-delete marker. (REQ-260)

Table poll config (in `provisa.yaml`):

    tables:
      - id: federated_orders
        source_id: federated-source
        live:
          strategy: poll
          watermark_column: updated_at
          poll_interval: 30
          outputs:
            - type: sse

### Debezium CDC

Requires a running Debezium connector writing to Kafka. (REQ-261) Provisa consumes the Kafka topic and forwards change events to connected SSE clients. (REQ-261)

CDC transport is configured once per source in a `cdc` block; topics are derived as `{topic_prefix}.{schema}.{table}` and never repeated per table. (REQ-824) Each table then selects `strategy: debezium`:

    sources:
      - id: sales-mysql
        cdc:
          bootstrap_servers: kafka:9092
          topic_prefix: debezium
          # schema_registry_url: http://schema-registry:8081   # set for Avro; omit for JSON
        tables:
          - id: orders
            live:
              strategy: debezium

## Kafka Sink Redirect

Any GraphQL subscription can be redirected to a Kafka topic instead of streaming back to the client. (REQ-812) Add the `X-Provisa-Sink` header to the subscription request:

    POST /data/graphql
    Authorization: Bearer <token>
    Content-Type: application/json
    X-Provisa-Sink: kafka://broker:9092/my-topic

The server responds `202 Accepted` immediately and starts a background task that: (REQ-812) 1. Watches for table changes using the same provider resolution as SSE (LISTEN/NOTIFY → asyncpg poll → federated poll) 2. Re-executes the equivalent query on each change 3. Publishes the result as a JSON message to the named Kafka topic

The sink runs for the lifetime of the server process. (REQ-812) Restart the server to stop it (persistent sink registration via the admin API is planned).

URI format: `kafka://[broker:port]/topic`

- If `broker:port` is omitted, `KAFKA_BOOTSTRAP_SERVERS` env var is used (default: `localhost:9092`) (REQ-812)
- `topic` is required

Example (curl):

    curl -X POST http://localhost:8000/data/graphql \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -H "X-Provisa-Sink: kafka://kafka:9092/orders-live" \
      -d '{"query": "subscription { orders { id status amount } }"}'
    # → 202 {"status":"streaming","sink":"kafka://kafka:9092/orders-live","table":"orders"}

### Kafka Sink as a Config-Level Second Output

A poll-based table subscription can simultaneously publish to a Kafka topic via `provisa.yaml`. (REQ-282, REQ-286) SSE subscription and Kafka sink are both outputs of the same Live Query Engine. (REQ-282) Each output tracks its watermark independently. (REQ-286)

    tables:
      - id: active-orders
        live:
          strategy: poll
          watermark_column: updated_at
          poll_interval: 30
          outputs:
            - type: sse
            - type: kafka
              topic: provisa.active-orders
              bootstrap_servers: kafka:9092
              key_column: id

See Kafka Sinks for full sink configuration reference.

## Security

All subscription modes enforce the same security pipeline as regular queries: (REQ-258, REQ-038)

- RLS filters are applied to every emitted row (REQ-040)
- Masked columns appear masked in events (REQ-040)
- Role authorization is checked at connection time (REQ-258)

## Client Example

    // Table subscription (LISTEN/NOTIFY)
    const source = new EventSource('/data/subscribe/orders', {
      headers: { 'Authorization': 'Bearer <token>' }
    });

    source.onmessage = (e) => {
      const event = JSON.parse(e.data);
      console.log(event.event, event.row);
    };