Problem
Deleting a stream leaves its stream_data and stream_events rows behind, and the worker keeps polling and processing events for deleted streams forever. The schema (database/schema.sql) declares the foreign keys without cascades:
CREATE TABLE IF NOT EXISTS stream_data (
id SERIAL PRIMARY KEY,
stream_id INTEGER NOT NULL REFERENCES streams(id), -- ← no ON DELETE CASCADE
...
);
CREATE TABLE IF NOT EXISTS stream_events (
id SERIAL PRIMARY KEY,
stream_id INTEGER NOT NULL REFERENCES streams(id), -- ← no ON DELETE CASCADE
...
);
(Compare stream_tags, webhook_subscriptions, and notifications, which all use ON DELETE CASCADE.) StreamsDbRepository.delete() (api/src/streams/repository/streams-db.repository.ts) issues a bare DELETE FROM streams WHERE id = $1. getPendingEvents() reads stream_data with no join to streams, so orphaned rows keep being returned:
SELECT stream_id, data, timestamp
FROM stream_data
ORDER BY timestamp ASC
LIMIT $1 OFFSET $2 -- ← no filter on the stream still existing
Consequence: after DELETE /streams/:id (which returns 204 and the dashboard clears the stream), the worker continues to fetch and route the deleted stream's events — spawning sessions, acquiring locks, and publishing — against a stream that no longer exists. Orphaned stream_events rows also survive, so future analytics/replay work that scans by stream_id accumulates dead rows, and the idx_stream_data_stream_id index can never help prune them. The in-memory repository's delete() (api/src/streams/repository/streams.repository.ts) does remove eventsByStream, so the two implementations already disagree about what delete means.
Root cause
-- database/schema.sql
stream_id INTEGER NOT NULL REFERENCES streams(id) -- ← no ON DELETE CASCADE on stream_data / stream_events
-- api/src/streams/repository/streams-db.repository.ts — delete()
DELETE FROM streams WHERE id = $1 -- ← leaves stream_data and stream_events rows orphaned
Why this is architecturally hard
- There are two viable fixes with different blast radius: (a) a migration adding
ON DELETE CASCADE to both tables (and optionally to the stream_locks-style worker state), which is schema-level and self-pruning; or (b) an application-level cleanup transaction in StreamsDbRepository.delete() that deletes stream_data, stream_events, and any webhook rows before the stream row. The schema fix is simpler but changes delete semantics for any future row type referencing streams; the app-level fix keeps the schema explicit but must be repeated per repository and risks forgetting a table. Pick one and document it.
- Deleting a stream while the worker holds a lock/session for it is a concurrent hazard: the worker may be mid-publish when the stream disappears. The lock manager (
xstreamroll-processing/src/leader-election.ts) keys locks by streamId strings and has no knowledge of deletion; the drain endpoint (separate issue) must tolerate a stream that vanished mid-processing.
getPendingEvents returns events without validating the stream exists, which is the direct cause of the worker loop. Even with cascades, the query should join or filter on stream existence so a mid-delete race cannot serve rows for a half-deleted stream.
- This is a correctness/data-integrity boundary: the platform's event log is meant to be the durable record (
stream_events, GET /streams/:id/events), and silently dropping those rows on delete is a product decision (retention vs purge) that should be stated in the acceptance criteria.
Acceptance criteria
Data integrity
Worker
Tests
Documentation
Out of scope
Event-log retention policies for live streams, and tombstoning/soft-delete of streams.
Getting started
Real files in scope: database/schema.sql, database/migrations/ (new migration), api/src/streams/repository/streams-db.repository.ts (delete, getPendingEvents), api/src/streams/repository/streams.repository.ts (in-memory delete), api/src/database.integration.spec.ts, xstreamroll-processing/src/worker.ts (poll loop tolerance).
Verify with:
cd api && npm run typecheck && npm test
Good first files to read: database/schema.sql (the stream_data/stream_events FK definitions), api/src/streams/repository/streams-db.repository.ts (delete, getPendingEvents).
Problem
Deleting a stream leaves its
stream_dataandstream_eventsrows behind, and the worker keeps polling and processing events for deleted streams forever. The schema (database/schema.sql) declares the foreign keys without cascades:(Compare
stream_tags,webhook_subscriptions, andnotifications, which all useON DELETE CASCADE.)StreamsDbRepository.delete()(api/src/streams/repository/streams-db.repository.ts) issues a bareDELETE FROM streams WHERE id = $1.getPendingEvents()readsstream_datawith no join tostreams, so orphaned rows keep being returned:Consequence: after
DELETE /streams/:id(which returns 204 and the dashboard clears the stream), the worker continues to fetch and route the deleted stream's events — spawning sessions, acquiring locks, and publishing — against a stream that no longer exists. Orphanedstream_eventsrows also survive, so future analytics/replay work that scans bystream_idaccumulates dead rows, and theidx_stream_data_stream_idindex can never help prune them. The in-memory repository'sdelete()(api/src/streams/repository/streams.repository.ts) does removeeventsByStream, so the two implementations already disagree about what delete means.Root cause
Why this is architecturally hard
ON DELETE CASCADEto both tables (and optionally to thestream_locks-style worker state), which is schema-level and self-pruning; or (b) an application-level cleanup transaction inStreamsDbRepository.delete()that deletesstream_data,stream_events, and any webhook rows before the stream row. The schema fix is simpler but changes delete semantics for any future row type referencingstreams; the app-level fix keeps the schema explicit but must be repeated per repository and risks forgetting a table. Pick one and document it.xstreamroll-processing/src/leader-election.ts) keys locks bystreamIdstrings and has no knowledge of deletion; the drain endpoint (separate issue) must tolerate a stream that vanished mid-processing.getPendingEventsreturns events without validating the stream exists, which is the direct cause of the worker loop. Even with cascades, the query should join or filter on stream existence so a mid-delete race cannot serve rows for a half-deleted stream.stream_events,GET /streams/:id/events), and silently dropping those rows on delete is a product decision (retention vs purge) that should be stated in the acceptance criteria.Acceptance criteria
Data integrity
DELETE /streams/:id, nostream_data,stream_events, or other stream-referencing rows remain for that stream (per the chosen cascade or cleanup strategy), verified against the test database.cd api && npm run migrate.StreamsRepositoryand the DB-backedStreamsDbRepositoryimplement the same delete semantics.Worker
GET /streams/pendingno longer returns rows for streams that do not exist (join/filter onstreams), so a worker cannot process a deleted stream's events.Tests
stream_dataandstream_eventsrows, delete the stream, assert the tables hold no rows for thatstream_id.GET /streams/pendingexcludes rows whose stream was deleted mid-poll.Documentation
DELETE /streams/:idSwagger description and the schema comment.Out of scope
Event-log retention policies for live streams, and tombstoning/soft-delete of streams.
Getting started
Real files in scope:
database/schema.sql,database/migrations/(new migration),api/src/streams/repository/streams-db.repository.ts(delete,getPendingEvents),api/src/streams/repository/streams.repository.ts(in-memory delete),api/src/database.integration.spec.ts,xstreamroll-processing/src/worker.ts(poll loop tolerance).Verify with:
Good first files to read:
database/schema.sql(thestream_data/stream_eventsFK definitions),api/src/streams/repository/streams-db.repository.ts(delete,getPendingEvents).