openapi: 3.0.3
info:
  title: DataEgress API
  version: 0.1.0
  description: >
    Control-plane API for DataEgress: register datasets, create destinations,
    run and schedule exports. See AGENTS.md and docs/quickstart.md for the
    full integration flow — this spec covers the HTTP surface only.
servers:
  - url: http://localhost:3001
    description: Local dev
paths:
  /api/datasets:
    post:
      summary: Register a dataset
      description: >
        Registers the HTTP endpoint (created via defineDataset(...).handler())
        that DataEgress will call to paginate through this dataset's rows.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, fetchUrl]
              properties:
                id: { type: string, example: "transactions" }
                fetchUrl: { type: string, format: uri, example: "https://your-app.com/api/dataegress/transactions" }
                schemaVersion: { type: string, default: "1" }
      responses:
        "200":
          description: Dataset registered (upsert — safe to call again with a new fetchUrl)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Dataset" }
        "400":
          description: Missing required field
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List registered datasets
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Dataset" }

  /api/destinations:
    post:
      summary: Create a destination
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDestinationInput" }
      responses:
        "200":
          description: Destination created (secretAccessKey is masked in the response)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Destination" }
        "400":
          description: Missing or invalid field (e.g. missing_bucket, missing_region, missing_credentials)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List destinations
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Destination" }

  /api/schedules:
    post:
      summary: Create a recurring schedule
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateScheduleInput" }
      responses:
        "201":
          description: Schedule created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Schedule" }
        "400":
          description: "Invalid input, e.g. invalid_cron: not a valid 5-field UTC cron expression"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: dataset_not_found or destination_not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List schedules
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Schedule" }

  /api/schedules/{id}:
    patch:
      summary: Enable or disable a schedule
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Schedule" }
        "404":
          description: schedule_not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/exports:
    post:
      summary: Run an export
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RunExportInput" }
      responses:
        "201":
          description: Export run created (status starts "pending")
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExportRun" }
        "400":
          description: Missing/invalid field
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: dataset_not_found or destination_not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      summary: List export runs (newest first)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ExportRun" }

  /api/exports/{id}:
    get:
      summary: Get an export run's status/manifest
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExportRun" }
        "404":
          description: export_not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/exports/{id}/retry:
    post:
      summary: Retry a failed export run
      description: >
        Only valid when status is "failed". Re-runs generate-file from the
        start; delivery overwrites the same destination key, so retrying
        never duplicates the delivered file.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — run reset to "pending" and re-queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExportRun" }
        "404":
          description: export_not_found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: not_retryable — run status isn't "failed"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  schemas:
    Error:
      type: object
      properties:
        error: { type: string, example: "missing_bucket" }
        message: { type: string, example: "S3 destination is missing `bucket`. Set `bucket` when creating the destination." }

    Dataset:
      type: object
      properties:
        id: { type: string }
        fetchUrl: { type: string }
        schemaVersion: { type: string }
        createdAt: { type: string, format: date-time }

    CreateDestinationInput:
      type: object
      required: [tenantId, type]
      properties:
        id: { type: string, description: "optional, auto-generated if omitted" }
        tenantId: { type: string }
        type: { type: string, enum: [s3, signed_url] }
        s3:
          type: object
          description: required when type is "s3"
          required: [bucket, region, accessKeyId, secretAccessKey]
          properties:
            bucket: { type: string }
            region: { type: string, example: "us-east-1" }
            prefix: { type: string }
            accessKeyId: { type: string }
            secretAccessKey: { type: string, format: password }
            endpoint: { type: string, description: "S3-compatible endpoint override, e.g. Cloudflare R2. Omit for real AWS S3." }

    Destination:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string }
        type: { type: string, enum: [s3, signed_url] }
        config:
          type: object
          description: secretAccessKey is always masked as "••••••••" in responses
        createdAt: { type: string, format: date-time }

    CreateScheduleInput:
      type: object
      required: [tenantId, dataset, destination, format, cron]
      properties:
        tenantId: { type: string }
        dataset: { type: string }
        destination: { type: string }
        format: { type: string, enum: [csv, parquet] }
        cron: { type: string, example: "0 2 * * *", description: "5-field cron, evaluated in UTC" }

    Schedule:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string }
        datasetId: { type: string }
        destinationId: { type: string }
        format: { type: string, enum: [csv, parquet] }
        cron: { type: string }
        enabled: { type: boolean }
        lastRunAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }

    RunExportInput:
      type: object
      required: [tenantId, dataset, destination, format]
      properties:
        tenantId: { type: string }
        dataset: { type: string }
        destination: { type: string }
        format: { type: string, enum: [csv, parquet] }

    ExportManifestFile:
      type: object
      properties:
        path: { type: string }
        rows: { type: integer }
        bytes: { type: integer }
        checksum: { type: string, example: "sha256:..." }

    ExportManifest:
      type: object
      properties:
        export_id: { type: string }
        status: { type: string }
        rows: { type: integer }
        schema_version: { type: string }
        checksum: { type: string }
        generated_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        files:
          type: array
          items: { $ref: "#/components/schemas/ExportManifestFile" }

    ExportRun:
      type: object
      properties:
        id: { type: string, example: "exp_9f2a1c" }
        dataset: { type: string }
        tenantId: { type: string }
        destination: { type: string }
        format: { type: string, enum: [csv, parquet] }
        status: { type: string, enum: [pending, running, completed, failed] }
        scheduleId: { type: string, nullable: true, description: "set when triggered by a schedule rather than a manual runExport" }
        startedAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        rowCount: { type: integer, nullable: true }
        fileCount: { type: integer, nullable: true }
        bytes: { type: integer, nullable: true }
        schemaVersion: { type: string, nullable: true }
        checksum: { type: string, nullable: true }
        error: { type: string, nullable: true }
        errorStage: { type: string, nullable: true, example: "generating_file" }
        rowsProcessedAtFailure: { type: integer, nullable: true }
        idempotencyKey: { type: string }
        manifest:
          allOf:
            - { $ref: "#/components/schemas/ExportManifest" }
          nullable: true
        downloadUrl: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
