Files
suaveplan/openspec/changes/add-import-sse-progress/specs/api-plugin-import/spec.md
2026-05-27 23:29:53 +01:00

4.9 KiB
Raw Blame History

Spec Delta: api-plugin-import — add SSE progress push

ADDED Requirements

Requirement: SSE progress push — GET /imports/:id/progress

The package SHALL expose GET /imports/:id/progress returning Content-Type: text/event-stream; charset=utf-8, mounted under the existing requireAuth() middleware. Frames SHALL be driven directly off the existing ImportService.bus events (import.progress, import.completed, import.failed); NO polling loop over the store is permitted. Every frame SHALL carry the canonical ImportProgress JSON payload. Three SSE event kinds SHALL be emitted: progress (every import.progress tick), complete (terminal, on import.completed), and failed (terminal, on import.failed). The stream SHALL close cleanly after a terminal event. The handler SHALL respect the request's AbortSignal (client disconnect) by detaching the bus listener and incrementing import.sse.disconnected{reason: "client"}. Foreign-owner or missing-job lookups SHALL return HTTP 404 RFC 7807 problem+json with code === IMPORT.NOT_FOUND (anti-enumeration parity with GET /:id). Anonymous requests SHALL return HTTP 401 problem+json via the existing auth middleware.

The package SHALL extend getImportInstruments() with three additional counters: import.sse.connected (Counter), import.sse.events_sent (Counter, labelled by kind ∈ "progress" | "complete" | "failed"), import.sse.disconnected (Counter, labelled by reason ∈ "complete" | "failed" | "client"). The counters SHALL be cached on the same bundle as the existing six instruments and SHALL rebind through resetImportInstruments().

Scenario: opens a text/event-stream and emits an initial progress frame

  • WHEN an authenticated caller GET /imports/:id/progress for a job they own
  • THEN the response is HTTP 200 with content-type: text/event-stream; charset=utf-8 and the first frame is event: progress\ndata: <ImportProgress JSON>\n\n

Scenario: emits a progress frame for every bus progress event

  • WHEN the service emits import.progress while the client is connected
  • THEN the stream writes a frame whose first non-id line is event: progress and whose data: line decodes to an ImportProgress object with the matching processedRows

Scenario: emits a complete frame and closes on import.completed

  • WHEN the service emits import.completed
  • THEN the stream writes a frame whose first non-id line is event: complete and then closes; subsequent reads from the stream return done: true

Scenario: emits a failed frame and closes on import.failed

  • WHEN the service emits import.failed
  • THEN the stream writes a frame whose first non-id line is event: failed and then closes

Scenario: returns 404 problem+json for a foreign-owner job

  • WHEN alice owns job j1 and bob requests GET /imports/j1/progress
  • THEN the response is HTTP 404 with content-type: application/problem+json and a body whose code === "IMPORT.NOT_FOUND"

Scenario: returns 401 problem+json when the caller is anonymous

  • WHEN an anonymous caller requests GET /imports/:id/progress
  • THEN the response is HTTP 401 with content-type: application/problem+json

Scenario: detaches the listener and increments disconnect counter on client abort

  • WHEN the client aborts the request before a terminal event
  • THEN the handler unsubscribes from the bus and increments import.sse.disconnected{reason:"client"}

Scenario: increments import.sse counters on connect, send, and disconnect

  • WHEN a connection opens, two progress events fire, and a terminal complete event closes the stream
  • THEN import.sse.connected increments by 1, import.sse.events_sent{kind} increments by 3 (2× progress + 1× complete), and import.sse.disconnected{reason:"complete"} increments by 1

Verified by: src/routes/progress-sse/progress-sse.test.ts::"opens a text/event-stream and emits an initial progress frame" AND src/routes/progress-sse/progress-sse.test.ts::"emits a progress frame for every bus progress event" AND src/routes/progress-sse/progress-sse.test.ts::"emits a complete frame and closes on import.completed" AND src/routes/progress-sse/progress-sse.test.ts::"emits a failed frame and closes on import.failed" AND src/routes/progress-sse/progress-sse.test.ts::"returns 404 problem+json for a foreign-owner job" AND src/routes/progress-sse/progress-sse.test.ts::"returns 401 problem+json when the caller is anonymous" AND src/routes/progress-sse/progress-sse.test.ts::"detaches the listener and increments disconnect counter on client abort" AND src/routes/progress-sse/progress-sse.test.ts::"increments import.sse counters on connect, send, and disconnect" AND src/otel/instruments.test.ts::"bundle exposes the SSE counters"