Streams are durable, PostgreSQL-backed records that represent on-chain payment streams from the
Stellar Soroban contract. All list / get / create / cancel operations go through
src/db/repositories/streamRepository.ts — there is no in-memory fallback.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | Derived from stream-{txHash}-{eventIndex} |
sender_address |
TEXT | NOT NULL | Stellar address of the sender |
recipient_address |
TEXT | NOT NULL | Stellar address of the recipient |
amount |
TEXT | NOT NULL, CHECK decimal-string | Total deposit amount (decimal string) |
streamed_amount |
TEXT | NOT NULL DEFAULT '0', CHECK decimal-string | Amount streamed so far |
remaining_amount |
TEXT | NOT NULL, CHECK decimal-string | Remaining amount |
rate_per_second |
TEXT | NOT NULL, CHECK decimal-string | Streaming rate per second |
start_time |
BIGINT | NOT NULL, CHECK >= 0 | Unix epoch seconds |
end_time |
BIGINT | NOT NULL DEFAULT 0, CHECK >= 0 | Unix epoch seconds (0 = indefinite) |
status |
TEXT | NOT NULL DEFAULT 'active', CHECK enum | active / paused / completed / cancelled |
contract_id |
TEXT | NOT NULL | Soroban contract ID |
transaction_hash |
TEXT | NOT NULL | Transaction hash that created the stream |
event_index |
INTEGER | NOT NULL | Event index within the transaction |
created_at |
TIMESTAMPTZ | NOT NULL DEFAULT NOW() | Record creation timestamp |
updated_at |
TIMESTAMPTZ | NOT NULL DEFAULT NOW() | Last update timestamp |
Unique constraint: (transaction_hash, event_index) — guarantees idempotent event ingestion.
Composite indexes match the filter+order patterns issued by streamRepository:
| Index | Column(s) | Repository method | Query pattern |
|---|---|---|---|
idx_streams_status_id |
(status, id) |
findWithCursor |
WHERE status = $1 … ORDER BY id ASC |
idx_streams_sender_id |
(sender_address, id) |
findWithCursor |
WHERE sender_address = $1 … ORDER BY id ASC |
idx_streams_contract_id |
(contract_id, id) |
findWithCursor |
WHERE contract_id = $1 … ORDER BY id ASC |
idx_streams_status_created_at_desc |
(status, created_at DESC) |
find |
WHERE status = $1 ORDER BY created_at DESC |
idx_streams_recipient |
recipient_address |
findWithCursor |
Recipient filter (single-column) |
idx_streams_created_at |
created_at |
find |
Unfiltered ORDER BY created_at DESC |
idx_streams_start_time |
start_time |
find |
start_time range filters |
idx_streams_end_time |
end_time |
find |
end_time range filters |
Existing deployments receive these indexes via migrations/20260622000000_streams_composite_pagination_indexes.ts
using CREATE INDEX CONCURRENTLY (non-transactional migration — no write locks during build).
Redundant single-column indexes (idx_streams_status, idx_streams_sender, idx_streams_contract) are dropped
once the composites exist because their leading column is a left-prefix of the new indexes.
Security: Index additions do not change which rows are visible. Address columns are equality-filtered only; no index sorts by encrypted/hashed address values, so no timing side-channel is introduced.
All monetary amounts cross the chain/API boundary as decimal strings (e.g. "1000",
"0.0000116"). This prevents JSON floating-point precision loss.
- DB CHECK constraints enforce the format at the storage layer.
- Zod schemas (
src/validation/schemas.ts) enforce it at the HTTP boundary. src/serialization/decimal.tsprovides helpers for validation and display.- Numeric types in request bodies are rejected with 400.
The HTTP API uses camelCase; the database uses snake_case. The toApiStream() function in
src/routes/streams.ts performs the mapping:
| API field | DB column |
|---|---|
id |
id |
sender |
sender_address |
recipient |
recipient_address |
depositAmount |
amount |
ratePerSecond |
rate_per_second |
startTime |
start_time |
endTime |
end_time |
status |
status |
active ──► paused ──► active
│ │
▼ ▼
completed cancelled (terminal)
| From \ To | active | paused | completed | cancelled |
|---|---|---|---|---|
| active | ✗ | ✓ | ✓ | ✓ |
| paused | ✓ | ✗ | ✗ | ✓ |
| completed | ✗ | ✗ | ✗ | ✗ |
| cancelled | ✗ | ✗ | ✗ | ✗ |
completed and cancelled are terminal — no further transitions are permitted.
Invalid transitions return 409 CONFLICT:
{
"success": false,
"error": {
"code": "CONFLICT",
"message": "Stream is already completed and cannot be transitioned",
"details": { "streamId": "...", "currentStatus": "completed" }
}
}List streams with cursor-based pagination.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Results per page (1–100) |
cursor |
string | — | Opaque pagination token from previous response |
include_total |
boolean | false | Include total count (may be expensive) |
Response:
{
"success": true,
"data": {
"streams": [...],
"has_more": true,
"next_cursor": "<opaque>",
"total": 100
},
"meta": { "timestamp": "..." }
}totalis only present wheninclude_total=true. It reflects the current count at response time and is not a snapshot guarantee.next_cursoris only present whenhas_moreistrue.
Indexed filters:
The status, sender, and contract_id cursor filters use composite indexes
(idx_streams_status_id, idx_streams_sender_id, idx_streams_contract_id) so filtered pages
avoid full-table sorts. The recipient filter uses idx_streams_recipient. Offset pagination with
a status filter uses idx_streams_status_created_at_desc. Combining filters with cursor pagination
is safe — the cursor position is relative to the filtered result set.
Get a single stream by ID.
Responses: 200 OK | 404 NOT_FOUND | 503 SERVICE_UNAVAILABLE
Check whether a stream exists without fetching the full record.
Success response: 200 OK
Headers:
ETag- Weak validator derived from the stream ID and last update timestampLast-Modified- Timestamp of the last DB update for the stream
Responses: 200 OK | 404 NOT_FOUND | 503 SERVICE_UNAVAILABLE
Create a new stream. Requires Authorization: Bearer <token> and Idempotency-Key header.
Request body:
{
"sender": "G...",
"recipient": "G...",
"depositAmount": "1000",
"ratePerSecond": "10",
"startTime": 1700000000,
"endTime": 0
}Responses: 201 Created | 400 VALIDATION_ERROR | 401 UNAUTHORIZED | 409 CONFLICT | 503 SERVICE_UNAVAILABLE
Cancel a stream. Requires authentication.
Responses: 200 OK | 404 NOT_FOUND | 409 CONFLICT | 503 SERVICE_UNAVAILABLE
Transition a stream to a new status.
Request body: { "status": "paused" | "active" | "completed" | "cancelled" }
Responses: 200 OK | 400 VALIDATION_ERROR | 404 NOT_FOUND | 409 CONFLICT | 503 SERVICE_UNAVAILABLE
POST /api/streams requires an Idempotency-Key header (1–128 chars, [A-Za-z0-9:_-]).
- First request: creates the stream, returns
201, setsIdempotency-Replayed: false. - Repeat with same key + same body: returns cached
201, setsIdempotency-Replayed: true. - Same key + different body: returns
409 CONFLICT.
The idempotency store is currently in-memory (Redis-backed in production).
At the DB layer, upsertStream uses INSERT … ON CONFLICT DO NOTHING on
(transaction_hash, event_index) for safe blockchain event replay.
| Client Type | Access | Auth required |
|---|---|---|
| Public users | GET /api/streams, GET /api/streams/:id, HEAD /api/streams/:id |
No |
| Authenticated users | POST, DELETE, PATCH /status |
JWT Bearer |
| Internal workers | Indexer ingestion via upsertStream |
Service account |
| Scenario | HTTP Status | Error Code |
|---|---|---|
| Invalid input | 400 | VALIDATION_ERROR |
| Missing auth | 401 | UNAUTHORIZED |
| Stream not found | 404 | NOT_FOUND |
| Invalid transition | 409 | CONFLICT |
| Idempotency conflict | 409 | CONFLICT |
| DB pool exhausted | 503 | SERVICE_UNAVAILABLE |
| Dependency down | 503 | SERVICE_UNAVAILABLE |
# All tests
pnpm test
# With coverage (target ≥ 95%)
pnpm test:coverage
# Specific suites
pnpm test tests/routes/streams.test.ts
pnpm test tests/streamsRepository.test.ts| File | Purpose |
|---|---|
src/db/types.ts |
TypeScript types for DB records |
src/db/pool.ts |
PostgreSQL connection pool + query helper |
src/db/migrations/001_create_streams_table.ts |
PostgreSQL DDL migration |
src/db/repositories/streamRepository.ts |
All DB operations for streams |
src/routes/streams.ts |
HTTP route handlers |
src/validation/schemas.ts |
Zod input validation schemas |
src/serialization/decimal.ts |
Decimal-string helpers |
tests/routes/streams.test.ts |
Route integration tests (mocked DB) |
tests/streamsRepository.test.ts |
Repository unit tests (mocked pool) |