feat(ui-api): cap the off-spec calls, vendor the ESB extension, CODEOWNERS (#107)

- OFF_SPEC_CEILING (45) caps OFF_SPEC_CALLS; the contract test fails if it grows.
- Vendor the qqq-esb extension document (extensions/esb.json, 1.0.0): the five
  ESB calls are now spec'd and leave the off-spec list.
- CODEOWNERS on src/lib/api/openapi/; CONTRIBUTING documents the merge order
  (extension, then vendored document, then UI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 5.5 committed 2026-10-13 20:04:50 -05:00
1 parent 35249098bb
commit 4376c28896
5 files changed
+773 -36

No files matched your search

+3
View File
@@ -1,2 +1,5 @@
# Default reviewers for every change.
* @KofTwentyTwo
# The UI API contract (QRun-IO/qqq-frontend-next#107): the vendored documents and the off-spec list
/src/lib/api/openapi/ @KofTwentyTwo
+48 -34
View File
@@ -1,37 +1,51 @@
# Contributing
# Contributing
## Branching: trunk-based on `main`
`main` is the only long-lived branch. There is no `develop`, `release/*` or `hotfix/*` branch.
1. Branch from the current `main` (`feature/...`, `fix/...`, `docs/...`, `chore/...`).
2. Keep the branch current by merging `main` into it; do not rebase or force-push a branch
that is under review.
3. Open a pull request into `main`. Every commit must be signed.
4. A pull request merges when the required checks pass and the code owner approves.
Head branches are deleted automatically after the merge.
### Required checks
| Check | Source |
|-------|--------|
| `Typecheck, lint, unit tests and coverage` | `.github/workflows/ci.yml` |
| `Mocked e2e (Playwright, chromium)` | `.github/workflows/ci.yml` |
| `Bundle budget (static export)` | `.github/workflows/ci.yml` |
| `Storybook build` | `.github/workflows/ci.yml` |
| `ci/circleci: qqq-orb/node_app_test_only` | `.circleci/config.yml` |
The real-backend acceptance matrix (`.github/workflows/acceptance.yml`) runs on every pull request
and on `main`. Treat a red row as a release blocker and investigate it; never re-run it until it passes.
## Releases
A release is a signed `v<version>` tag on `main` (for example `v1.0.0-RC.14`), where `package.json`
and `pom.xml` carry that version. The tag publishes the dashboard jar to Maven Central (CircleCI
`publish_dashboard_jar`) and the dashboard container image (`.github/workflows/dashboard-image.yml`).
Release notes live in `docs/releases/`.
## Dependencies
Dependabot opens grouped weekly updates for npm, GitHub Actions and Maven (`.github/dependabot.yml`),
plus security updates as they are published.
## Branching: trunk-based on `main`
## UI API calls (merge order)
Next calls the backend only through operations described in the vendored UI API documents:
`src/lib/api/openapi/qqq-v1.json`, plus one JSON file per extension in `extensions/`.
`ui-api-contract.test.ts` fails on any other call.
`main` is the only long-lived branch. There is no `develop`, `release/*` or `hotfix/*` branch.
A feature that needs new backend calls lands in this order:
1. The qbit or module ships the operations as a UI API extension, with spec classes and its
committed `openapi/qqq-v1/<name>.yaml`.
2. That document is vendored here, as `extensions/<name>.json` or a re-vendored `qqq-v1.json`.
3. The UI PR that calls them.
1. Branch from the current `main` (`feature/...`, `fix/...`, `docs/...`, `chore/...`).
2. Keep the branch current by merging `main` into it; do not rebase or force-push a branch
that is under review.
3. Open a pull request into `main`. Every commit must be signed.
4. A pull request merges when the required checks pass and the code owner approves.
Head branches are deleted automatically after the merge.
### Required checks
| Check | Source |
|-------|--------|
| `Typecheck, lint, unit tests and coverage` | `.github/workflows/ci.yml` |
| `Mocked e2e (Playwright, chromium)` | `.github/workflows/ci.yml` |
| `Bundle budget (static export)` | `.github/workflows/ci.yml` |
| `Storybook build` | `.github/workflows/ci.yml` |
| `ci/circleci: qqq-orb/node_app_test_only` | `.circleci/config.yml` |
The real-backend acceptance matrix (`.github/workflows/acceptance.yml`) runs on every pull request
and on `main`. Treat a red row as a release blocker and investigate it; never re-run it until it passes.
## Releases
A release is a signed `v<version>` tag on `main` (for example `v1.0.0-RC.14`), where `package.json`
and `pom.xml` carry that version. The tag publishes the dashboard jar to Maven Central (CircleCI
`publish_dashboard_jar`) and the dashboard container image (`.github/workflows/dashboard-image.yml`).
Release notes live in `docs/releases/`.
## Dependencies
Dependabot opens grouped weekly updates for npm, GitHub Actions and Maven (`.github/dependabot.yml`),
plus security updates as they are published.
`OFF_SPEC_CALLS` in `ui-api-calls.ts` is a shrinking list of calls that predate the extension point.
`OFF_SPEC_CEILING` caps it: lower the ceiling as entries move onto specs, and never raise it.
+709
View File
@@ -0,0 +1,709 @@
{
"components": {
"schemas": {
"EsbCapabilities": {
"description": "What the provider supports for this destination - show only the actions that are true.",
"properties": {
"browse": {
"description": "Messages can be browsed.",
"type": "boolean"
},
"deleteOlderThan": {
"description": "Messages older than a time can be deleted.",
"type": "boolean"
},
"deleteSelected": {
"description": "Selected messages can be deleted.",
"type": "boolean"
},
"move": {
"description": "Messages can be moved to another destination.",
"type": "boolean"
},
"pauseQueue": {
"description": "The queue can be paused and resumed.",
"type": "boolean"
},
"purge": {
"description": "All messages can be deleted.",
"type": "boolean"
}
},
"type": "object"
},
"EsbCounter": {
"description": "Activity counters since the server started.",
"properties": {
"avgMs": {
"description": "Average processing time in milliseconds.",
"type": "integer"
},
"consumed": {
"description": "Messages consumed.",
"type": "integer"
},
"deadLettered": {
"description": "Messages moved to the dead-letter destination.",
"type": "integer"
},
"failed": {
"description": "Consumed messages that failed.",
"type": "integer"
},
"inFlight": {
"description": "Messages being processed now.",
"type": "integer"
},
"lastActivity": {
"description": "Time of the last activity (ISO-8601), or null.",
"format": "date-time",
"nullable": true,
"type": "string"
},
"lastError": {
"description": "The last error message, or null.",
"nullable": true,
"type": "string"
},
"maxMs": {
"description": "Longest processing time in milliseconds.",
"type": "integer"
},
"publishFailures": {
"description": "Publish attempts that failed.",
"type": "integer"
},
"published": {
"description": "Messages published.",
"type": "integer"
},
"retried": {
"description": "Redeliveries.",
"type": "integer"
},
"succeeded": {
"description": "Consumed messages processed successfully.",
"type": "integer"
}
},
"type": "object"
},
"EsbDeadLetter": {
"description": "A dead-letter (or subscription) destination of a trigger.",
"properties": {
"brokerName": {
"description": "Name on the broker.",
"type": "string"
},
"messageCount": {
"description": "Messages waiting, or null when unknown.",
"nullable": true,
"type": "integer"
},
"paused": {
"description": "Whether it is paused, or null when unknown.",
"nullable": true,
"type": "boolean"
}
},
"type": "object"
},
"EsbDestination": {
"description": "A queue or topic.",
"properties": {
"brokerName": {
"description": "Name of the destination on the broker.",
"type": "string"
},
"capabilities": {
"$ref": "#/components/schemas/EsbCapabilities"
},
"counters": {
"$ref": "#/components/schemas/EsbCounter"
},
"name": {
"description": "Destination name.",
"type": "string"
},
"provider": {
"description": "Name of the provider (broker connection).",
"type": "string"
},
"queueInfo": {
"$ref": "#/components/schemas/EsbQueueInfo",
"nullable": true
},
"type": {
"description": "Destination type.",
"enum": [
"QUEUE",
"TOPIC"
],
"type": "string"
}
},
"type": "object"
},
"EsbMessage": {
"description": "A message on a destination.",
"properties": {
"deliveryCount": {
"description": "Delivery attempts so far.",
"type": "integer"
},
"event": {
"additionalProperties": true,
"description": "The CloudEvents envelope (specversion, id, source, type, time, subject, datacontenttype, data, qqqcausationid), or null when the body is not one.",
"nullable": true,
"type": "object"
},
"messageId": {
"description": "Broker message id.",
"type": "string"
},
"properties": {
"additionalProperties": true,
"description": "Message properties.",
"type": "object"
},
"rawBody": {
"description": "The message body as text.",
"type": "string"
},
"timestamp": {
"description": "When it was sent (ISO-8601).",
"format": "date-time",
"type": "string"
}
},
"type": "object"
},
"EsbMessagePageResponseV1": {
"description": "A page of messages.",
"properties": {
"hasMore": {
"description": "Whether more messages follow this page.",
"type": "boolean"
},
"messages": {
"description": "The messages, oldest first.",
"items": {
"$ref": "#/components/schemas/EsbMessage"
},
"type": "array"
}
},
"type": "object"
},
"EsbOverviewDestination": {
"allOf": [
{
"$ref": "#/components/schemas/EsbDestination"
},
{
"properties": {
"publishers": {
"description": "Tables and processes publishing to it.",
"items": {
"$ref": "#/components/schemas/EsbPublisher"
},
"type": "array"
},
"triggers": {
"description": "Triggers consuming it.",
"items": {
"$ref": "#/components/schemas/EsbTrigger"
},
"type": "array"
}
},
"type": "object"
}
],
"description": "A destination with its publishers and triggers."
},
"EsbOverviewResponseV1": {
"description": "GET /esb/overview: providers and every destination.",
"properties": {
"destinations": {
"description": "Destinations with their publishers and triggers.",
"items": {
"$ref": "#/components/schemas/EsbOverviewDestination"
},
"type": "array"
},
"permissions": {
"$ref": "#/components/schemas/EsbPermissions"
},
"providers": {
"description": "Broker connections.",
"items": {
"$ref": "#/components/schemas/EsbProvider"
},
"type": "array"
}
},
"type": "object"
},
"EsbPermissions": {
"description": "What the user may do.",
"properties": {
"canDelete": {
"description": "May delete messages.",
"type": "boolean"
},
"canOperate": {
"description": "May pause, resume and retry.",
"type": "boolean"
}
},
"type": "object"
},
"EsbProcessResponseV1": {
"description": "GET /esb/process/{process}: what a process publishes, and the triggers that run it.",
"properties": {
"permissions": {
"$ref": "#/components/schemas/EsbPermissions"
},
"process": {
"description": "Process name.",
"type": "string"
},
"publications": {
"description": "Destinations the process publishes to.",
"items": {
"$ref": "#/components/schemas/EsbPublication"
},
"type": "array"
},
"triggers": {
"description": "Triggers that run the process.",
"items": {
"$ref": "#/components/schemas/EsbTrigger"
},
"type": "array"
}
},
"type": "object"
},
"EsbProvider": {
"description": "A broker connection.",
"properties": {
"connected": {
"description": "Whether the connection is up.",
"type": "boolean"
},
"managementEnabled": {
"description": "Whether broker management (browse, pause, purge) is available.",
"type": "boolean"
},
"name": {
"description": "Provider name.",
"type": "string"
},
"type": {
"description": "Broker type.",
"enum": [
"ACTIVEMQ_ARTEMIS",
"RABBITMQ"
],
"type": "string"
}
},
"type": "object"
},
"EsbPublication": {
"description": "A destination that a table or process publishes events to.",
"properties": {
"destination": {
"$ref": "#/components/schemas/EsbDestination"
},
"events": {
"description": "Events published: INSERT, UPDATE or DELETE for tables; STARTED, COMPLETED or FAILED for processes.",
"items": {
"description": "Event name.",
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"EsbPublisher": {
"description": "A table or process that publishes to a destination.",
"properties": {
"events": {
"description": "Events published.",
"items": {
"description": "Event name.",
"type": "string"
},
"type": "array"
},
"kind": {
"description": "What publishes.",
"enum": [
"TABLE",
"PROCESS"
],
"type": "string"
},
"name": {
"description": "Table or process name.",
"type": "string"
}
},
"type": "object"
},
"EsbQueueInfo": {
"description": "Broker-reported state of a queue.",
"properties": {
"consumerCount": {
"description": "Connected consumers.",
"type": "integer"
},
"messageCount": {
"description": "Messages waiting.",
"type": "integer"
},
"paused": {
"description": "Whether the queue is paused.",
"type": "boolean"
}
},
"type": "object"
},
"EsbTableResponseV1": {
"description": "GET /esb/table/{table}: what a table publishes, and who subscribes.",
"properties": {
"permissions": {
"$ref": "#/components/schemas/EsbPermissions"
},
"publications": {
"description": "Destinations the table publishes to.",
"items": {
"$ref": "#/components/schemas/EsbPublication"
},
"type": "array"
},
"subscribers": {
"description": "Triggers subscribed to those destinations (on processes the user can access).",
"items": {
"$ref": "#/components/schemas/EsbTrigger"
},
"type": "array"
},
"table": {
"description": "Table name.",
"type": "string"
}
},
"type": "object"
},
"EsbTrigger": {
"description": "A process subscribed to a destination.",
"properties": {
"concurrency": {
"description": "Consumers run in parallel.",
"type": "integer"
},
"counters": {
"$ref": "#/components/schemas/EsbCounter"
},
"deadLetter": {
"$ref": "#/components/schemas/EsbDeadLetter"
},
"destination": {
"$ref": "#/components/schemas/EsbDestination"
},
"maxAttempts": {
"description": "Attempts before a message is dead-lettered.",
"type": "integer"
},
"mode": {
"description": "SINGLE runs the process per message; BATCH per batch of messages.",
"enum": [
"SINGLE",
"BATCH"
],
"type": "string"
},
"name": {
"description": "Trigger name.",
"type": "string"
},
"processLabel": {
"description": "User-facing label of the process.",
"type": "string"
},
"processName": {
"description": "Process the trigger runs.",
"type": "string"
},
"state": {
"description": "Current state of the trigger.",
"enum": [
"RUNNING",
"PAUSED",
"CONNECTING",
"STOPPED"
],
"type": "string"
},
"subscription": {
"$ref": "#/components/schemas/EsbDeadLetter",
"nullable": true
}
},
"type": "object"
}
},
"securitySchemes": {
"sessionUuidCookie": {
"in": "cookie",
"name": "sessionUUID",
"type": "apiKey"
}
}
},
"info": {
"contact": {
"email": "contact@kingsrook.com"
},
"description": "ESB overview, the ESB of tables and processes, and messages and dead letters on destinations.",
"title": "QQQ Middleware API extension: esb",
"version": "1.0.0"
},
"openapi": "3.0.3",
"paths": {
"/qqq/v1/esb/deadLetters/{trigger}": {
"get": {
"description": "A page of the messages on a trigger dead-letter destination, oldest first. Needs the operate permission.",
"parameters": [
{
"description": "Trigger name.",
"in": "path",
"name": "trigger",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Messages to skip (default 0).",
"in": "query",
"name": "offset",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Messages to return (default and maximum set by the server).",
"in": "query",
"name": "limit",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EsbMessagePageResponseV1"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "Dead letters of a trigger",
"tags": [
"ESB"
]
}
},
"/qqq/v1/esb/messages/{destination}": {
"get": {
"description": "A page of the messages waiting on a destination (or, with trigger, on that trigger subscription of a topic), oldest first. Needs the operate permission.",
"parameters": [
{
"description": "Destination name.",
"in": "path",
"name": "destination",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "For a topic: the trigger whose subscription to browse.",
"in": "query",
"name": "trigger",
"required": false,
"schema": {
"type": "string"
}
},
{
"description": "Messages to skip (default 0).",
"in": "query",
"name": "offset",
"required": false,
"schema": {
"type": "integer"
}
},
{
"description": "Messages to return (default and maximum set by the server).",
"in": "query",
"name": "limit",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EsbMessagePageResponseV1"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "Messages on a destination",
"tags": [
"ESB"
]
}
},
"/qqq/v1/esb/overview": {
"get": {
"description": "Every broker connection and destination, with the tables and processes publishing to each and the triggers consuming it, and what the user may do. Needs the ESB app permission.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EsbOverviewResponseV1"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "ESB overview",
"tags": [
"ESB"
]
}
},
"/qqq/v1/esb/process/{process}": {
"get": {
"description": "The destinations a process publishes its lifecycle events to, the triggers that run it, and what the user may do. Needs access to the process.",
"parameters": [
{
"description": "Process name.",
"in": "path",
"name": "process",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EsbProcessResponseV1"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "ESB of a process",
"tags": [
"ESB"
]
}
},
"/qqq/v1/esb/table/{table}": {
"get": {
"description": "The destinations a table publishes its events to, the triggers subscribed to them (on processes the user can access), and what the user may do. Needs READ on the table.",
"parameters": [
{
"description": "Table name.",
"in": "path",
"name": "table",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EsbTableResponseV1"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"sessionUuidCookie": [
"N/A"
]
}
],
"summary": "ESB of a table",
"tags": [
"ESB"
]
}
}
}
}
+8 -1
View File
@@ -43,7 +43,6 @@ export const OFF_SPEC_CALLS: OffSpecCall[] = [
'GET /assistant/turns/{}/events', 'POST /assistant/turns/{}/cancel', 'POST /assistant/messages/{}/feedback',
'GET /assistant/actions/{}', 'POST /assistant/actions/{}/confirm', 'POST /assistant/actions/{}/reject',
].map((c) => call(c, 'qbit-assistant'))),
...(['GET /esb/overview', 'GET /esb/table/{}', 'GET /esb/process/{}', 'GET /esb/messages/{}', 'GET /esb/deadLetters/{}'].map((c) => call(c, 'qqq-esb'))),
...([
'GET /sql-workbench/capabilities', 'GET /sql-workbench/schema/{}', 'POST /sql-workbench/execute', 'POST /sql-workbench/explain',
'GET /sql-workbench/executions/{}/rows', 'POST /sql-workbench/executions/{}/cancel', 'GET /sql-workbench/executions/{}/export',
@@ -60,6 +59,14 @@ export const OFF_SPEC_CALLS: OffSpecCall[] = [
call('GET {}', 'qqq-middleware-api table spec URL'),
]
/**
* The most off-spec calls allowed (QRun-IO/qqq-frontend-next#107). Lower it whenever an owner moves
* calls onto spec'd operations; never raise it. A feature that needs new backend calls lands in this
* order: the qbit or module ships its UI API extension, its document is vendored into
* `extensions/`, then the UI calls it - so new calls never need this list.
*/
export const OFF_SPEC_CEILING = 45
/**
* Call sites whose path is computed rather than written as a literal: `file#expression` (the
* first argument as written) to the calls it can make.
+5 -1
View File
@@ -26,7 +26,7 @@ import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { SUPPORTED_UI_API, parseSemver } from '../ui-api-version'
import { DYNAMIC_CALL_SITES, OFF_SPEC_CALLS, PATH_HELPERS } from './ui-api-calls'
import { DYNAMIC_CALL_SITES, OFF_SPEC_CALLS, OFF_SPEC_CEILING, PATH_HELPERS } from './ui-api-calls'
const SRC = path.resolve(__dirname, '../../..')
const OPENAPI_DIR = __dirname
@@ -191,6 +191,10 @@ describe('UI API contract', () => {
expect(Object.keys(DYNAMIC_CALL_SITES).filter((key) => !dynamicSites.has(key))).toEqual([])
})
it('never grows the off-spec list (ship the extension and vendor its document instead)', () => {
expect(OFF_SPEC_CALLS.length, 'OFF_SPEC_CALLS may only shrink: add the operation to the backend as a UI API extension and vendor its document').toBeLessThanOrEqual(OFF_SPEC_CEILING)
})
it('vendors a document inside the supported UI API range', () => {
const version = parseSemver(loadDocuments()[0].info.version)
expect(version).not.toBeNull()