Merge feat/m1-kernel-cli (extensions) into feat/m1-query-record-gaps

My server findings move to 19 and 20 after the merged 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-10-31 18:01:43 -05:00
commit ee855eb146
29 files changed
+61904 -52

No files matched your search

+62
View File
@@ -123,6 +123,7 @@ qqq widget get SampleStackedBarChartWidget --output json # the raw payload
qqq prefs list ; qqq prefs get regional ; qqq prefs set regional --set weekStartsOn=MONDAY
qqq help-search person ; qqq report run <name> --format xlsx
qqq search Avery ; qqq tail
qqq extensions ; qqq esb table person # extensions (qbits): see "Extensions (qbits)" below
```
Profiles name servers, as `aws --profile`; sign-ins live in the OS keyring (macOS Keychain here, Secret Service on
@@ -227,6 +228,67 @@ durable-jobs server, TABLE_BASED sign-in. Built but not verifiable against this
saved views, server-side column statistics (columnStats), audits, a variant-backed table, exposed joins (joins were
checked through an association join instead); node tests and a local stub server cover those.
## Extensions (qbits)
An extension is a qbit or module that adds UI API operations. The CLI knows every extension whose
document was merged into the client (13 today, listed in `docs/extensions.md`); a server installs
some of them and lists them in `GET /version`. Each has a noun:
| Noun | Extension | First-class verbs | Generated verbs (one per operation) |
|---|---|---|---|
| `qqq esb` | esb (qqq) | `overview`, `table <t>`, `process <p>`, `dead-letters <trigger>`, `messages <destination> [--trigger t]` | `get-overview`, `get-table`, ... |
| `qqq collab` | collaboration | `presence <table> [pk] [--watch\|--observe]`, `comments list\|add`, `favorites list\|add\|remove`, `recents`, `follow [--status]`, `unfollow`, `my-work`, `people [q]` | `get-records-timeline <table> <pk>`, `get-records-tags`, `put-records-assignment`, ... (45) |
| `qqq health`, `alerts`, `assistant`, `data-analysis`, `db-performance`, `edi`, `qa-capture`, `quick-search`, `release-notes`, `secured-data`, `sql-workbench` | the qbit of that name | none yet | every operation, e.g. `qqq sql-workbench get-capabilities` |
Generated verbs are the method plus the literal path segments after the extension's prefix. They take
path parameters as arguments or `--path name=value`, plus `--param`, `--body-json`, `--form` and `--out`.
`qqq <noun> --help` lists them with the operation behind each.
What happens:
- **Installed.** The command runs. `qqq whoami` and `qqq extensions` show it with its version.
- **Not installed.** Every verb of the noun, and `qqq api <operation>` for any of its operations, stops
before calling the server, with exit 5: `extension collaboration is not installed on this server
(installed: esb 1.1.0)`.
- **Installed below the minimum the client needs** (esb, health and collaboration need 1.1.0): exit 5,
`this client needs extension esb 1.1.0 or later; this server has 1.0.0`.
- **Installed but unknown to this client** (a newer qbit): listed by `qqq whoami` and `qqq extensions`
as `unknown to this client`, never dropped. It has no noun until the client document is regenerated.
The installed list comes from `GET /version`, cached with the metadata for five minutes (`qqq cache
refresh` rereads it). `QQQ_EXTENSION_CHECK=off` skips the check, for a Prism mock whose `/version` is
only an example.
```sh
qqq extensions # known vs installed, versions, minimums, operations
qqq api --list --extension collaboration # an extension's operations (the list has an extension column)
qqq esb table person # sample server: what person publishes, which processes it triggers
qqq esb process syncPerson ; qqq esb messages personEvents --trigger syncPerson.personEvents
qqq collab presence person 1 # sample server: exit 5, collaboration is not installed
```
Collaboration needs a server with qbit-collaboration. Its repo has a demo server (memory backend, MOCK
users alice, bob and admin); it runs on port 8098 beside the sample server:
```sh
cd ../qbit-collaboration && ./mvnw -q test-compile dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/demo-classpath.txt
mkdir -p /tmp/fe/next-dashboard && echo '<html></html>' > /tmp/fe/next-dashboard/index.html # it insists on a Next export
java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -cp "target/test-classes:target/classes:$(cat target/demo-classpath.txt):/tmp/fe" \
com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
qqq login --server http://localhost:8098 --profile collab # alice
qqq --profile collab collab presence person 1 --watch # join; prints each change until Ctrl+C
qqq --profile collab collab presence person --observe # watch the table without appearing
qqq --profile collab collab comments list person 1 ; qqq --profile collab collab comments add person 1 "Looks right"
qqq --profile collab collab favorites list ; qqq --profile collab collab favorites add person 4
qqq --profile collab collab follow person 2 --comments off ; qqq --profile collab collab my-work
```
To see someone else, open `http://localhost:8098/?createMockSession=true&user=bob` in a browser on a
person (needs a real Next export), or hold a lease as bob with curl (`POST /qqq/v1/qbit/collaboration/presence?user=bob`).
Presence lists everyone holding a lease on the record or table, you included while you `--watch`;
`since` is when the CLI first saw them.
## Reporting what you find
Say which screen, what you pressed, what you expected. If the TUI crashes, the terminal should be restored;
+37
View File
@@ -0,0 +1,37 @@
# Extension documents in the client
The client document (`packages/ui-api-client/openapi/qqq-v1.json`) is the sample server's live
document for core (QQQ 4.1.0-SNAPSHOT, `develop` 6ac64fc66, UI API 1.8.0, which already carries esb)
merged with the extension document each repository commits at `src/main/resources/openapi/qqq-v1/`.
Rebuild it with the sample server running:
```sh
npm run spec:merge-extensions && npm run generate # writes openapi/extension-sources.json too
```
Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/openapi/extension-sources.json`):
| Extension | Version | Prefix | Operations | Repository at | Minimum the client needs |
|---|---|---|---|---|---|
| esb | 1.1.0 | `/esb` | 5 | qqq `develop` 6ac64fc66 (qqq-esb) | 1.1.0 (operationIds, UI-API-CHANGELOG) |
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
| qa-capture | 1.0.0 | `/qbit/qa-capture` | 2 | qbit-qa-capture `develop` 1aee6cb | |
| quick-search | 1.1.0 | `/qbit/quick-search` | 2 | qbit-quick-search `develop` bd50e1f | |
| release-notes | 1.0.0 | `/qbit/release-notes` | 3 | qbit-release-notes `develop` 8fed18e | |
| secured-data | 1.1.0 | `/qbit/secured-data` | 3 | qbit-secured-data `develop` 7b302ed | |
| sql-workbench | 1.1.0 | `/sql-workbench` | 21 | qbit-sql-workbench `develop` a182076 | |
202 operations in all, 136 from extensions. No extension document conflicted with core or another
(identical shared schemas such as `BasicErrorResponseV1`). There is no `qbit-middleware-health`: the
health extension lives in qqq's `qqq-middleware-javalin`. Other qbits in the workspace (authentik,
jobs, help, middleware-mcp, ...) register no UI API extension.
Minimums are only set where an extension's own docs state one; the rest accept any version the
server lists. Each extension's minimum and realtime channels live in
`packages/core/src/extensions/registry.ts` (`FACTS`).
+1 -1
View File
@@ -273,7 +273,7 @@ The TUI column fills in during M3.
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and | pass | todo | inline enums |
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | todo | todo | adjusters wired, no sample to test |
| REC-064 | Record sections render qualified fields from readable exposed joins | partial | todo | table get --join adds joined fields (queryJoins); verified with an association join |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | partial | todo | get, copy and update -i read with --variant; v1 writes ignore it (server finding #18) |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | partial | todo | get, copy and update -i read with --variant; v1 writes ignore it (server finding #19) |
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | todo | an unchanged wall-clock time keeps the original instant and its precision |
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | todo | |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | todo | todo | disabled tables listed with a reason |
+3 -2
View File
@@ -22,5 +22,6 @@ the sample app and MOCK authentication. Each is a candidate issue for `QRun-IO/q
| 15 | A step that fails on missing input answers `ERROR` with only `error` (`Error message: Unable to get Cities by state. Missing required input.`), no `userFacingError`, and the field is not marked required in the step metadata, so a client cannot tell an input problem from a crash or ask for the field first. | `qqq process run placeAddressPick --no-prompt`. | The CLI exits 6 (validation) when the message says required input is missing or the error is user-facing, 1 otherwise. Mark the fields required, or return `userFacingError` (or a 400 with `fieldErrors`). |
| 16 | `downloadRecordFieldFile` on a BLOB field without adornments answers 500 `NullPointerException (Cannot invoke "java.util.List.stream()" because the return value of "QFieldMetaData.getAdornments()" is null)`. | `qqq table create fieldLab --set name=x --upload blobValue=./a.txt`, then `qqq api downloadRecordFieldFile --path tableName=fieldLab --path primaryKey=<id> --path fieldName=blobValue --path filename=a.txt -o out`. | `qqq table download` uses the route only for FILE_DOWNLOAD fields and decodes the record's base64 BLOB value otherwise. The route should treat no adornments as none (or answer 404/400). |
| 17 | The sample app declares no field adornments other than LINK, no hidden fields or sections, no help content, no exposed joins, no composite or aliased associations and no table the mock user may not read, so CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP, help display, denied child reads and composite associations can only be checked with unit tests against the client. | `GET /metaData/table/{t}` for every sample table. | None needed in the client; a `fieldLab`-style adornment table in the sample (one field per adornment, a hidden section, help on a field, section and table, a composite association) would make these rows checkable live. |
| 18 | The v1 insert, update and delete routes do not read a table variant: `TableInsertExecutor`, `TableUpdateExecutor` and `TableDeleteExecutor` never call `ExecutorSessionUtils.setTableVariantInSession`, and the spec gives them no `tableVariant` (query, count, get, export and the process routes have it). A client cannot write to a variant-backed table over v1. | Read `executors/Table{Insert,Update,Delete}Executor.java`; the legacy `QJavalinImplementation.setupSession` read a `tableVariant` form or query parameter for every route. | `qqq table create/update/delete/copy --variant` send `tableVariant` as a query parameter (as the get route takes it) and say the server may ignore it. Add the parameter to the three routes. |
| 19 | Joins do not reach the export route: `exportTable` has no `joins` member and a joined name in `fieldNames` (`pet.name` on `person`, a join the query route accepts) answers 500 `Field name pet.name was not found on the Person table.` The web UI exports joined columns by name; unclear whether exposed joins work there. | `qqq api exportTable --path tableName=person --body-json '{"format":"csv","fieldNames":["id","pet.name"]}'` | `qqq table export` accepts joined columns of exposed joins only and checks them before sending. |
| 18 | qbit-collaboration 1.1.0: when the last viewer leaves, `presence.changed` (and the table snapshot) is `{"table":"person"}` with no `viewers`, although `CollaborationPresenceSnapshotV1.viewers` is a non-nullable array. A client that waits for `viewers` keeps showing the last person. Seen on the qbit's `CollaborationDevServer` (QQQ 4.1.0-RC.15). | Two sessions (`?user=bob` and alice); bob POSTs then DELETEs `/qbit/collaboration/presence` while alice is subscribed to `table:person`. | The kernel treats a payload with `table` as the snapshot and missing `viewers` as none. The qbit should send `"viewers": []`. |
| 19 | The v1 insert, update and delete routes do not read a table variant: `TableInsertExecutor`, `TableUpdateExecutor` and `TableDeleteExecutor` never call `ExecutorSessionUtils.setTableVariantInSession`, and the spec gives them no `tableVariant` (query, count, get, export and the process routes have it). A client cannot write to a variant-backed table over v1. | Read `executors/Table{Insert,Update,Delete}Executor.java`; the legacy `QJavalinImplementation.setupSession` read a `tableVariant` form or query parameter for every route. | `qqq table create/update/delete/copy --variant` send `tableVariant` as a query parameter (as the get route takes it) and say the server may ignore it. Add the parameter to the three routes. |
| 20 | Joins do not reach the export route: `exportTable` has no `joins` member and a joined name in `fieldNames` (`pet.name` on `person`, a join the query route accepts) answers 500 `Field name pet.name was not found on the Person table.` The web UI exports joined columns by name; unclear whether exposed joins work there. | `qqq api exportTable --path tableName=person --body-json '{"format":"csv","fieldNames":["id","pet.name"]}'` | `qqq table export` accepts joined columns of exposed joins only and checks them before sending. |
+1
View File
@@ -15,6 +15,7 @@
],
"scripts": {
"spec:merge": "node scripts/merge-spec.mjs --out packages/ui-api-client/openapi/qqq-v1.json",
"spec:merge-extensions": "node scripts/merge-extensions.mjs",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present",
+52 -29
View File
@@ -6,7 +6,7 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { basename } from 'node:path'
import type { Command } from 'commander'
import { OPERATIONS, operation } from '@qrun-io/qqq-core'
import { OPERATIONS, operation, type OperationInfo } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { parsePairs, readJson } from './shared.js'
@@ -21,39 +21,62 @@ export function registerApi(program: Command, ctx: () => Context): void {
.option('--bytes <file>', 'raw request body (application/octet-stream)')
.option('-o, --out <file>', 'write a binary response to a file')
.option('-l, --list', 'list the operations instead of calling one')
.action(async (operationId: string | undefined, options: { path?: string[]; param?: string[]; bodyJson?: string; form?: string[]; bytes?: string; out?: string; list?: boolean }) => {
.option('-e, --extension <name>', 'with --list: only this extension\'s operations (`core` for core ones)')
.action(async (operationId: string | undefined, options: OperationOptions & { list?: boolean; extension?: string }) => {
const c = ctx()
if (options.list || !operationId) {
const rows = OPERATIONS.map((o) => ({ operationId: o.id, method: o.method, path: o.path, body: o.bodyTypes.join('|'), summary: o.summary }))
c.print({ data: rows, columns: ['operationId', 'method', 'path', 'body'], footer: `(${rows.length} operations; qqq api <operationId> --path k=v --param k=v --body-json '{...}')` })
const selected = OPERATIONS.filter((o) => !options.extension || (options.extension === 'core' ? !o.extension : o.extension === options.extension))
const rows = selected.map((o) => ({ operationId: o.id, extension: o.extension ?? '', method: o.method, path: o.path, body: o.bodyTypes.join('|'), summary: o.summary }))
const extensions = new Set(selected.map((o) => o.extension).filter(Boolean)).size
c.print({ data: rows, columns: ['operationId', 'extension', 'method', 'path', 'body'], footer: `(${rows.length} operations, ${extensions} extensions; qqq api <operationId> --path k=v --param k=v --body-json '{...}'; whether an extension is installed: qqq whoami)` })
return
}
const op = operation(operationId)
if (!op) throw new UsageError(`No operation "${operationId}"; \`qqq api --list\` shows them.`)
let form: Record<string, string | Blob> | undefined
if (options.form) {
form = {}
for (const [k, v] of Object.entries(parsePairs(options.form, '--form'))) {
form[k] = v.startsWith('@') ? new File([readFileSync(v.slice(1))], basename(v.slice(1))) : v
}
}
const result = await c.call(operationId, {
path: parsePairs(options.path, '--path'),
query: parsePairs(options.param, '--param'),
json: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined,
form,
bytes: options.bytes ? new Uint8Array(readFileSync(options.bytes)) : undefined,
})
if (result.bytes) {
if (!options.out) throw new UsageError(`${operationId} returned ${result.contentType} (${result.bytes.length} bytes); pass --out <file>`)
writeFileSync(options.out, result.bytes)
c.note(`wrote ${options.out} (${result.bytes.length} bytes, ${result.contentType})`)
return
}
if (typeof result.data === 'string') {
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data })
await runOperation(c, op, options)
})
}
/** The options every generic operation command takes. */
export interface OperationOptions {
path?: string[]
param?: string[]
bodyJson?: string
form?: string[]
bytes?: string
out?: string
}
/**
* Calls one operation from command options and prints the result. An operation of an extension
* the server does not list is refused before the request (ExtensionNotInstalledError, exit 5).
*/
export async function runOperation(c: Context, op: OperationInfo, options: OperationOptions, pathValues: Record<string, string> = {}): Promise<void> {
if (op.extension) (await c.extensions()).requireOperation(op.id)
const operationId = op.id
let form: Record<string, string | Blob> | undefined
if (options.form) {
form = {}
for (const [k, v] of Object.entries(parsePairs(options.form, '--form'))) {
form[k] = v.startsWith('@') ? new File([readFileSync(v.slice(1))], basename(v.slice(1))) : v
}
}
const result = await c.call(operationId, {
path: { ...pathValues, ...parsePairs(options.path, '--path') },
query: parsePairs(options.param, '--param'),
json: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined,
form,
bytes: options.bytes ? new Uint8Array(readFileSync(options.bytes)) : undefined,
})
if (result.bytes) {
if (!options.out) throw new UsageError(`${operationId} returned ${result.contentType} (${result.bytes.length} bytes); pass --out <file>`)
writeFileSync(options.out, result.bytes)
c.note(`wrote ${options.out} (${result.bytes.length} bytes, ${result.contentType})`)
return
}
if (typeof result.data === 'string') {
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data })
}
+1 -1
View File
@@ -78,7 +78,7 @@ export function registerAuth(program: Command, ctx: () => Context): void {
tokenExpires: login && login.kind !== 'session' ? new Date(login.tokens.expiresAt).toISOString() : '',
apiVersion: version.apiVersion,
features: ((version.features ?? []) as string[]).join(', '),
extensions: ((version.extensions ?? []) as Json[]).map((e) => `${e.name} ${e.version}`).join(', '),
extensions: (await c.extensions({ refresh: true })).describe(),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
+213
View File
@@ -0,0 +1,213 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* First-class commands of `qqq collab` (the collaboration extension): presence, comments,
* favorites, recents, follow, my work and people. The kernel's Collaboration module does the
* calls; every command requires the extension first (exit 5 with the installed list).
*/
import type { Command } from 'commander'
import { COLLABORATION, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import type { Json } from './shared.js'
const TARGET_TYPES = ['TABLE', 'APP', 'RECORD', 'PROCESS', 'REPORT', 'SAVED_VIEW']
async function collab(c: Context) {
;(await c.extensions()).require(COLLABORATION)
return c.collaboration()
}
function hhmmss(date: Date): string {
return date.toTimeString().slice(0, 8)
}
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
export function presenceLine(state: PresenceState): string {
if (!state.available) return 'presence: the collaboration extension is not installed'
if (state.error) return `presence: ${state.error}`
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
if (!state.count) return `nobody on ${where}`
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
return `${state.count} on ${where}: ${people.join(', ')}`
}
function favoriteRows(items: FavoriteEntry[]): Json[] {
return items.map((f) => ({ id: f.id ?? '', type: f.targetType ?? '', name: f.targetName ?? '', record: f.recordId ?? '', label: f.label ?? '', folder: f.folder ?? '', available: f.available ?? '', href: f.href ?? '' }))
}
function commentRows(threads: CommentEntry[]): Json[] {
const rows: Json[] = []
const add = (comment: CommentEntry, depth: number) => {
rows.push({
id: comment.id ?? '',
author: comment.authorDisplayName ?? comment.authorUserId ?? '',
at: String(comment.createdAt ?? '').slice(0, 16).replace('T', ' '),
state: comment.deleted ? 'deleted' : comment.resolvedAt ? 'resolved' : '',
body: `${depth ? ' > ' : ''}${String(comment.body ?? '').replace(/\s+/g, ' ').slice(0, 100)}`,
})
for (const reply of comment.replies ?? []) add(reply, depth + 1)
}
for (const thread of threads) add(thread, 0)
return rows
}
/** `favorites add person 1` is a RECORD; `favorites add person` a TABLE; --type for the rest. */
function targetOf(name: string, recordId: string | undefined, type: string | undefined): CollaborationTarget {
const targetType = (type ?? (recordId !== undefined ? 'RECORD' : 'TABLE')).toUpperCase()
if (!TARGET_TYPES.includes(targetType)) throw new UsageError(`--type must be one of ${TARGET_TYPES.join(', ')}`)
if (targetType === 'RECORD' && recordId === undefined) throw new UsageError('A RECORD favorite needs the record id: favorites add <table> <id>')
return { targetType: targetType as CollaborationTarget['targetType'], targetName: name, ...(targetType === 'RECORD' ? { recordId } : {}) }
}
export function registerCollab(noun: Command, ctx: () => Context): void {
noun.command('presence <table> [pk]')
.description('Who is on a table or record right now. --watch joins (you appear too) and prints each change until Ctrl+C; --observe watches without joining.')
.option('-w, --watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
.option('--observe', 'follow changes without joining')
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
const c = ctx()
const collaboration = await collab(c)
const scope = { table, pk }
const json = c.format === 'json' || c.format === 'yaml'
const emit = (state: PresenceState) => {
if (json) process.stdout.write(JSON.stringify({ ...state, people: state.people.map((p) => ({ ...p, since: p.since.toISOString() })) }) + '\n')
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
}
if (!options.watch && !options.observe) {
const state = await collaboration.presenceSnapshot(scope)
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: `(${state.count} on ${pk !== undefined ? `${table} ${pk}` : table}; --watch to join and follow)` })
return
}
let last = ''
const handle = await collaboration.joinPresence(scope, {
announce: Boolean(options.watch),
mode: options.editing ? 'EDITING' : 'VIEWING',
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
onChange: (state) => {
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
if (key !== last) {
last = key
emit(state)
}
},
})
if (handle.state.error) throw new Error(`presence on ${table}${pk !== undefined ? ` ${pk}` : ''}: ${handle.state.error}`)
if (!json) c.note(options.watch ? 'Joined; Ctrl+C leaves.' : 'Observing; Ctrl+C stops.')
await new Promise<void>((resolve) => {
const leave = () => {
void handle.stop().finally(resolve)
}
process.once('SIGINT', leave)
process.once('SIGTERM', leave)
})
})
const comments = noun.command('comments').description("A record's comment threads.")
comments.command('list <table> <pk>')
.description('Comment threads by latest activity, replies under their thread.')
.option('-l, --limit <n>', 'threads per page (1-50)', '20')
.option('--cursor <token>', 'continue from a previous page')
.action(async (table: string, pk: string, options: { limit: string; cursor?: string }) => {
const c = ctx()
const page = await (await collab(c)).comments(table, pk, { limit: Number(options.limit), cursor: options.cursor })
c.print({ data: page, rows: commentRows(page.threads), columns: ['id', 'author', 'at', 'state', 'body'], footer: `(${page.threads.length} threads${page.nextCursor ? `; more: --cursor ${page.nextCursor}` : ''})` })
})
comments.command('add <table> <pk> <body...>')
.description('Post a comment (mentions: @[Name](user:id)); --reply-to answers a thread.')
.option('-r, --reply-to <commentId>', 'reply to this comment')
.option('--field <name>', 'anchor the comment on a field')
.action(async (table: string, pk: string, body: string[], options: { replyTo?: string; field?: string }) => {
const c = ctx()
const comment = await (await collab(c)).addComment(table, pk, body.join(' '), { parentCommentId: options.replyTo ? Number(options.replyTo) : undefined, fieldName: options.field })
c.print({ data: comment, rows: commentRows([comment]), columns: ['id', 'author', 'at', 'state', 'body'] })
})
const favorites = noun.command('favorites').description('Your favorites (starred tables, records, apps, processes, reports, saved views).')
favorites.command('list').description('Your favorites in order.').action(async () => {
const c = ctx()
const items = await (await collab(c)).favorites()
c.print({ data: items, rows: favoriteRows(items), columns: ['id', 'type', 'name', 'record', 'label', 'folder', 'available'] })
})
favorites.command('add <name> [recordId]')
.description('Star a record (`add person 1`), a table (`add person`), or --type APP|PROCESS|REPORT|SAVED_VIEW.')
.option('-t, --type <type>', TARGET_TYPES.join('|'))
.action(async (name: string, recordId: string | undefined, options: { type?: string }) => {
const c = ctx()
const target = targetOf(name, recordId, options.type)
await (await collab(c)).addFavorite(target)
c.print({ data: { starred: target }, rows: [{ starred: `${target.targetType} ${name}${target.recordId ? ` ${target.recordId}` : ''}` }], columns: ['starred'] })
})
favorites.command('remove <name> [recordId]')
.description('Unstar a target (same arguments as add).')
.option('-t, --type <type>', TARGET_TYPES.join('|'))
.action(async (name: string, recordId: string | undefined, options: { type?: string }) => {
const c = ctx()
const target = targetOf(name, recordId, options.type)
await (await collab(c)).removeFavorite(target)
c.print({ data: { unstarred: target }, rows: [{ unstarred: `${target.targetType} ${name}${target.recordId ? ` ${target.recordId}` : ''}` }], columns: ['unstarred'] })
})
noun.command('recents')
.description('What you viewed recently, newest first.')
.option('-l, --limit <n>', 'most entries (1-50)', '20')
.action(async (options: { limit: string }) => {
const c = ctx()
const items = await (await collab(c)).recents(Number(options.limit))
c.print({ data: items, rows: favoriteRows(items), columns: ['type', 'name', 'record', 'label', 'available'] })
})
const followRows = (state: { following: boolean; follow?: Json | null }) => [{ following: state.following, notifyOnChange: state.follow?.notifyOnChange ?? '', notifyOnComment: state.follow?.notifyOnComment ?? '', reason: state.follow?.reason ?? '' }]
noun.command('follow <table> <pk>')
.description('Follow a record (notifications on changes and comments); --status only shows the state.')
.option('--status', 'show whether you follow it, change nothing')
.option('--changes <on|off>', 'notify on changes')
.option('--comments <on|off>', 'notify on comments')
.action(async (table: string, pk: string, options: { status?: boolean; changes?: string; comments?: string }) => {
const c = ctx()
const collaboration = await collab(c)
const flag = (v: string | undefined, name: string) => {
if (v === undefined) return undefined
if (!/^(on|off|true|false)$/i.test(v)) throw new UsageError(`${name} takes on or off`)
return /^(on|true)$/i.test(v)
}
const state = options.status ? await collaboration.followState(table, pk) : await collaboration.follow(table, pk, { notifyOnChange: flag(options.changes, '--changes'), notifyOnComment: flag(options.comments, '--comments') })
c.print({ data: state, rows: followRows(state as never), columns: ['following', 'notifyOnChange', 'notifyOnComment', 'reason'] })
})
noun.command('unfollow <table> <pk>')
.description('Stop following a record.')
.action(async (table: string, pk: string) => {
const c = ctx()
const state = await (await collab(c)).unfollow(table, pk)
c.print({ data: state, rows: followRows(state as never), columns: ['following', 'notifyOnChange', 'notifyOnComment', 'reason'] })
})
noun.command('my-work')
.description('Your open tasks, records assigned to you per table, and unread mentions.')
.action(async () => {
const c = ctx()
const work = await (await collab(c)).myWork()
const rows: Json[] = [
{ item: 'open tasks', value: work.openTaskCount ?? 0 },
{ item: 'unread mentions', value: work.unreadMentionCount ?? 0 },
...Object.entries(work.assignedRecordsByTable ?? {}).map(([table, n]) => ({ item: `assigned: ${table}`, value: n })),
...(work.tasks ?? []).map((t) => ({ item: `task ${t.id ?? ''}${t.dueAt ? ` due ${String(t.dueAt).slice(0, 10)}` : ''}`, value: `${t.title ?? ''}${t.tableName ? ` (${t.tableName} ${t.recordId ?? ''})` : ''}` })),
]
c.print({ data: work, rows, columns: ['item', 'value'] })
})
noun.command('people [query]')
.description('Search the people directory by name or email.')
.option('-l, --limit <n>', 'most people (1-100)', '20')
.action(async (query: string | undefined, options: { limit: string }) => {
const c = ctx()
const people = await (await collab(c)).people(query, Number(options.limit))
c.print({ data: people, rows: people.map((p) => ({ user: p.userId ?? '', name: p.displayName ?? '', email: p.email ?? '', lastSeen: String(p.lastSeenAt ?? '').slice(0, 16).replace('T', ' ') })), columns: ['user', 'name', 'email', 'lastSeen'] })
})
}
+183
View File
@@ -0,0 +1,183 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* One noun per extension (qbit or module): `qqq esb ...`, `qqq collab ...`, `qqq sql-workbench ...`.
* Every operation of an extension is a subcommand, generated from the operation index
* (`get-overview`, `put-favorites`, `get-records-comments <table> <pk>`), taking its path parameters
* as arguments or --path, plus --param and --body-json. First-class commands (esb, collab) sit next
* to the generated ones. Every subcommand checks the server's GET /version first: an extension that
* is not installed stops with exit 5 and the list of what is installed.
*/
import type { Command } from 'commander'
import { KNOWN_EXTENSIONS, OPERATIONS, operationsOf, type OperationInfo } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { runOperation, type OperationOptions } from './api.js'
import type { Json } from './shared.js'
import { registerCollab } from './collab.js'
/** The noun of each extension; the extension's own name unless it is long. */
export const NOUNS: Record<string, string> = { collaboration: 'collab' }
export function nounOf(extension: string): string {
return NOUNS[extension] ?? extension
}
function kebab(text: string): string {
return text.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[^A-Za-z0-9]+/g, '-').toLowerCase()
}
/**
* Subcommand names for an extension's operations: the method and the literal path segments after
* the extension's prefix (`GET /qbit/collaboration/records/{table}/{pk}/comments` is
* `get-records-comments`); names that would collide get `-by-<param>`, then the operationId.
*/
export function subcommandNames(extension: string, pathPrefix: string, operations: OperationInfo[]): Map<string, OperationInfo> {
const base = `/qqq/v1${pathPrefix}`
const first = operations.map((op) => {
const segments = op.path.startsWith(base) ? op.path.slice(base.length).split('/').filter(Boolean) : op.path.split('/').filter(Boolean)
const literals = segments.filter((s) => !s.startsWith('{'))
return { op, name: `${op.method.toLowerCase()}-${literals.length ? literals.map(kebab).join('-') : extension}` }
})
const count = (name: string, list: { name: string }[]) => list.filter((x) => x.name === name).length
const second = first.map((x) => (count(x.name, first) > 1 && x.op.pathParams.length ? { op: x.op, name: `${x.name}-by-${x.op.pathParams.map(kebab).join('-')}` } : x))
const names = new Map<string, OperationInfo>()
for (const x of second) names.set(count(x.name, second) > 1 ? kebab(x.op.id) : x.name, x.op)
return names
}
/** Adds one generated subcommand per operation of an extension to its noun. */
export function addOperationCommands(noun: Command, extension: string, pathPrefix: string, ctx: () => Context): void {
const taken = new Set(noun.commands.map((c) => c.name()))
for (const [name, op] of subcommandNames(extension, pathPrefix, operationsOf(extension))) {
if (taken.has(name)) continue
const args = op.pathParams.map((p) => ` [${p}]`).join('')
const cmd = noun.command(`${name}${args}`)
.description(`${op.summary || op.id} (${op.method} ${op.path.replace('/qqq/v1', '')}; operation ${op.id})`)
.option('-p, --path <name=value...>', 'path parameters (or give them as arguments)')
.option('-q, --param <name=value...>', `query parameters${op.queryParams.length ? `: ${op.queryParams.join(', ')}` : ''}`)
if (op.bodyTypes.includes('application/json') || op.bodyTypes.length === 0) cmd.option('-b, --body-json <json|file|->', 'JSON request body (inline, a file, or - for stdin)')
if (op.bodyTypes.includes('multipart/form-data')) cmd.option('-F, --form <name=value|name=@file...>', 'multipart form fields; @file uploads a file')
cmd.option('-o, --out <file>', 'write a binary response to a file')
cmd.action(async (...actionArgs: unknown[]) => {
const values = actionArgs.slice(0, op.pathParams.length) as (string | undefined)[]
const options = actionArgs[op.pathParams.length] as OperationOptions
const pathValues: Record<string, string> = {}
op.pathParams.forEach((p, i) => {
if (values[i] !== undefined) pathValues[p] = values[i]!
})
await runOperation(ctx(), op, options, pathValues)
})
}
}
/** Requires the extension on the target server (exit 5 with the installed list when it is absent). */
export async function requireExtension(c: Context, name: string): Promise<void> {
;(await c.extensions()).require(name)
}
function counters(destination: Json | undefined): Json {
const c = (destination?.counters ?? {}) as Json
return { published: c.published ?? '', consumed: c.consumed ?? '', failed: c.failed ?? '', deadLettered: c.deadLettered ?? '', inFlight: c.inFlight ?? '', lastActivity: String(c.lastActivity ?? '').slice(0, 19) }
}
function registerEsb(noun: Command, ctx: () => Context): void {
const run = async (id: string, path: Record<string, string> = {}, query: Record<string, string | undefined> = {}) => {
const c = ctx()
await requireExtension(c, 'esb')
return { c, data: (await c.call(id, { path, query })).data as Json }
}
const columns = ['role', 'destination', 'type', 'detail', 'state', 'published', 'consumed', 'failed', 'deadLettered', 'inFlight', 'lastActivity']
const linkRows = (data: Json) => [
...((data.publications ?? []) as Json[]).map((p) => {
const d = p.destination as Json | undefined
return { role: 'publishes', destination: d?.name ?? '', type: d?.type ?? '', detail: ((p.events ?? []) as string[]).join(' '), state: '', ...counters(d) }
}),
...((data.subscribers ?? data.triggers ?? []) as Json[]).map((s) => {
const d = s.destination as Json | undefined
return { role: 'trigger', destination: d?.name ?? '', type: d?.type ?? '', detail: s.processLabel ?? s.processName ?? s.name ?? '', state: s.state ?? '', ...counters(d) }
}),
]
noun.command('overview')
.description('Broker connections and every destination with its publishers and triggers (needs ESB permission).')
.action(async () => {
const { c, data } = await run('getEsbOverview')
const rows = ((data.destinations ?? []) as Json[]).map((d) => ({ destination: d.name, type: d.type, provider: d.provider, publishers: ((d.publishers ?? []) as unknown[]).length, triggers: ((d.triggers ?? []) as unknown[]).length, ...counters(d) }))
c.print({ data, rows, columns: ['destination', 'type', 'provider', 'publishers', 'triggers', 'published', 'consumed', 'failed', 'deadLettered', 'lastActivity'], footer: `(${((data.providers ?? []) as unknown[]).length} providers, ${rows.length} destinations)` })
})
noun.command('table <table>')
.description('What a table publishes and which processes it triggers.')
.action(async (table: string) => {
const { c, data } = await run('getEsbTableByTable', { table })
c.print({ data, rows: linkRows(data), columns })
})
noun.command('process <process>')
.description('What a process publishes and what triggers it.')
.action(async (process: string) => {
const { c, data } = await run('getEsbProcessByProcess', { process })
c.print({ data, rows: linkRows(data), columns })
})
const messageRows = (data: Json) => ((data.messages ?? []) as Json[]).map((m) => ({ messageId: m.messageId, timestamp: String(m.timestamp ?? '').slice(0, 19), deliveries: m.deliveryCount ?? '', event: m.event ?? '', body: String(m.rawBody ?? '').slice(0, 80) }))
noun.command('dead-letters <trigger>')
.description("A trigger's dead letters.")
.option('-l, --limit <n>', 'most messages', '20')
.option('--offset <n>', 'skip this many')
.action(async (trigger: string, options: { limit: string; offset?: string }) => {
const { c, data } = await run('getEsbDeadLettersByTrigger', { trigger }, { limit: options.limit, offset: options.offset })
const rows = messageRows(data)
c.print({ data, rows, columns: ['messageId', 'timestamp', 'deliveries', 'event', 'body'], footer: `(${rows.length} dead letters${data.hasMore ? ', more with --offset' : ''})` })
})
noun.command('messages <destination>')
.description('Messages waiting on a destination (a topic needs --trigger: its messages are kept per trigger).')
.option('-t, --trigger <name>', 'the trigger (subscription) of a topic')
.option('-l, --limit <n>', 'most messages', '20')
.option('--offset <n>', 'skip this many')
.action(async (destination: string, options: { trigger?: string; limit: string; offset?: string }) => {
const { c, data } = await run('getEsbMessagesByDestination', { destination }, { trigger: options.trigger, limit: options.limit, offset: options.offset })
const rows = messageRows(data)
c.print({ data, rows, columns: ['messageId', 'timestamp', 'deliveries', 'event', 'body'], footer: `(${rows.length} messages${data.hasMore ? ', more with --offset' : ''})` })
})
}
export function registerExtensions(program: Command, ctx: () => Context): void {
program.command('extensions')
.description('Extensions (qbits) this client knows and which ones the server has installed.')
.action(async () => {
const c = ctx()
const registry = await c.extensions()
const rows: Json[] = KNOWN_EXTENSIONS.map((e) => {
const installed = registry.installed.find((i) => i.name === e.name)
return {
extension: e.name,
noun: `qqq ${nounOf(e.name)}`,
installed: installed ? installed.version ?? 'yes' : '',
status: !installed ? 'not installed' : installed.meetsMinimum ? 'ok' : `needs ${e.minVersion}+`,
client: e.documentVersion ?? '',
minimum: e.minVersion ?? '',
operations: operationsOf(e.name).length,
channels: e.channels.join(' '),
}
})
for (const u of registry.unknown()) rows.push({ extension: u.name, noun: '', installed: u.version ?? 'yes', status: 'unknown to this client', client: '', minimum: '', operations: 0, channels: '' })
c.print({ data: { installed: registry.installed, known: KNOWN_EXTENSIONS }, rows, columns: ['extension', 'noun', 'installed', 'status', 'client', 'minimum', 'operations', 'channels'], footer: `(installed: ${registry.describe()}; ${OPERATIONS.filter((o) => o.extension).length} extension operations in this client)` })
})
for (const e of KNOWN_EXTENSIONS) {
const name = nounOf(e.name)
const noun = program.command(name).description(`${e.label} (${e.name} extension${e.minVersion ? ` ${e.minVersion}+` : ''}, ${e.repository}): ${e.description || 'operations'}`)
if (name !== e.name) noun.alias(e.name)
if (e.name === 'esb') registerEsb(noun, ctx)
if (e.name === 'collaboration') registerCollab(noun, ctx)
addOperationCommands(noun, e.name, e.pathPrefix, ctx)
noun.addHelpText('after', `\nEvery ${e.name} operation is a subcommand; path parameters go as arguments or --path name=value.\nNot installed on the server: exit 5 with the installed list (see \`qqq extensions\`).`)
}
}
+2 -2
View File
@@ -51,7 +51,7 @@ export async function chooseVariant(c: Context, meta: Json, typed: string | unde
} else {
throw new UsageError(`${meta.label} needs a ${label}: --variant <id|name>${variants.length ? ` (${variants.slice(0, 10).map((v) => `${v.name ?? v.id} (${v.id})`).join(', ')}${variants.length > 10 ? ', …' : ''})` : '; the server lists none'}`)
}
// UI API 1.8.0 reads tableVariant on query, count, get, export and processes only (docs/server-findings.md #18)
if (options.write) c.note(`note: the server may ignore the ${label} on inserts, updates and deletes (UI API ${'1.8.0'}); see docs/server-findings.md #18`)
// UI API 1.8.0 reads tableVariant on query, count, get, export and processes only (docs/server-findings.md #19)
if (options.write) c.note(`note: the server may ignore the ${label} on inserts, updates and deletes (UI API ${'1.8.0'}); see docs/server-findings.md #19`)
return chosen
}
+32 -2
View File
@@ -10,8 +10,9 @@
import type { Command } from 'commander'
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
import { FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { authenticatedFetch, clientFor, selectTarget, target, type Target } from './session.js'
import { Collaboration, ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieFor, target, type Target } from './session.js'
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
export const EXIT = { ok: 0, error: 1, usage: 2, auth: 3, transient: 4, notFound: 5, validation: 6, unexpected: 70 } as const
@@ -32,6 +33,7 @@ export interface GlobalOptions {
export class Context {
readonly options: GlobalOptions
private clientCache?: UiApiClient
private registryCache?: Promise<ExtensionRegistry>
constructor(program: Command) {
this.options = program.opts<GlobalOptions>()
@@ -94,6 +96,33 @@ export class Context {
return getTableMetaData<T>(this.server, table, authenticatedFetch(this.target), options)
}
/** The server's extensions (GET /version, cached with the metadata). */
extensions(options: { refresh?: boolean } = {}): Promise<ExtensionRegistry> {
// QQQ_EXTENSION_CHECK=off treats every extension the client knows as installed, for mock
// servers (Prism) whose GET /version is only the document's example.
if (process.env.QQQ_EXTENSION_CHECK === 'off') return Promise.resolve(new ExtensionRegistry(KNOWN_EXTENSIONS.map((e) => ({ name: e.name, version: e.documentVersion, pathPrefix: e.pathPrefix, known: true, meetsMinimum: true }))))
this.registryCache ??= extensionRegistry(this.server, authenticatedFetch(this.target), options)
return this.registryCache
}
/** The collaboration module for this server, with the realtime stream for presence. */
async collaboration(): Promise<Collaboration> {
const t = this.target
return new Collaboration(await this.extensions(), {
call: (id, input) => this.call(id, input),
openStream: (signal) => (async function* () {
const session = await sessionCookieFor(t)
yield* openRealtimeStream({
baseUrl: t.server,
clientChannel: CLIENT_CHANNEL,
signal,
headers: session ? { Cookie: `sessionUUID=${session}` } : undefined,
token: session ? undefined : () => currentToken(t),
})
})(),
})
}
/** Calls an operation by id (the generic escape hatch). */
call(id: string, input: CallInput): Promise<CallResult> {
return callOperation(this.server, id, input, authenticatedFetch(this.target))
@@ -130,6 +159,7 @@ export class UsageError extends Error {}
/** Maps an error to its exit code and the stderr text. */
export function describeError(error: unknown, trace = false): { code: number; text: string } {
if (error instanceof UsageError || error instanceof FilterSyntaxError || error instanceof UnsafeFileNameError) return { code: EXIT.usage, text: `Error: ${error.message}` }
if (error instanceof ExtensionNotInstalledError) return { code: EXIT.notFound, text: `Error: ${error.message}` }
if (error instanceof UiApiError) {
const lines = [`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`]
for (const fieldError of error.fieldErrors) lines.push(` ${fieldError.field}: ${fieldError.message}`)
+4 -1
View File
@@ -7,7 +7,8 @@
/**
* qqq - the command-line client for QQQ applications. Every call is an operation of the QQQ UI API
* (/qqq/v1); nothing is hard-coded about an application: apps, tables, fields and processes all
* come from metadata. Grammar: `qqq <noun> <verb>` (table, process, job, widget, report, prefs, api), plus the flat
* come from metadata. Grammar: `qqq <noun> <verb>` (table, process, job, widget, report, prefs, api, and one noun per
* extension: esb, collab, sql-workbench, ...), plus the flat
* commands of 0.1 (apps, tables, query, get, run, jobs, tail) as aliases.
*/
@@ -20,6 +21,7 @@ import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
import { registerCompletion } from './commands/completion.js'
import { registerConfig } from './commands/config.js'
import { registerExtensions } from './commands/extensions.js'
import { registerHelp } from './commands/help.js'
import { registerJob } from './commands/job.js'
import { registerMisc } from './commands/misc.js'
@@ -59,6 +61,7 @@ registerSavedView(program, ctx)
registerJob(program, ctx)
registerWidget(program, ctx)
registerApi(program, ctx)
registerExtensions(program, ctx)
registerCompletion(program, ctx)
registerConfig(program, ctx)
registerHelp(program, ctx)
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Command } from 'commander'
import { ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, operationsOf } from '@qrun-io/qqq-core'
import { EXIT, describeError } from '../context.js'
import { nounOf, registerExtensions, subcommandNames } from '../commands/extensions.js'
import { presenceLine } from '../commands/collab.js'
test('every operation of every extension is a uniquely named subcommand of its noun', () => {
const program = new Command()
registerExtensions(program, () => { throw new Error('not called') })
for (const e of KNOWN_EXTENSIONS) {
const names = subcommandNames(e.name, e.pathPrefix, operationsOf(e.name))
assert.equal(names.size, operationsOf(e.name).length, `${e.name}: one name per operation`)
const noun = program.commands.find((c) => c.name() === nounOf(e.name))!
assert.ok(noun, `noun for ${e.name}`)
for (const name of names.keys()) assert.ok(noun.commands.some((c) => c.name() === name), `${nounOf(e.name)} ${name}`)
}
const esb = subcommandNames('esb', '/esb', operationsOf('esb'))
assert.deepEqual([...esb.keys()].sort(), ['get-dead-letters', 'get-messages', 'get-overview', 'get-process', 'get-table'])
const collab = subcommandNames('collaboration', '/qbit/collaboration', operationsOf('collaboration'))
assert.equal(collab.get('get-records-comments')?.id, 'getQbitCollaborationRecordsByTableByPkComments')
assert.equal(collab.get('get-people-by-user-id')?.id, 'getQbitCollaborationPeopleByUserId')
const collabNoun = program.commands.find((c) => c.name() === 'collab')!
assert.deepEqual(collabNoun.aliases(), ['collaboration'])
for (const verb of ['presence', 'comments', 'favorites', 'recents', 'follow', 'unfollow', 'my-work', 'people']) assert.ok(collabNoun.commands.some((c) => c.name() === verb), verb)
})
test('a missing extension maps to exit 5 with the installed list', () => {
const error = new ExtensionNotInstalledError('collaboration', ExtensionRegistry.fromVersion({ extensions: [{ name: 'esb', version: '1.1.0' }] }).installed)
assert.deepEqual(describeError(error), { code: EXIT.notFound, text: 'Error: extension collaboration is not installed on this server (installed: esb 1.1.0)' })
})
test('presence lines say who is there and since when', () => {
const since = new Date(2026, 9, 31, 17, 50, 1)
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
})
+2
View File
@@ -19,6 +19,8 @@ what it produces; the TUI draws it.
| `records/record-view` | the record view as text: sections without hidden sections or fields, adornments (LINK, CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP), view-screen help by role |
| `widgets/render-model` | any widget payload to neutral blocks (heading, stat, table, series, pairs, steps, alert, link, rule, children) and blocks to text |
| `metadata/cache` | on-disk cache of `GET /metaData` and table definitions per server, with a TTL and offline reads |
| `extensions/registry` | every extension the client document carries (operations, documented minimum version, realtime channels), joined with the server's `GET /version`: `has`, `require` (typed `ExtensionNotInstalledError`), unknown extensions kept; see `src/extensions/README.md` |
| `extensions/collaboration` | the collaboration qbit: live presence (`joinPresence`, a no-op without the extension), comments, favorites, recents, follow, people, my work |
| `session/profiles`, `session/keyring` | named profiles and an OS keyring without native modules (Keychain, Secret Service, DPAPI, file) |
Rules the kernel keeps for both heads: every call is a spec operation named by `operationId`; text comes from the
+164 -6
View File
@@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
// Generated by scripts/gen-operations.mjs from the UI API 1.8.0 document. Do not edit.
// Generated by scripts/gen-operations.mjs from the UI API 1.8.0 document (core and 13 extensions). Do not edit.
export interface OperationInfo {
id: string
@@ -14,31 +14,85 @@ export interface OperationInfo {
pathParams: string[]
queryParams: string[]
bodyTypes: string[]
/** The extension (qbit or module) that serves this operation; absent for core operations. */
extension?: string
}
/** An extension document merged into the client document (info.x-qqq-extensions). */
export interface DocumentExtension {
name: string
version: string
pathPrefix?: string
description?: string
}
export const UI_API_DOCUMENT_VERSION = "1.8.0"
/** The extensions merged into the client document, with the versions it was generated from. */
export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
{"name":"esb","version":"1.1.0","pathPrefix":"/esb","description":"ESB overview, the ESB of tables and processes, and messages and dead letters on destinations."},
{"name":"health","version":"1.1.0","pathPrefix":"/health","description":"Central health summary, components, history and on-demand checks."},
{"name":"alerts","version":"1.2.0","pathPrefix":"/alerts","description":"Alerts inbox and record panel"},
{"name":"assistant","version":"1.6.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
{"name":"collaboration","version":"1.1.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
{"name":"data-analysis","version":"1.5.0","pathPrefix":"/qbit/data-analysis","description":"Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user."},
{"name":"db-performance","version":"1.0.0","pathPrefix":"/qbit/db-performance","description":"Database performance, live activity and audited query plans"},
{"name":"edi","version":"1.1.0","pathPrefix":"/qbit/edi","description":"EDI sandbox and mapper, using the signed-in user's permissions."},
{"name":"qa-capture","version":"1.0.0","pathPrefix":"/qbit/qa-capture","description":"QA bug capture: the user's capture capability and QA report creation"},
{"name":"quick-search","version":"1.1.0","pathPrefix":"/qbit/quick-search","description":"Quick Search administration and suggestions"},
{"name":"release-notes","version":"1.0.0","pathPrefix":"/qbit/release-notes","description":"What's new: published release notes and each user's unread count"},
{"name":"secured-data","version":"1.1.0","pathPrefix":"/qbit/secured-data","description":"Reveal grants and break-glass status"},
{"name":"sql-workbench","version":"1.1.0","pathPrefix":"/sql-workbench","description":"Audited SQL execution and notebooks"},
]
export const OPERATIONS: OperationInfo[] = [
{"id":"addRelatedRecords","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/add","summary":"Add child or join rows for picked records (MULTI_CHILD, LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"alertsInbox","method":"GET","path":"/qqq/v1/alerts/inbox","summary":"Page the notification inbox","tag":"alerts","pathParams":[],"queryParams":["status","category","severity","offset","limit"],"bodyTypes":[],"extension":"alerts"},
{"id":"alertsInboxDismiss","method":"POST","path":"/qqq/v1/alerts/inbox/dismiss","summary":"Dismiss own notifications","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"alerts"},
{"id":"alertsInboxRead","method":"POST","path":"/qqq/v1/alerts/inbox/read","summary":"Mark own notifications read","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"alerts"},
{"id":"alertsInboxSummary","method":"GET","path":"/qqq/v1/alerts/inbox/summary","summary":"Read the bell summary","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"alerts"},
{"id":"alertsInboxUnread","method":"POST","path":"/qqq/v1/alerts/inbox/unread","summary":"Mark own notifications unread","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"alerts"},
{"id":"alertsRecordPanel","method":"GET","path":"/qqq/v1/alerts/record-panel","summary":"Read visible alerts for a record","tag":"alerts","pathParams":[],"queryParams":["tableName","recordId"],"bodyTypes":[],"extension":"alerts"},
{"id":"cancelJob","method":"POST","path":"/qqq/v1/jobs/{jobId}/cancel","summary":"Jobs: cancel","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"cancelProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/cancel","summary":"Cancel a running process","tag":"Processes","pathParams":["processName","processUUID"],"queryParams":["tableVariant"],"bodyTypes":[]},
{"id":"chooseDeliveryTargets","method":"POST","path":"/qqq/v1/delivery/destinations/{name}/targets","summary":"Describe the next delivery target","tag":"General","pathParams":["name"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"countTable","method":"POST","path":"/qqq/v1/table/{tableName}/count","summary":"Count records in a table","tag":"Tables","pathParams":["tableName"],"queryParams":["includeDistinct"],"bodyTypes":["application/json"]},
{"id":"dbPerformanceActivity","method":"GET","path":"/qqq/v1/qbit/db-performance/activity","summary":"Database Performance activity","tag":"","pathParams":[],"queryParams":["backend"],"bodyTypes":[],"extension":"db-performance"},
{"id":"dbPerformanceExplain","method":"POST","path":"/qqq/v1/qbit/db-performance/explain","summary":"Explain a PostgreSQL query","tag":"","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"db-performance"},
{"id":"dbPerformanceLookup","method":"GET","path":"/qqq/v1/qbit/db-performance/statements/lookup","summary":"Database Performance lookup","tag":"","pathParams":[],"queryParams":["backend","sql"],"bodyTypes":[],"extension":"db-performance"},
{"id":"dbPerformanceOverview","method":"GET","path":"/qqq/v1/qbit/db-performance/overview","summary":"Database Performance overview","tag":"","pathParams":[],"queryParams":["backend"],"bodyTypes":[],"extension":"db-performance"},
{"id":"deleteAssistantConversationsById","method":"DELETE","path":"/qqq/v1/assistant/conversations/{id}","summary":"Delete a conversation","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"deleteQbitCollaborationAttachmentsById","method":"DELETE","path":"/qqq/v1/qbit/collaboration/attachments/{id}","summary":"Delete an attachment","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"deleteQbitCollaborationCommentsById","method":"DELETE","path":"/qqq/v1/qbit/collaboration/comments/{id}","summary":"Delete a comment thread","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"deleteQbitCollaborationFavorites","method":"DELETE","path":"/qqq/v1/qbit/collaboration/favorites","summary":"Unstar a target","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"deleteQbitCollaborationPresence","method":"DELETE","path":"/qqq/v1/qbit/collaboration/presence","summary":"Leave presence","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"deleteQbitCollaborationRecordsByTableByPkFollow","method":"DELETE","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Unfollow a record","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"deleteQbitDataAnalysisSavedByIdStar","method":"DELETE","path":"/qqq/v1/qbit/data-analysis/saved/{id}/star","summary":"Unstar a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"deleteRecord","method":"DELETE","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Delete a record from a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"download","method":"GET","path":"/qqq/v1/download/{file}","summary":"Download a file","tag":"General","pathParams":["file"],"queryParams":["filePath","storageTableName","storageReference"],"bodyTypes":[]},
{"id":"downloadRecordFieldFile","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/{fieldName}/{filename}","summary":"Download a field value from a record","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","filename"],"queryParams":[],"bodyTypes":[]},
{"id":"exportTable","method":"POST","path":"/qqq/v1/table/{tableName}/export","summary":"Export records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"getAssistantAccess","method":"GET","path":"/qqq/v1/assistant/access","summary":"Explain access: which UI API operations the assistant could call for this user, and why not","tag":"assistant","pathParams":[],"queryParams":["table","prompt","turnId"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantActionsById","method":"GET","path":"/qqq/v1/assistant/actions/{id}","summary":"A write the assistant proposed, and its state","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantConfig","method":"GET","path":"/qqq/v1/assistant/config","summary":"Whether the assistant is available to this user, its persona and limits","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantConversations","method":"GET","path":"/qqq/v1/assistant/conversations","summary":"The user's own conversations","tag":"assistant","pathParams":[],"queryParams":["status"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantConversationsById","method":"GET","path":"/qqq/v1/assistant/conversations/{id}","summary":"A conversation and a page of its messages","tag":"assistant","pathParams":["id"],"queryParams":["before","limit"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantTurnsActive","method":"GET","path":"/qqq/v1/assistant/turns/active","summary":"Find the user's active assistant runs","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantTurnsByTurnIdEvents","method":"GET","path":"/qqq/v1/assistant/turns/{turnId}/events","summary":"Resume a turn's events after a sequence number (replay, then the live tail)","tag":"assistant","pathParams":["turnId"],"queryParams":["after"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssociatedScriptLogs","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}/{scriptRevisionId}/logs","summary":"Get the logs of a record's associated script revision (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","scriptRevisionId"],"queryParams":[],"bodyTypes":[]},
{"id":"getAuthenticationMetaData","method":"GET","path":"/qqq/v1/metaData/authentication","summary":"Get authentication metaData","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getAvatar","method":"GET","path":"/qqq/v1/avatars/{avatarId}","summary":"Get an avatar","tag":"Authentication","pathParams":["avatarId"],"queryParams":["size","v"],"bodyTypes":[]},
{"id":"getBrandingAsset","method":"GET","path":"/qqq/v1/branding/{role}","summary":"Get a branding image","tag":"Authentication","pathParams":["role"],"queryParams":[],"bodyTypes":[]},
{"id":"getBulkLoadUploadProfile","method":"GET","path":"/qqq/v1/bulkLoad/uploads/{uploadRef}/profile","summary":"Profile an uploaded file for a bulk load","tag":"Bulk Load","pathParams":["uploadRef"],"queryParams":["tableName","sampleCount"],"bodyTypes":[]},
{"id":"getDeliveryOutput","method":"GET","path":"/qqq/v1/delivery/output/{logId}","summary":"Download stored output as its owner or delivery administrator","tag":"General","pathParams":["logId"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbDeadLettersByTrigger","method":"GET","path":"/qqq/v1/esb/deadLetters/{trigger}","summary":"Dead letters of a trigger","tag":"ESB","pathParams":["trigger"],"queryParams":["offset","limit"],"bodyTypes":[]},
{"id":"getEsbMessagesByDestination","method":"GET","path":"/qqq/v1/esb/messages/{destination}","summary":"Messages on a destination","tag":"ESB","pathParams":["destination"],"queryParams":["trigger","offset","limit"],"bodyTypes":[]},
{"id":"getEsbOverview","method":"GET","path":"/qqq/v1/esb/overview","summary":"ESB overview","tag":"ESB","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbProcessByProcess","method":"GET","path":"/qqq/v1/esb/process/{process}","summary":"ESB of a process","tag":"ESB","pathParams":["process"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbTableByTable","method":"GET","path":"/qqq/v1/esb/table/{table}","summary":"ESB of a table","tag":"ESB","pathParams":["table"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbDeadLettersByTrigger","method":"GET","path":"/qqq/v1/esb/deadLetters/{trigger}","summary":"Dead letters of a trigger","tag":"ESB","pathParams":["trigger"],"queryParams":["offset","limit"],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbMessagesByDestination","method":"GET","path":"/qqq/v1/esb/messages/{destination}","summary":"Messages on a destination","tag":"ESB","pathParams":["destination"],"queryParams":["trigger","offset","limit"],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbOverview","method":"GET","path":"/qqq/v1/esb/overview","summary":"ESB overview","tag":"ESB","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbProcessByProcess","method":"GET","path":"/qqq/v1/esb/process/{process}","summary":"ESB of a process","tag":"ESB","pathParams":["process"],"queryParams":[],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbTableByTable","method":"GET","path":"/qqq/v1/esb/table/{table}","summary":"ESB of a table","tag":"ESB","pathParams":["table"],"queryParams":[],"bodyTypes":[],"extension":"esb"},
{"id":"getHealthChecksByNameHistory","method":"GET","path":"/qqq/v1/health/checks/{name}/history","summary":"Health history","tag":"Health","pathParams":["name"],"queryParams":["from","to","limit"],"bodyTypes":[],"extension":"health"},
{"id":"getHealthComponentsByName","method":"GET","path":"/qqq/v1/health/components/{name}","summary":"Health component","tag":"Health","pathParams":["name"],"queryParams":[],"bodyTypes":[],"extension":"health"},
{"id":"getHealthSummary","method":"GET","path":"/qqq/v1/health/summary","summary":"Health summary","tag":"Health","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"health"},
{"id":"getHelpCoverage","method":"GET","path":"/qqq/v1/help/coverage","summary":"Inspect application help coverage","tag":"General","pathParams":[],"queryParams":["kind","missingOnly","parentKey"],"bodyTypes":[]},
{"id":"getJob","method":"GET","path":"/qqq/v1/jobs/{jobId}","summary":"Jobs: detail","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"getJobProgress","method":"GET","path":"/qqq/v1/jobs/{jobId}/progress","summary":"Jobs: progress","tag":"Processes","pathParams":["jobId"],"queryParams":["afterLogId","logLimit"],"bodyTypes":[]},
@@ -47,6 +101,37 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"getProcessMetaData","method":"GET","path":"/qqq/v1/metaData/process/{processName}","summary":"Get process metaData","tag":"Processes","pathParams":["processName"],"queryParams":[],"bodyTypes":[]},
{"id":"getProcessRecords","method":"GET","path":"/qqq/v1/processes/{processName}/{processUUID}/records","summary":"Get records from a process","tag":"Processes","pathParams":["processName","processUUID"],"queryParams":["skip","limit","tableVariant"],"bodyTypes":[]},
{"id":"getProcessStatus","method":"GET","path":"/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}","summary":"Get job status","tag":"Processes","pathParams":["processName","processUUID","jobUUID"],"queryParams":["tableVariant"],"bodyTypes":[]},
{"id":"getQbitCollaborationAccessRequestsMine","method":"GET","path":"/qqq/v1/qbit/collaboration/access-requests/mine","summary":"List my access requests","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationAttachmentsByIdDownload","method":"GET","path":"/qqq/v1/qbit/collaboration/attachments/{id}/download","summary":"Get a temporary download URL; clients follow url","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationAttachmentsByIdPreview","method":"GET","path":"/qqq/v1/qbit/collaboration/attachments/{id}/preview","summary":"Get a temporary preview URL; clients follow url","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationCommentsByIdHistory","method":"GET","path":"/qqq/v1/qbit/collaboration/comments/{id}/history","summary":"Read comment edit history","tag":"collaboration","pathParams":["id"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationFavorites","method":"GET","path":"/qqq/v1/qbit/collaboration/favorites","summary":"List personal favorites","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationMentionsCandidates","method":"GET","path":"/qqq/v1/qbit/collaboration/mentions/candidates","summary":"Find people to mention on a record","tag":"collaboration","pathParams":[],"queryParams":["table","pk","q","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationMyWork","method":"GET","path":"/qqq/v1/qbit/collaboration/my-work","summary":"Get my work summary","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeople","method":"GET","path":"/qqq/v1/qbit/collaboration/people","summary":"Search the active people directory","tag":"collaboration","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeopleByUserId","method":"GET","path":"/qqq/v1/qbit/collaboration/people/{userId}","summary":"Get an active directory person","tag":"collaboration","pathParams":["userId"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecents","method":"GET","path":"/qqq/v1/qbit/collaboration/recents","summary":"List recent targets","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkAttachments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"List record attachments","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkComments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Page record comment threads","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkFollow","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Get the caller's follow state","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkFollowers","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/followers","summary":"List record followers","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkLink","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/link","summary":"Get a record link","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkTags","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tags","summary":"List applied record tags","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkTasks","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tasks","summary":"List record tasks","tag":"collaboration","pathParams":["table","pk"],"queryParams":["includeDone","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkTimeline","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/timeline","summary":"Page the merged record timeline","tag":"collaboration","pathParams":["table","pk"],"queryParams":["before","types","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationTags","method":"GET","path":"/qqq/v1/qbit/collaboration/tags","summary":"Search table tag vocabulary","tag":"collaboration","pathParams":[],"queryParams":["table","q","limit"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationTasksMine","method":"GET","path":"/qqq/v1/qbit/collaboration/tasks/mine","summary":"List my tasks","tag":"collaboration","pathParams":[],"queryParams":["status","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitDataAnalysisRunsByRunId","method":"GET","path":"/qqq/v1/qbit/data-analysis/runs/{runId}","summary":"Get an exact profile run","tag":"data-analysis","pathParams":["runId"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitDataAnalysisSaved","method":"GET","path":"/qqq/v1/qbit/data-analysis/saved","summary":"List visible saved analyses","tag":"data-analysis","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitDataAnalysisSavedByIdExport","method":"GET","path":"/qqq/v1/qbit/data-analysis/saved/{id}/export","summary":"Export a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":["format"],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitDataAnalysisTablesByTableCapabilities","method":"GET","path":"/qqq/v1/qbit/data-analysis/tables/{table}/capabilities","summary":"Check analysis availability","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitEdiGuidesByGuideIdTree","method":"GET","path":"/qqq/v1/qbit/edi/guides/{guideId}/tree","summary":"Read the guide tree","tag":"edi","pathParams":["guideId"],"queryParams":[],"bodyTypes":[],"extension":"edi"},
{"id":"getQbitEdiMapsByMapIdTarget","method":"GET","path":"/qqq/v1/qbit/edi/maps/{mapId}/target","summary":"Read target metadata and map rules","tag":"edi","pathParams":["mapId"],"queryParams":[],"bodyTypes":[],"extension":"edi"},
{"id":"getQbitSecuredDataRevealOptions","method":"GET","path":"/qqq/v1/qbit/secured-data/reveal/options","summary":"RevealOptions","tag":"secured-data","pathParams":[],"queryParams":["tableName","fieldName"],"bodyTypes":[],"extension":"secured-data"},
{"id":"getQbitSecuredDataStatus","method":"GET","path":"/qqq/v1/qbit/secured-data/status","summary":"Status","tag":"secured-data","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"secured-data"},
{"id":"getQuickSearchStatus","method":"GET","path":"/qqq/v1/qbit/quick-search/status","summary":"Get Quick Search status","tag":"quick-search","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"quick-search"},
{"id":"getQuickSearchSuggestions","method":"GET","path":"/qqq/v1/qbit/quick-search/suggest","summary":"Get Quick Search suggestions","tag":"quick-search","pathParams":[],"queryParams":["q","limitPerTable","maxTables","tables"],"bodyTypes":[],"extension":"quick-search"},
{"id":"getRecord","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Get a record from a table by primary key","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["includeAssociations","queryJoins","tableVariant"],"bodyTypes":[]},
{"id":"getRecordDeveloperMode","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer","summary":"Get a record's associated scripts (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"getRecordFeatures","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/features","summary":"Get record collaboration features","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
@@ -68,9 +153,61 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"manageSession","method":"POST","path":"/qqq/v1/manageSession","summary":"Create a session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"oidcBackChannelLogout","method":"POST","path":"/qqq/v1/oidc/backchannel-logout","summary":"OIDC Back-Channel Logout","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"openRealtimeStream","method":"GET","path":"/qqq/v1/realtime/stream","summary":"Open the realtime event stream","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"patchAssistantConversationsById","method":"PATCH","path":"/qqq/v1/assistant/conversations/{id}","summary":"Rename, pin, archive or mark read a conversation","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"patchQbitCollaborationAttachmentsById","method":"PATCH","path":"/qqq/v1/qbit/collaboration/attachments/{id}","summary":"Rename or describe an attachment","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"patchQbitCollaborationCommentsById","method":"PATCH","path":"/qqq/v1/qbit/collaboration/comments/{id}","summary":"Edit the caller's comment","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"patchQbitCollaborationFavoritesOrder","method":"PATCH","path":"/qqq/v1/qbit/collaboration/favorites/order","summary":"Reorder personal favorites","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"patchQbitCollaborationTasksById","method":"PATCH","path":"/qqq/v1/qbit/collaboration/tasks/{id}","summary":"Update a task","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postAssistantActionsByIdConfirm","method":"POST","path":"/qqq/v1/assistant/actions/{id}/confirm","summary":"Confirm a proposed write: it runs once, through the UI API, with this request's session","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"postAssistantActionsByIdReject","method":"POST","path":"/qqq/v1/assistant/actions/{id}/reject","summary":"Cancel a proposed write; nothing changes","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"postAssistantClientErrors","method":"POST","path":"/qqq/v1/assistant/client-errors","summary":"Report an error the client hit while showing the assistant (stored for administrators, rate limited per user)","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantConversations","method":"POST","path":"/qqq/v1/assistant/conversations","summary":"Start a conversation","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantConversationsByIdTurns","method":"POST","path":"/qqq/v1/assistant/conversations/{id}/turns","summary":"Ask: start a turn and stream the reply as server-sent events","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantMessagesByIdFeedback","method":"POST","path":"/qqq/v1/assistant/messages/{id}/feedback","summary":"Thumbs up or down on an assistant message","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantTurnsByTurnIdCancel","method":"POST","path":"/qqq/v1/assistant/turns/{turnId}/cancel","summary":"Stop a running turn","tag":"assistant","pathParams":["turnId"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"postHealthChecksByNameRun","method":"POST","path":"/qqq/v1/health/checks/{name}/run","summary":"Health run","tag":"Health","pathParams":["name"],"queryParams":[],"bodyTypes":[],"extension":"health"},
{"id":"postQbitCollaborationAccessRequests","method":"POST","path":"/qqq/v1/qbit/collaboration/access-requests","summary":"Request access to a record","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationCommentsByIdReopen","method":"POST","path":"/qqq/v1/qbit/collaboration/comments/{id}/reopen","summary":"Reopen a comment thread","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"postQbitCollaborationCommentsByIdResolve","method":"POST","path":"/qqq/v1/qbit/collaboration/comments/{id}/resolve","summary":"Resolve a comment thread","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"postQbitCollaborationPresence","method":"POST","path":"/qqq/v1/qbit/collaboration/presence","summary":"Renew presence","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecents","method":"POST","path":"/qqq/v1/qbit/collaboration/recents","summary":"Record a recent view","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkAttachments","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"Upload an attachment","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["multipart/form-data"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkComments","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Post a comment or reply","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkShares","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/shares","summary":"Share a record","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkSharesPreview","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/shares/preview","summary":"Preview share recipients","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkTasks","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tasks","summary":"Create a task","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationTags","method":"POST","path":"/qqq/v1/qbit/collaboration/tags","summary":"Create or suggest a tag","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitDataAnalysisExport","method":"POST","path":"/qqq/v1/qbit/data-analysis/export","summary":"Export an analysis result","tag":"data-analysis","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisRuns","method":"POST","path":"/qqq/v1/qbit/data-analysis/runs","summary":"Start an exact profile run","tag":"data-analysis","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisSavedByIdRun","method":"POST","path":"/qqq/v1/qbit/data-analysis/saved/{id}/run","summary":"Run a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableColumnProfile","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/column-profile","summary":"Profile one column","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableCompare","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/compare","summary":"Compare two populations","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableCorrelation","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/correlation","summary":"Find numeric relationships","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableCrosstab","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/crosstab","summary":"Cross-tabulate two fields","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableDistinctValues","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/distinct-values","summary":"List distinct column values","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableDuplicates","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/duplicates","summary":"Find duplicate groups","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTablePivot","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/pivot","summary":"Explore a pivot table","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableScatter","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/scatter","summary":"Inspect a numeric relationship","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableShift","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/shift","summary":"Measure distribution shift over time","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableSql","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/sql","summary":"Read an analysis aggregate SQL statement","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableTableProfile","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/table-profile","summary":"Profile table completeness and keys","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisWatchesByIdPreview","method":"POST","path":"/qqq/v1/qbit/data-analysis/watches/{id}/preview","summary":"Preview an anomaly watch","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"postQbitEdiMapsByMapIdValidate","method":"POST","path":"/qqq/v1/qbit/edi/maps/{mapId}/validate","summary":"Validate unsaved map rules","tag":"edi","pathParams":["mapId"],"queryParams":[],"bodyTypes":["application/json"],"extension":"edi"},
{"id":"postQbitEdiSandboxPreview","method":"POST","path":"/qqq/v1/qbit/edi/sandbox/preview","summary":"Preview an encrypted sample with optional unsaved rules","tag":"edi","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"edi"},
{"id":"postQbitSecuredDataReveal","method":"POST","path":"/qqq/v1/qbit/secured-data/reveal","summary":"Reveal","tag":"secured-data","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"secured-data"},
{"id":"prepareRelatedRecordPicker","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/prepare","summary":"Prepare child rows for picked records (MULTI_CHILD, LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"putQbitCollaborationFavorites","method":"PUT","path":"/qqq/v1/qbit/collaboration/favorites","summary":"Star a target","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitCollaborationRecordsByTableByPkAssignment","method":"PUT","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/assignment","summary":"Set record owner and assignee","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitCollaborationRecordsByTableByPkFollow","method":"PUT","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Follow a record and configure notifications","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitCollaborationRecordsByTableByPkTags","method":"PUT","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tags","summary":"Add and remove record tags","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitDataAnalysisSavedByIdStar","method":"PUT","path":"/qqq/v1/qbit/data-analysis/saved/{id}/star","summary":"Star a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"qaCaptureCapability","method":"GET","path":"/qqq/v1/qbit/qa-capture/capability","summary":"Read the QA capture capability","tag":"","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"qa-capture"},
{"id":"qaCaptureReportCreate","method":"POST","path":"/qqq/v1/qbit/qa-capture/report","summary":"Create a QA report","tag":"","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"qa-capture"},
{"id":"queryRelatedRecordPicker","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/query","summary":"Query the records a related-record picker offers","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"queryTable","method":"POST","path":"/qqq/v1/table/{tableName}/query","summary":"Query for records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"releaseNotesReleases","method":"GET","path":"/qqq/v1/qbit/release-notes/releases","summary":"Published release notes","tag":"release-notes","pathParams":[],"queryParams":["limit","offset"],"bodyTypes":[],"extension":"release-notes"},
{"id":"releaseNotesSeen","method":"POST","path":"/qqq/v1/qbit/release-notes/seen","summary":"Mark What's new as seen","tag":"release-notes","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"release-notes"},
{"id":"releaseNotesSummary","method":"GET","path":"/qqq/v1/qbit/release-notes/summary","summary":"What's new summary","tag":"release-notes","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"release-notes"},
{"id":"removeRelatedRecords","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/remove","summary":"Unlink picked records (LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"retryJob","method":"POST","path":"/qqq/v1/jobs/{jobId}/retry","summary":"Jobs: retry","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"runFormAdjuster","method":"POST","path":"/qqq/v1/table/{tableName}/formAdjuster/{identifier}/{event}","summary":"Run a form adjuster","tag":"Tables","pathParams":["tableName","identifier","event"],"queryParams":[],"bodyTypes":["application/json"]},
@@ -81,6 +218,27 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"searchProcessPossibleValues","method":"POST","path":"/qqq/v1/processes/{processName}/possibleValues/{fieldName}","summary":"Search possible values for a process field","tag":"Processes","pathParams":["processName","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchRecords","method":"POST","path":"/qqq/v1/search","summary":"Search records across tables","tag":"Tables","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchTablePossibleValues","method":"POST","path":"/qqq/v1/table/{tableName}/possibleValues/{fieldName}","summary":"Search possible values for a table field","tag":"Tables","pathParams":["tableName","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"sqlWorkbenchCancelExecution","method":"POST","path":"/qqq/v1/sql-workbench/executions/{uuid}/cancel","summary":"Cancel the caller's running execution","tag":"sql-workbench","pathParams":["uuid"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchCreateNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks","summary":"Create a personal notebook","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchCreateSnapshot","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/snapshots","summary":"Pin the caller's cached result to an owned notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchDeleteNotebook","method":"DELETE","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Delete an owned notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchDeleteSnapshot","method":"DELETE","path":"/qqq/v1/sql-workbench/notebooks/{id}/snapshots/{snapshotId}","summary":"Delete a snapshot from an owned notebook","tag":"sql-workbench","pathParams":["id","snapshotId"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchDuplicateNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/duplicate","summary":"Make a personal copy of a visible notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchExecute","method":"POST","path":"/qqq/v1/sql-workbench/execute","summary":"Execute read-only SQL or preview and confirm a gated write","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchExplain","method":"POST","path":"/qqq/v1/sql-workbench/explain","summary":"Explain SQL; ANALYZE remains subject to write gates","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchExportResult","method":"GET","path":"/qqq/v1/sql-workbench/executions/{uuid}/export","summary":"Export the caller's cached result as CSV, JSON or XLSX","tag":"sql-workbench","pathParams":["uuid"],"queryParams":["format"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetCapabilities","method":"GET","path":"/qqq/v1/sql-workbench/capabilities","summary":"Get caller capabilities and configured limits","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetHistory","method":"GET","path":"/qqq/v1/sql-workbench/history","summary":"List the caller's recent audited executions","tag":"sql-workbench","pathParams":[],"queryParams":["limit","backendName"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetNotebook","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Get a visible notebook and its cells","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetRows","method":"GET","path":"/qqq/v1/sql-workbench/executions/{uuid}/rows","summary":"Page or sort the caller's cached result","tag":"sql-workbench","pathParams":["uuid"],"queryParams":["offset","limit","sort","dir"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetSchema","method":"GET","path":"/qqq/v1/sql-workbench/schema/{backendName}","summary":"Inspect a target backend schema","tag":"sql-workbench","pathParams":["backendName"],"queryParams":["refresh"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetVersion","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions/{n}","summary":"Get a visible notebook version and saved content","tag":"sql-workbench","pathParams":["id","n"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchListNotebooks","method":"GET","path":"/qqq/v1/sql-workbench/notebooks","summary":"List visible personal or shared notebooks","tag":"sql-workbench","pathParams":[],"queryParams":["scope"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchListSnapshots","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}/snapshots","summary":"List snapshots of a visible notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchListVersions","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions","summary":"List versions of a visible notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchPublishNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/publish","summary":"Publish or withdraw an owned notebook with share permission","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchRestoreVersion","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions/{n}/restore","summary":"Restore an owned notebook version as a new version","tag":"sql-workbench","pathParams":["id","n"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchUpdateNotebook","method":"PUT","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Update an owned notebook and record a version","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"stepProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}","summary":"Run a step in a process","tag":"Processes","pathParams":["processName","processUUID","stepName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
{"id":"storeAssociatedScript","method":"POST","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}","summary":"Store a new revision of a record's associated script (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"suggestBulkLoadMapping","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/suggestMapping","summary":"Suggest a bulk load mapping for an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
+68
View File
@@ -0,0 +1,68 @@
# Extensions in the kernel
Extensions are the qbits and modules that add UI API operations (`/qqq/v1/qbit/{name}/...`, or an
established prefix such as `/esb`). The client document carries every extension found when it was
generated; a server has only some of them installed and lists them in `GET /version`.
## Registry (`registry.ts`)
```ts
import { extensionRegistry, KNOWN_EXTENSIONS, operationsOf, ExtensionNotInstalledError } from '@qrun-io/qqq-core'
const registry = await extensionRegistry(server, authenticatedFetch) // GET /version, cached 5 min with the metadata
registry.has('collaboration') // installed at a version the client works with
registry.version('esb') // '1.1.0' | undefined
registry.require('collaboration') // throws ExtensionNotInstalledError:
// "extension collaboration is not installed on this server (installed: esb 1.1.0)"
registry.requireOperation(id) // same check for an operation id; core operations pass
registry.installed // every extension the server lists, `known: false` for ones the client lacks
registry.describe() // 'esb 1.1.0, zebra 0.3.0 (unknown to this client)'
```
- `KNOWN_EXTENSIONS`: name, label, path prefix, the version the document was generated from,
`minVersion` where the extension's docs state one (esb, health and collaboration 1.1.0), and
`channels`: the realtime channels it publishes on (collaboration: `record:{table}:{pk}`,
`table:{table}`, `user:me`).
- `operationsOf(name)` and `OperationInfo.extension` map operations to extensions.
- Unknown extensions are never dropped. An installed extension below `minVersion` gives
`has() === false` and `require()` throws with `reason: 'too-old'`.
- `ExtensionNotInstalledError` is the CLI's exit 5. The TUI should hide what `has()` denies
instead of showing the error.
## Collaboration (`collaboration.ts`)
```ts
const collab = new Collaboration(registry, {
call: (id, input) => callOperation(server, id, input, authenticatedFetch),
openStream: (signal) => openRealtimeStream({ baseUrl: server, signal, headers, token }), // optional
})
const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
onChange: (state) => draw(state.count, state.people), // people: [{ userId, name, mode, fieldName, since }]
mode: 'VIEWING', // or 'EDITING' (setMode('EDITING', 'email') while a field has focus)
announce: true, // false: watch without appearing
})
presence.count; presence.people; presence.state.live; presence.state.error
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
```
- Without the extension `joinPresence` returns at once with `available: false`, nobody there,
and no requests; `stop()` is a no-op. A TUI can call it for every screen unconditionally.
- With it: POST `/presence` now and every 20 s (leases last 45 s), GET the snapshot, and, when
`openStream` is given, subscribe to `record:{t}:{pk}` or `table:{t}` on the stream's `hello`
and apply `presence.changed` events. Without a stream, each heartbeat re-reads the snapshot.
The stream reconnects with backoff (2 s doubling to 30 s) and re-reads on `resync`.
- `since` is when this client first saw the person; the qbit does not send join times.
- The list includes the signed-in user when they hold a lease (tabs and clients merge per user;
EDITING wins).
- One stream per joinPresence. A TUI that already holds a stream for other channels can pass an
`openStream` that fans out its own connection's events instead of opening another.
- `state.error` holds a refused heartbeat (404 when presence is off for the table or the record is
not visible). The heartbeat keeps trying.
Other calls (all throw `ExtensionNotInstalledError` before any request when it is absent):
`favorites()`, `addFavorite(target)`, `removeFavorite(target)`, `recents(limit)`,
`recordView(target)`, `comments(table, pk, { cursor, limit })`, `addComment(table, pk, body,
{ parentCommentId, fieldName })`, `followState`, `follow(table, pk, prefs)`, `unfollow`,
`myWork()`, `people(q, limit)`, `presenceSnapshot(scope)` (empty when absent). Everything else the
qbit offers is reachable by operation id through `callOperation`.
@@ -0,0 +1,392 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The collaboration extension (qbit-collaboration, `/qqq/v1/qbit/collaboration`) for both heads:
* live presence on a table or record, comments, favorites, recents, follow, people and my work.
*
* Presence follows the qbit's contract: a lease is renewed with POST /presence every 20 seconds
* (it expires after 45), DELETE /presence leaves, GET /presence is the snapshot, and
* `presence.changed` events carry the same snapshot on the `record:{table}:{pk}` and
* `table:{table}` realtime channels. Connection ids need not be stream connections, so a client
* without the stream still takes part by polling on each heartbeat.
*
* When the server does not have the extension, presence is a no-op (`available: false`, nobody
* there) and the other calls throw ExtensionNotInstalledError.
*/
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import type { ExtensionRegistry } from './registry.js'
export const COLLABORATION = 'collaboration'
/** How the module reaches the server: the kernel's callOperation, and optionally the realtime stream. */
export interface CollaborationTransport {
call(id: string, input: CallInput): Promise<CallResult>
/** Opens GET /realtime/stream; without it presence is polled on each heartbeat. */
openStream?(signal: AbortSignal): AsyncIterable<RealtimeMessage>
}
/** A navigation target (favorites and recents). */
export interface CollaborationTarget {
targetType: 'TABLE' | 'APP' | 'RECORD' | 'PROCESS' | 'REPORT' | 'SAVED_VIEW'
targetName: string
recordId?: string
}
export interface FavoriteEntry {
id?: number
targetType?: string
targetName?: string
recordId?: string | null
label?: string
folder?: string | null
sortOrder?: number | null
available?: boolean
href?: string | null
}
export interface CommentEntry {
id?: number
authorUserId?: string
authorDisplayName?: string
body?: string | null
createdAt?: string
editedAt?: string | null
resolvedAt?: string | null
deleted?: boolean
fieldName?: string | null
replies?: CommentEntry[]
[key: string]: unknown
}
export interface CommentPage {
threads: CommentEntry[]
nextCursor?: string | null
}
export interface FollowState {
following: boolean
follow?: { notifyOnChange?: boolean; notifyOnComment?: boolean; reason?: string; mutedUntil?: string | null } | null
}
export interface PersonEntry {
userId?: string
displayName?: string
email?: string | null
lastSeenAt?: string | null
}
export interface MyWork {
openTaskCount?: number
unreadMentionCount?: number
assignedRecordsByTable?: Record<string, number>
tasks?: Record<string, unknown>[]
}
/** What presence is attached to: a table, or one record of it. */
export interface PresenceScope {
table: string
pk?: string
}
/** One person on the scope, merged across their tabs. */
export interface PresencePerson {
userId: string
name: string
mode: 'VIEWING' | 'EDITING' | string
fieldName?: string | null
/** When this client first saw them there (the qbit does not send join times). */
since: Date
}
export interface PresenceState {
/** False when the server has no collaboration extension: nobody is ever shown. */
available: boolean
scope: PresenceScope
count: number
people: PresencePerson[]
/** True while the realtime stream is connected and subscribed (else presence is polled). */
live: boolean
/** The last error (presence not enabled on the table, record not visible, ...), cleared on success. */
error?: string
}
export interface PresenceOptions {
/** Called after every change of the state. */
onChange?: (state: PresenceState) => void
/** VIEWING (default) or EDITING. */
mode?: 'VIEWING' | 'EDITING'
fieldName?: string
/** Heartbeat period; default 20000 ms as the qbit asks. */
heartbeatMs?: number
/** Connection id for the lease; default a random `cli-...` id. */
connectionId?: string
/** False to only watch (snapshot plus events) without holding a lease. Default true. */
announce?: boolean
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
reconnectMs?: number
}
export interface PresenceHandle {
readonly state: PresenceState
readonly count: number
readonly people: PresencePerson[]
/** Re-reads the snapshot now. */
refresh(): Promise<void>
/** Changes mode or focused field and renews the lease at once. */
setMode(mode: 'VIEWING' | 'EDITING', fieldName?: string): Promise<void>
/** Leaves (DELETE /presence), stops the heartbeat and closes the stream. */
stop(): Promise<void>
}
interface Viewer {
userId?: string
displayName?: string
mode?: string
fieldName?: string | null
}
interface Snapshot {
table?: string
pk?: string | null
viewers?: Viewer[]
}
/** The channel presence events for a scope arrive on. */
export function presenceChannel(scope: PresenceScope): string {
return scope.pk !== undefined ? `record:${scope.table}:${scope.pk}` : `table:${scope.table}`
}
function randomId(): string {
const bytes = new Uint8Array(8)
globalThis.crypto.getRandomValues(bytes)
return `cli-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The collaboration calls for one server; gated by its extension registry. */
export class Collaboration {
constructor(private readonly registry: ExtensionRegistry, private readonly transport: CollaborationTransport) {}
/** Whether the server has the extension (presence works, the other calls do not throw). */
get available(): boolean {
return this.registry.has(COLLABORATION)
}
private async data<T>(id: string, input: CallInput = {}): Promise<T> {
this.registry.require(COLLABORATION)
return (await this.transport.call(id, input)).data as T
}
/** The signed-in user's favorites, in their order. */
async favorites(): Promise<FavoriteEntry[]> {
return (await this.data<{ items?: FavoriteEntry[] }>('getQbitCollaborationFavorites')).items ?? []
}
/** Stars a target (idempotent). */
async addFavorite(target: CollaborationTarget): Promise<void> {
await this.data('putQbitCollaborationFavorites', { json: target })
}
/** Unstars a target (idempotent). */
async removeFavorite(target: CollaborationTarget): Promise<void> {
await this.data('deleteQbitCollaborationFavorites', { json: target })
}
/** Recently viewed targets, newest first (at most 50). */
async recents(limit?: number): Promise<FavoriteEntry[]> {
return (await this.data<{ items?: FavoriteEntry[] }>('getQbitCollaborationRecents', { query: { limit: limit === undefined ? undefined : String(limit) } })).items ?? []
}
/** Records a view of a target (the server ignores repeats within a minute). */
async recordView(target: CollaborationTarget): Promise<void> {
await this.data('postQbitCollaborationRecents', { json: target })
}
/** A page of a record's comment threads, by latest activity, with replies. */
async comments(table: string, pk: string, page: { cursor?: string; limit?: number } = {}): Promise<CommentPage> {
const result = await this.data<{ threads?: CommentEntry[]; nextCursor?: string | null }>('getQbitCollaborationRecordsByTableByPkComments', {
path: { table, pk },
query: { cursor: page.cursor, limit: page.limit === undefined ? undefined : String(page.limit) },
})
return { threads: result.threads ?? [], nextCursor: result.nextCursor ?? null }
}
/** Posts a comment, or a reply with `parentCommentId`. Mentions are `@[Name](user:id)`. */
async addComment(table: string, pk: string, body: string, options: { parentCommentId?: number; fieldName?: string } = {}): Promise<CommentEntry> {
return this.data<CommentEntry>('postQbitCollaborationRecordsByTableByPkComments', { path: { table, pk }, json: { body, ...options } })
}
/** The signed-in user's follow state on a record. */
async followState(table: string, pk: string): Promise<FollowState> {
return this.data<FollowState>('getQbitCollaborationRecordsByTableByPkFollow', { path: { table, pk } })
}
/** Follows a record; omitted preferences keep their current values. */
async follow(table: string, pk: string, preferences: { notifyOnChange?: boolean; notifyOnComment?: boolean } = {}): Promise<FollowState> {
return this.data<FollowState>('putQbitCollaborationRecordsByTableByPkFollow', { path: { table, pk }, json: preferences })
}
/** Stops following a record. */
async unfollow(table: string, pk: string): Promise<FollowState> {
return this.data<FollowState>('deleteQbitCollaborationRecordsByTableByPkFollow', { path: { table, pk } })
}
/** Open tasks, assigned records per table and unread mentions of the signed-in user. */
async myWork(): Promise<MyWork> {
return this.data<MyWork>('getQbitCollaborationMyWork')
}
/** Searches the active people directory by name or email. */
async people(q?: string, limit?: number): Promise<PersonEntry[]> {
return (await this.data<{ items?: PersonEntry[] }>('getQbitCollaborationPeople', { query: { q, limit: limit === undefined ? undefined : String(limit) } })).items ?? []
}
/** Who is on a table or record right now, without joining. Empty when the extension is absent. */
async presenceSnapshot(scope: PresenceScope): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
if (!state.available) return state
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot
const now = new Date()
state.people = (snapshot.viewers ?? []).map((v) => ({ userId: String(v.userId ?? ''), name: String(v.displayName ?? v.userId ?? ''), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: now }))
state.count = state.people.length
return state
}
/**
* Joins presence on a table or record: holds a lease (unless `announce: false`), subscribes to
* the scope's realtime channel when a stream is available, and keeps `state` current. Returns
* at once with a no-op handle when the extension is absent.
*/
async joinPresence(scope: PresenceScope, options: PresenceOptions = {}): Promise<PresenceHandle> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
const notify = () => options.onChange?.({ ...state, people: [...state.people] })
if (!state.available) {
return { state, get count() { return 0 }, get people() { return [] }, refresh: async () => {}, setMode: async () => {}, stop: async () => {} }
}
const announce = options.announce !== false
const connectionId = options.connectionId ?? randomId()
const firstSeen = new Map<string, Date>()
let mode = options.mode ?? 'VIEWING'
let fieldName = options.fieldName
let stopped = false
const abort = new AbortController()
const apply = (snapshot: Snapshot) => {
const now = new Date()
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const ids = new Set(viewers.map((v) => String(v.userId)))
for (const id of [...firstSeen.keys()]) if (!ids.has(id)) firstSeen.delete(id)
state.people = viewers.map((v) => {
const userId = String(v.userId)
if (!firstSeen.has(userId)) firstSeen.set(userId, now)
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen.get(userId)! }
})
state.count = state.people.length
state.error = undefined
notify()
}
const refresh = async () => {
try {
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
} catch (error) {
state.error = messageOf(error)
notify()
}
}
const heartbeat = async () => {
if (stopped) return
if (announce) {
try {
const result = await this.transport.call('postQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk, mode, fieldName } })
const data = result.data as Snapshot | undefined
// The qbit answers the heartbeat with the snapshot; older builds answer {success}.
if (data && typeof data.table === 'string') {
apply(data)
return
}
} catch (error) {
state.error = messageOf(error)
notify()
return
}
}
await refresh()
}
const subscribe = async () => {
if (!this.transport.openStream) return
let delay = options.reconnectMs ?? 2000
while (!stopped) {
try {
for await (const message of this.transport.openStream(abort.signal)) {
if (stopped) break
if (message.event === 'hello') {
const hello = (message.data ?? {}) as { connectionId?: string }
const result = await this.transport.call('updateRealtimeSubscriptions', { json: { connectionId: hello.connectionId, add: [presenceChannel(scope)] } })
const active = ((result.data ?? {}) as { active?: string[] }).active ?? []
state.live = active.includes(presenceChannel(scope))
delay = options.reconnectMs ?? 2000
await refresh()
} else if (message.event === 'presence.changed') {
// An emptied scope arrives as {"table": "person"} with no viewers at all.
const raw = message.data as Snapshot & { data?: Snapshot }
const snapshot = raw && typeof raw.table === 'string' ? raw : raw?.data
if (snapshot && snapshot.table === scope.table && (scope.pk === undefined ? snapshot.pk == null : String(snapshot.pk) === scope.pk)) apply(snapshot)
} else if (message.event === 'resync') {
await refresh()
}
}
} catch (error) {
if (stopped) break
state.error = messageOf(error)
}
if (stopped) break
state.live = false
notify()
await new Promise((resolve) => setTimeout(resolve, delay))
delay = Math.min(delay * 2, 30000)
}
}
await heartbeat()
const timer = setInterval(() => void heartbeat(), options.heartbeatMs ?? 20000)
void subscribe()
return {
state,
get count() { return state.count },
get people() { return state.people },
refresh,
setMode: async (next, field) => {
mode = next
fieldName = field
await heartbeat()
},
stop: async () => {
if (stopped) return
stopped = true
clearInterval(timer)
abort.abort()
state.live = false
if (announce) {
try {
await this.transport.call('deleteQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk } })
} catch {
// the lease expires on its own after 45 seconds
}
}
},
}
}
}
+220
View File
@@ -0,0 +1,220 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Extensions (qbits and modules that add UI API operations): which ones the client knows, which
* operations belong to each, and which ones a server has installed. The server's truth is
* GET /version `extensions`; the client's knowledge is the merged document it was generated from
* (DOCUMENT_EXTENSIONS and OperationInfo.extension) plus the facts below that the documents do not
* carry (documented minimum versions and the realtime channels an extension publishes on).
*
* Rules: an extension the server lists but the client does not know is kept and shown, never
* dropped; an operation of an extension the server does not list is refused before any request,
* with a message naming what is installed.
*/
import { DOCUMENT_EXTENSIONS, OPERATIONS, operation, type OperationInfo } from '../api/operations.js'
import { getServerVersion, type MetaDataCacheOptions, type ServerVersion } from '../metadata/cache.js'
/** What the client knows about one extension. */
export interface KnownExtension {
name: string
/** Short human name, for headings. */
label: string
/** The repository that publishes it. */
repository: string
/** Path prefix under /qqq/v1 (`/qbit/{name}`, or an established one such as `/esb`). */
pathPrefix: string
/** The extension version the client document was generated from. */
documentVersion?: string
/** The lowest server version of this extension the client works with, when its docs state one. */
minVersion?: string
/** The UI API version the extension's docs say it needs, when they say. */
minUiApi?: string
/** Realtime channels (GET /realtime/stream subscriptions) the extension publishes events on. */
channels: string[]
/** One line on what it does. */
description: string
}
/** One extension as the server lists it, joined with what the client knows. */
export interface InstalledExtension {
name: string
version?: string
pathPrefix?: string
description?: string
/** False when the client has no document for it (it is still listed). */
known: boolean
/** False when the client knows a minimum version and the server's is lower. */
meetsMinimum: boolean
}
interface Facts {
label: string
repository: string
minVersion?: string
minUiApi?: string
channels?: string[]
}
/**
* Facts from each extension's own docs (README, UI-API-CHANGELOG.md) that the OpenAPI documents
* do not carry. Minimums are only set where the docs state one.
*/
const FACTS: Record<string, Facts> = {
// UI-API-CHANGELOG.md: esb and health 1.1.0 added operationIds, tags and the standard errors.
esb: { label: 'ESB', repository: 'qqq (qqq-esb)', minVersion: '1.1.0' },
health: { label: 'Health', repository: 'qqq (qqq-middleware-javalin)', minVersion: '1.1.0' },
alerts: { label: 'Alerts', repository: 'qbit-alerts' },
assistant: { label: 'Assistant', repository: 'qbit-assistant' },
// qbit-collaboration README: 1.1.0 carries the UI API 1.6.0 error contract and the published
// presence schemas; it publishes on record:{table}:{pk}, table:{table} and user:{userId}.
collaboration: { label: 'Collaboration', repository: 'qbit-collaboration', minVersion: '1.1.0', minUiApi: '1.6.0', channels: ['record:{table}:{pk}', 'table:{table}', 'user:me'] },
'data-analysis': { label: 'Data analysis', repository: 'qbit-data-analysis' },
'db-performance': { label: 'DB performance', repository: 'qbit-db-performance' },
edi: { label: 'EDI', repository: 'qbit-edi', minUiApi: '1.6.0' },
'qa-capture': { label: 'QA capture', repository: 'qbit-qa-capture' },
'quick-search': { label: 'Quick search', repository: 'qbit-quick-search' },
'release-notes': { label: 'Release notes', repository: 'qbit-release-notes' },
'secured-data': { label: 'Secured data', repository: 'qbit-secured-data', minUiApi: '1.6.0' },
'sql-workbench': { label: 'SQL workbench', repository: 'qbit-sql-workbench' },
}
/** Every extension the client has a document for, in document order. */
export const KNOWN_EXTENSIONS: readonly KnownExtension[] = DOCUMENT_EXTENSIONS.map((e) => {
const facts: Facts = FACTS[e.name] ?? { label: e.name, repository: `qbit-${e.name}` }
return {
name: e.name,
label: facts.label,
repository: facts.repository,
pathPrefix: e.pathPrefix ?? `/qbit/${e.name}`,
documentVersion: e.version,
minVersion: facts.minVersion,
minUiApi: facts.minUiApi,
channels: facts.channels ?? [],
description: e.description ?? '',
}
})
const knownByName = new Map(KNOWN_EXTENSIONS.map((e) => [e.name, e]))
/** What the client knows about an extension, or undefined. */
export function knownExtension(name: string): KnownExtension | undefined {
return knownByName.get(name)
}
/** The operations an extension serves, by id. */
export function operationsOf(name: string): OperationInfo[] {
return OPERATIONS.filter((o) => o.extension === name)
}
/** The extension an operation belongs to; undefined for core operations and unknown ids. */
export function extensionOfOperation(id: string): string | undefined {
return operation(id)?.extension
}
/** Compares two MAJOR.MINOR.PATCH versions (pre-release suffixes ignored). */
export function compareVersions(a: string, b: string): number {
const parts = (v: string) => v.split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0)
const [x, y] = [parts(a), parts(b)]
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0)
return 0
}
/** Thrown when an operation or command needs an extension the server does not have (CLI exit 5). */
export class ExtensionNotInstalledError extends Error {
readonly extension: string
readonly installed: InstalledExtension[]
/** `absent`, or `too-old` when the server has it below the client's minimum. */
readonly reason: 'absent' | 'too-old'
constructor(extension: string, installed: InstalledExtension[], reason: 'absent' | 'too-old' = 'absent', operationId?: string) {
const list = installed.length ? installed.map((e) => `${e.name}${e.version ? ` ${e.version}` : ''}`).join(', ') : 'none'
const subject = operationId ? `${operationId} belongs to extension ${extension}, which` : `extension ${extension}`
const found = installed.find((e) => e.name === extension)
const text = reason === 'too-old'
? `${operationId ? `${operationId} needs` : 'this client needs'} extension ${extension} ${knownExtension(extension)?.minVersion} or later; this server has ${found?.version ?? 'an unknown version'} (installed: ${list})`
: `${subject} is not installed on this server (installed: ${list})`
super(text)
this.name = 'ExtensionNotInstalledError'
this.extension = extension
this.installed = installed
this.reason = reason
}
}
/** Joins the server's GET /version `extensions` with the client's knowledge. Unknown ones are kept. */
export function joinInstalled(version: ServerVersion): InstalledExtension[] {
return (version.extensions ?? []).filter((e) => e && typeof e.name === 'string').map((e) => {
const known = knownByName.get(e.name)
const meetsMinimum = !known?.minVersion || !e.version || compareVersions(e.version, known.minVersion) >= 0
return { name: e.name, version: e.version, pathPrefix: e.pathPrefix, description: e.description, known: Boolean(known), meetsMinimum }
})
}
/** The extensions a server has installed, from its GET /version (cached with the metadata cache). */
export async function installedExtensions(server: string, fetchImpl: typeof fetch, options: MetaDataCacheOptions & { offline?: boolean; refresh?: boolean } = {}): Promise<InstalledExtension[]> {
return joinInstalled(await getServerVersion(server, fetchImpl, options))
}
/** One server's extensions: what is installed, and the checks commands make before calling. */
export class ExtensionRegistry {
readonly installed: InstalledExtension[]
private readonly byName: Map<string, InstalledExtension>
constructor(installed: InstalledExtension[]) {
this.installed = installed
this.byName = new Map(installed.map((e) => [e.name, e]))
}
/** A registry from a GET /version document. */
static fromVersion(version: ServerVersion): ExtensionRegistry {
return new ExtensionRegistry(joinInstalled(version))
}
/** Whether the server has the extension at a version the client works with. */
has(name: string): boolean {
const e = this.byName.get(name)
return Boolean(e && e.meetsMinimum)
}
/** The installed version of an extension, or undefined. */
version(name: string): string | undefined {
return this.byName.get(name)?.version
}
/** The installed extension, or a typed error naming what is installed. */
require(name: string): InstalledExtension {
const e = this.byName.get(name)
if (!e) throw new ExtensionNotInstalledError(name, this.installed, 'absent')
if (!e.meetsMinimum) throw new ExtensionNotInstalledError(name, this.installed, 'too-old')
return e
}
/** Throws when the operation belongs to an extension this server does not have; core operations pass. */
requireOperation(id: string): void {
const name = extensionOfOperation(id)
if (!name) return
const e = this.byName.get(name)
if (!e) throw new ExtensionNotInstalledError(name, this.installed, 'absent', id)
if (!e.meetsMinimum) throw new ExtensionNotInstalledError(name, this.installed, 'too-old', id)
}
/** Installed extensions the client has no document for (tolerated and listed). */
unknown(): InstalledExtension[] {
return this.installed.filter((e) => !e.known)
}
/** `esb 1.1.0, foo 2.0.0 (unknown to this client)`, or `none`. */
describe(): string {
if (!this.installed.length) return 'none'
return this.installed.map((e) => `${e.name}${e.version ? ` ${e.version}` : ''}${e.known ? '' : ' (unknown to this client)'}${e.meetsMinimum ? '' : ` (below ${knownExtension(e.name)?.minVersion})`}`).join(', ')
}
}
/** The registry of a server, from its cached GET /version. */
export async function extensionRegistry(server: string, fetchImpl: typeof fetch, options: MetaDataCacheOptions & { offline?: boolean; refresh?: boolean } = {}): Promise<ExtensionRegistry> {
return new ExtensionRegistry(await installedExtensions(server, fetchImpl, options))
}
+2
View File
@@ -31,3 +31,5 @@ export * from './forms/datetime.js'
export * from './records/associations.js'
export * from './records/record-view.js'
export * from './render/files.js'
export * from './extensions/registry.js'
export * from './extensions/collaboration.js'
+53 -2
View File
@@ -70,7 +70,10 @@ export function writeCachedMetaData(server: string, metaData: MetaDataDocument,
/** Removes the cache of one server, or of every server. */
export function clearMetaDataCache(server?: string, options: MetaDataCacheOptions = {}): void {
const dir = options.dir ?? defaultDir()
if (server) rmSync(fileFor(dir, server), { force: true })
if (server) {
rmSync(fileFor(dir, server), { force: true })
rmSync(versionFileFor(dir, server), { force: true })
}
else rmSync(dir, { recursive: true, force: true })
}
@@ -85,7 +88,8 @@ export async function getMetaData(server: string, fetchImpl: typeof fetch, optio
if (options.offline && !cached) throw new Error('No cached metadata for this server; run any qqq command once to fill the cache.')
let version: string | undefined
try {
const v = (await callOperation(server, 'getVersion', {}, fetchImpl)).data as { apiVersion?: string }
const v = (await callOperation(server, 'getVersion', {}, fetchImpl)).data as ServerVersion
writeCachedVersion(server, v, options)
version = v.apiVersion
} catch (error) {
if (cached) return cached.metaData
@@ -133,3 +137,50 @@ export async function getTableMetaData<T = unknown>(server: string, table: strin
writeCachedTableMeta(server, table, metaData, options)
return metaData
}
/** GET /version: the API semver, core features, registered extensions and accepted client channels. */
export interface ServerVersion {
apiVersion?: string
basePath?: string
supportedMajorVersions?: number[]
features?: string[]
extensions?: { name: string; version?: string; pathPrefix?: string; description?: string }[]
clientChannels?: string[]
[key: string]: unknown
}
function versionFileFor(dir: string, server: string): string {
return join(dir, `version-${server.replace(/[^A-Za-z0-9.-]+/g, '_')}.json`)
}
function writeCachedVersion(server: string, version: ServerVersion, options: MetaDataCacheOptions = {}): void {
const dir = options.dir ?? defaultDir()
mkdirSync(dir, { recursive: true, mode: 0o700 })
writeFileSync(versionFileFor(dir, server), JSON.stringify({ fetchedAt: new Date().toISOString(), version }), { mode: 0o600 })
}
/**
* The server's GET /version, cached next to its metadata with the same TTL (it is unsecured and
* small, but listings and completion should not wait for it). `offline` uses a stale copy.
*/
export async function getServerVersion(server: string, fetchImpl: typeof fetch, options: MetaDataCacheOptions & { offline?: boolean; refresh?: boolean } = {}): Promise<ServerVersion> {
const file = versionFileFor(options.dir ?? defaultDir(), server)
let cached: { fetchedAt: string; version: ServerVersion } | undefined
if (!options.refresh && existsSync(file)) {
try {
cached = JSON.parse(readFileSync(file, 'utf8')) as { fetchedAt: string; version: ServerVersion }
} catch {
cached = undefined
}
}
if (cached && (options.offline || Date.now() - Date.parse(cached.fetchedAt) < (options.ttlSeconds ?? 300) * 1000)) return cached.version
if (options.offline && !cached) throw new Error('No cached version for this server; run any qqq command once to fill the cache.')
try {
const version = (await callOperation(server, 'getVersion', {}, fetchImpl)).data as ServerVersion
writeCachedVersion(server, version, options)
return version
} catch (error) {
if (cached) return cached.version
throw error
}
}
@@ -0,0 +1,130 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import { Collaboration, presenceChannel, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
import { ExtensionNotInstalledError, ExtensionRegistry } from '../extensions/registry.js'
const withCollab = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.1.0' }] })
const without = ExtensionRegistry.fromVersion({ extensions: [{ name: 'esb', version: '1.1.0' }] })
const result = (data: unknown): CallResult => ({ status: 200, headers: new Headers(), data, contentType: 'application/json' })
const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms))
/** A fake server: records calls, answers from a handler. */
function fake(handler: (id: string, input: CallInput) => unknown, stream?: (signal: AbortSignal) => AsyncIterable<RealtimeMessage>) {
const calls: { id: string; input: CallInput }[] = []
const transport: CollaborationTransport = {
call: async (id, input) => {
calls.push({ id, input })
return result(handler(id, input))
},
openStream: stream,
}
return { calls, transport }
}
test('without the extension, presence is a no-op and other calls throw the typed error before any request', async () => {
const { calls, transport } = fake(() => ({}))
const collab = new Collaboration(without, transport)
assert.equal(collab.available, false)
const handle = await collab.joinPresence({ table: 'person', pk: '1' })
assert.deepEqual([handle.count, handle.people, handle.state.available], [0, [], false])
await handle.stop()
assert.equal((await collab.presenceSnapshot({ table: 'person' })).count, 0)
await assert.rejects(collab.favorites(), (e: unknown) => e instanceof ExtensionNotInstalledError && /installed: esb 1\.1\.0/.test(e.message))
assert.equal(calls.length, 0)
})
test('favorites, recents, comments, follow, people and my work call the qbit operations', async () => {
const { calls, transport } = fake((id) => {
if (id === 'getQbitCollaborationFavorites') return { items: [{ id: 1, targetType: 'RECORD', targetName: 'person', recordId: '1', label: 'Avery' }], nextCursor: null }
if (id === 'getQbitCollaborationRecordsByTableByPkComments') return { threads: [{ id: 5, body: 'hi' }], nextCursor: 'c2' }
if (id === 'postQbitCollaborationRecordsByTableByPkComments') return { id: 6, body: 'new' }
if (id.includes('Follow')) return { following: id !== 'deleteQbitCollaborationRecordsByTableByPkFollow' }
if (id === 'getQbitCollaborationPeople') return { items: [{ userId: 'bob', displayName: 'Bob Chen' }] }
if (id === 'getQbitCollaborationMyWork') return { openTaskCount: 2, unreadMentionCount: 1 }
return { items: [], success: true }
})
const collab = new Collaboration(withCollab, transport)
assert.equal((await collab.favorites())[0]?.label, 'Avery')
await collab.addFavorite({ targetType: 'RECORD', targetName: 'person', recordId: '1' })
await collab.removeFavorite({ targetType: 'TABLE', targetName: 'person' })
await collab.recents(10)
assert.deepEqual(await collab.comments('person', '1', { limit: 5 }), { threads: [{ id: 5, body: 'hi' }], nextCursor: 'c2' })
assert.equal((await collab.addComment('person', '1', 'new', { parentCommentId: 5 })).id, 6)
assert.equal((await collab.follow('person', '1', { notifyOnComment: true })).following, true)
assert.equal((await collab.unfollow('person', '1')).following, false)
assert.equal((await collab.people('bo'))[0]?.displayName, 'Bob Chen')
assert.equal((await collab.myWork()).openTaskCount, 2)
const byId = Object.fromEntries(calls.map((c) => [c.id, c.input]))
assert.deepEqual(byId.putQbitCollaborationFavorites, { json: { targetType: 'RECORD', targetName: 'person', recordId: '1' } })
assert.deepEqual(byId.getQbitCollaborationRecents, { query: { limit: '10' } })
assert.deepEqual(byId.postQbitCollaborationRecordsByTableByPkComments, { path: { table: 'person', pk: '1' }, json: { body: 'new', parentCommentId: 5 } })
assert.deepEqual(byId.getQbitCollaborationPeople, { query: { q: 'bo', limit: undefined } })
})
test('joinPresence holds a lease, polls on each heartbeat without a stream, tracks since, and leaves on stop', async () => {
let viewers = [{ userId: 'bob', displayName: 'Bob Chen', mode: 'VIEWING' }]
const { calls, transport } = fake((id) => (id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '1', viewers }))
const states: PresenceState[] = []
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { heartbeatMs: 20, connectionId: 'cli-test', onChange: (s) => states.push(s) })
assert.equal(handle.count, 1)
const bobSince = handle.people[0]!.since
viewers = [...viewers, { userId: 'carol', displayName: 'Carol', mode: 'EDITING' }]
await tick(35)
assert.deepEqual(handle.people.map((p) => [p.name, p.mode]), [['Bob Chen', 'VIEWING'], ['Carol', 'EDITING']])
assert.equal(handle.people[0]!.since, bobSince)
await handle.stop()
const posts = calls.filter((c) => c.id === 'postQbitCollaborationPresence')
assert.ok(posts.length >= 2)
assert.deepEqual(posts[0]!.input.json, { connectionId: 'cli-test', table: 'person', pk: '1', mode: 'VIEWING', fieldName: undefined })
assert.deepEqual(calls.at(-1), { id: 'deleteQbitCollaborationPresence', input: { json: { connectionId: 'cli-test', table: 'person', pk: '1' } } })
const after = calls.length
await tick(30)
assert.equal(calls.length, after, 'no heartbeat after stop')
assert.ok(states.length >= 2)
})
test('joinPresence subscribes the scope channel on hello and applies presence.changed for its scope only', async () => {
const events: RealtimeMessage[] = [
{ event: 'hello', data: { connectionId: 'sse-1', heartbeatSeconds: 25 } },
{ event: 'presence.changed', data: { table: 'person', viewers: [{ userId: 'zed', displayName: 'Zed' }] } },
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'bob', displayName: 'Bob Chen', mode: 'EDITING', fieldName: 'email' }] } },
]
const emptied: RealtimeMessage = { event: 'presence.changed', data: { table: 'person', pk: '1' } }
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
let next = 0
while (!signal.aborted) {
await tick(2)
if (next < events.length) yield events[next++]!
}
}
const { calls, transport } = fake((id, input) => {
if (id === 'updateRealtimeSubscriptions') return { active: (input.json as { add: string[] }).add, refused: [] }
if (id === 'deleteQbitCollaborationPresence') return { success: true }
return { table: 'person', pk: '1', viewers: [] }
}, stream)
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
await tick(30)
assert.deepEqual(calls.find((c) => c.id === 'updateRealtimeSubscriptions')?.input.json, { connectionId: 'sse-1', add: ['record:person:1'] })
assert.equal(handle.state.live, true)
assert.deepEqual(handle.people.map((p) => [p.userId, p.mode, p.fieldName]), [['bob', 'EDITING', 'email']])
events.push(emptied)
await tick(10)
assert.equal(handle.count, 0, 'a snapshot without viewers empties the scope (the qbit omits the empty list)')
await handle.stop()
assert.equal(calls.some((c) => c.id === 'postQbitCollaborationPresence' || c.id === 'deleteQbitCollaborationPresence'), false, 'watch-only never takes a lease')
assert.equal(presenceChannel({ table: 'person' }), 'table:person')
})
test('a refused heartbeat is reported as the state error', async () => {
const transport: CollaborationTransport = { call: async (id) => { if (id === 'postQbitCollaborationPresence') throw new Error('Not found'); return result({ success: true }) } }
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'secret' }, { heartbeatMs: 10000 })
assert.equal(handle.state.error, 'Not found')
await handle.stop()
})
+70
View File
@@ -0,0 +1,70 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { OPERATIONS, operation } from '../api/operations.js'
import { ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, compareVersions, extensionOfOperation, installedExtensions, knownExtension, operationsOf } from '../extensions/registry.js'
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
test('every extension operation in the index names its extension; core operations name none', () => {
assert.equal(operation('getEsbOverview')?.extension, 'esb')
assert.equal(operation('getQbitCollaborationPresence')?.extension, 'collaboration')
assert.equal(operation('queryTable')?.extension, undefined)
for (const e of KNOWN_EXTENSIONS) assert.ok(operationsOf(e.name).length > 0, `${e.name} has operations`)
const names = new Set(KNOWN_EXTENSIONS.map((e) => e.name))
for (const o of OPERATIONS) if (o.extension) assert.ok(names.has(o.extension), `${o.id} -> ${o.extension}`)
for (const name of ['esb', 'health', 'alerts', 'assistant', 'collaboration', 'data-analysis', 'sql-workbench', 'secured-data', 'edi', 'quick-search', 'qa-capture']) assert.ok(knownExtension(name), name)
assert.equal(knownExtension('collaboration')?.pathPrefix, '/qbit/collaboration')
assert.deepEqual(knownExtension('collaboration')?.channels, ['record:{table}:{pk}', 'table:{table}', 'user:me'])
assert.equal(extensionOfOperation('getQbitEdiGuidesByGuideIdTree') ?? extensionOfOperation(operationsOf('edi')[0]!.id), 'edi')
})
test('the registry knows what is installed, keeps unknown extensions, and refuses absent ones with the installed list', () => {
const registry = ExtensionRegistry.fromVersion({ apiVersion: '1.8.0', extensions: [{ name: 'esb', version: '1.1.0' }, { name: 'zebra', version: '0.3.0' }] })
assert.equal(registry.has('esb'), true)
assert.equal(registry.has('collaboration'), false)
assert.equal(registry.version('esb'), '1.1.0')
assert.deepEqual(registry.unknown().map((e) => e.name), ['zebra'])
assert.equal(registry.describe(), 'esb 1.1.0, zebra 0.3.0 (unknown to this client)')
assert.throws(() => registry.require('collaboration'), (e: unknown) => e instanceof ExtensionNotInstalledError && e.message === 'extension collaboration is not installed on this server (installed: esb 1.1.0, zebra 0.3.0)' && e.reason === 'absent')
assert.doesNotThrow(() => registry.requireOperation('queryTable'))
assert.doesNotThrow(() => registry.requireOperation('getEsbOverview'))
assert.throws(() => registry.requireOperation('getQbitCollaborationMyWork'), /getQbitCollaborationMyWork belongs to extension collaboration, which is not installed on this server \(installed: esb 1\.1\.0/)
assert.throws(() => new ExtensionRegistry([]).require('esb'), /\(installed: none\)/)
})
test('an installed extension below the documented minimum is not usable', () => {
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.0.3' }] })
assert.equal(registry.has('collaboration'), false)
assert.throws(() => registry.require('collaboration'), (e: unknown) => e instanceof ExtensionNotInstalledError && e.reason === 'too-old' && /1\.1\.0 or later; this server has 1\.0\.3/.test(e.message))
assert.ok(compareVersions('1.10.0', '1.9.9') > 0)
assert.equal(compareVersions('1.1.0-RC.2', '1.1.0'), 0)
})
test('installedExtensions reads GET /version once and serves it from the cache', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-ext-'))
let calls = 0
const fetchImpl: typeof fetch = async () => {
calls++
return json({ apiVersion: '1.8.0', extensions: [{ name: 'esb', version: '1.1.0', pathPrefix: '/esb' }] })
}
try {
const first = await installedExtensions('http://h', fetchImpl, { dir })
assert.deepEqual(first, [{ name: 'esb', version: '1.1.0', pathPrefix: '/esb', description: undefined, known: true, meetsMinimum: true }])
await installedExtensions('http://h', fetchImpl, { dir })
assert.equal(calls, 1)
await installedExtensions('http://h', fetchImpl, { dir, refresh: true })
assert.equal(calls, 2)
const offline = await installedExtensions('http://h', async () => { throw new Error('down') }, { dir, ttlSeconds: 0 })
assert.equal(offline[0]?.name, 'esb')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
@@ -0,0 +1,96 @@
{
"core": "http://localhost:8077/qqq/v1/openapi.json",
"extensions": [
{
"name": "esb",
"version": "1.1.0",
"document": ".worktrees/qqq-develop/qqq-esb/src/main/resources/openapi/qqq-v1/esb.yaml",
"branch": "HEAD",
"commit": "6ac64fc66"
},
{
"name": "health",
"version": "1.1.0",
"document": ".worktrees/qqq-develop/qqq-middleware-javalin/src/main/resources/openapi/qqq-v1/health.yaml",
"branch": "HEAD",
"commit": "6ac64fc66"
},
{
"name": "alerts",
"version": "1.2.0",
"document": "qbit-alerts/src/main/resources/openapi/qqq-v1/alerts.yaml",
"branch": "develop",
"commit": "a3fc6ac"
},
{
"name": "assistant",
"version": "1.6.0",
"document": "qbit-assistant/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml",
"branch": "develop",
"commit": "bcf173f"
},
{
"name": "collaboration",
"version": "1.1.0",
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "develop",
"commit": "03407ae"
},
{
"name": "data-analysis",
"version": "1.5.0",
"document": "qbit-data-analysis/src/main/resources/openapi/qqq-v1/data-analysis.yaml",
"branch": "develop",
"commit": "368d2c0"
},
{
"name": "db-performance",
"version": "1.0.0",
"document": "qbit-db-performance/src/main/resources/openapi/qqq-v1/db-performance.yaml",
"branch": "develop",
"commit": "4fd2c30"
},
{
"name": "edi",
"version": "1.1.0",
"document": "qbit-edi/src/main/resources/openapi/qqq-v1/edi.yaml",
"branch": "develop",
"commit": "75a4e6b"
},
{
"name": "qa-capture",
"version": "1.0.0",
"document": "qbit-qa-capture/src/main/resources/openapi/qqq-v1/qa-capture.yaml",
"branch": "develop",
"commit": "1aee6cb"
},
{
"name": "quick-search",
"version": "1.1.0",
"document": "qbit-quick-search/src/main/resources/openapi/qqq-v1/quick-search.yaml",
"branch": "develop",
"commit": "bd50e1f"
},
{
"name": "release-notes",
"version": "1.0.0",
"document": "qbit-release-notes/src/main/resources/openapi/qqq-v1/release-notes.yaml",
"branch": "develop",
"commit": "8fed18e"
},
{
"name": "secured-data",
"version": "1.1.0",
"document": "qbit-secured-data/src/main/resources/openapi/qqq-v1/secured-data.yaml",
"branch": "develop",
"commit": "7b302ed"
},
{
"name": "sql-workbench",
"version": "1.1.0",
"document": "qbit-sql-workbench/src/main/resources/openapi/qqq-v1/sql-workbench.yaml",
"branch": "develop",
"commit": "a182076"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -16,6 +16,19 @@ import { dirname } from 'node:path'
const [specPath = 'packages/ui-api-client/openapi/qqq-v1.json', outPath = 'packages/core/src/api/operations.ts'] = process.argv.slice(2)
const spec = JSON.parse(readFileSync(specPath, 'utf8'))
const extensions = spec.info['x-qqq-extensions'] ?? []
const extensionByOperation = new Map()
for (const e of extensions) for (const id of e.operations ?? []) extensionByOperation.set(id, e.name)
const ESTABLISHED_PREFIXES = { esb: 'esb', alerts: 'alerts', assistant: 'assistant', 'sql-workbench': 'sql-workbench', health: 'health' }
/** The extension an operation belongs to: x-qqq-extensions, else its /qbit/{name} or established path prefix. */
function extensionOf(id, path) {
if (extensionByOperation.has(id)) return extensionByOperation.get(id)
const rest = path.replace(/^\/qqq\/v1/, '').split('/')
if (rest[1] === 'qbit' && rest[2]) return rest[2]
return ESTABLISHED_PREFIXES[rest[1]]
}
const operations = []
for (const [path, item] of Object.entries(spec.paths)) {
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
@@ -31,6 +44,7 @@ for (const [path, item] of Object.entries(spec.paths)) {
pathParams: params.filter((p) => p.in === 'path').map((p) => p.name),
queryParams: params.filter((p) => p.in === 'query').map((p) => p.name),
bodyTypes: Object.keys(op.requestBody?.content ?? {}),
...(extensionOf(op.operationId, path) ? { extension: extensionOf(op.operationId, path) } : {}),
})
}
}
@@ -41,7 +55,7 @@ const lines = [
' * Licensed under the Apache License, Version 2.0 (see LICENSE).',
' */',
'',
`// Generated by scripts/gen-operations.mjs from the UI API ${spec.info.version} document. Do not edit.`,
`// Generated by scripts/gen-operations.mjs from the UI API ${spec.info.version} document (core and ${extensions.length} extensions). Do not edit.`,
'',
'export interface OperationInfo {',
' id: string',
@@ -52,10 +66,25 @@ const lines = [
' pathParams: string[]',
' queryParams: string[]',
' bodyTypes: string[]',
' /** The extension (qbit or module) that serves this operation; absent for core operations. */',
' extension?: string',
'}',
'',
'/** An extension document merged into the client document (info.x-qqq-extensions). */',
'export interface DocumentExtension {',
' name: string',
' version: string',
' pathPrefix?: string',
' description?: string',
'}',
'',
`export const UI_API_DOCUMENT_VERSION = ${JSON.stringify(spec.info.version)}`,
'',
'/** The extensions merged into the client document, with the versions it was generated from. */',
'export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [',
...extensions.map((e) => ` ${JSON.stringify({ name: e.name, version: e.version, pathPrefix: e.pathPrefix, description: (e.description ?? '').split('\n')[0].slice(0, 160) })},`),
']',
'',
'export const OPERATIONS: OperationInfo[] = [',
...operations.map((o) => ` ${JSON.stringify(o)},`),
']',
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Builds the client document from a core document plus every extension document found in the
* sibling checkouts of the QRun-IO workspace, and records where each came from.
*
* Usage: node scripts/merge-extensions.mjs [--core <url|file>] [--root <QRun-IO dir>] [--out <file>]
*
* --core the core document; default the sample server's http://localhost:8077/qqq/v1/openapi.json
* (a server's document is already merged with the extensions that server has; those are
* checked for equality and recorded, not duplicated)
* --root the directory holding the qbit checkouts (default: QRUN_IO_ROOT, else the nearest
* ancestor of this repository that contains qbit-* directories)
* --out default packages/ui-api-client/openapi/qqq-v1.json
*
* Writes packages/ui-api-client/openapi/extension-sources.json: each extension's name, version,
* document path and the git branch and commit it was read at.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from 'yaml'
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2)
const option = (name, fallback) => {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : fallback
}
const core = option('--core', 'http://localhost:8077/qqq/v1/openapi.json')
const out = option('--out', join(repo, 'packages/ui-api-client/openapi/qqq-v1.json'))
function findRoot() {
if (process.env.QRUN_IO_ROOT) return process.env.QRUN_IO_ROOT
for (let dir = dirname(repo); dir !== dirname(dir); dir = dirname(dir)) {
if (readdirSync(dir).some((d) => d.startsWith('qbit-'))) return dir
}
throw new Error('No qbit-* checkouts found above this repository; pass --root or set QRUN_IO_ROOT.')
}
const root = option('--root', findRoot())
/** Where each repository keeps its extension documents (a qbit's own repo, or qqq for esb and health). */
function candidates() {
const found = []
const qqq = [join(root, '.worktrees/qqq-develop'), join(root, 'qqq')].find((d) => existsSync(join(d, 'qqq-esb')))
if (qqq) {
for (const module of ['qqq-esb', 'qqq-middleware-javalin']) {
const dir = join(qqq, module, 'src/main/resources/openapi/qqq-v1')
if (existsSync(dir)) for (const f of readdirSync(dir)) if (f.endsWith('.yaml')) found.push(join(dir, f))
}
}
for (const entry of readdirSync(root).filter((d) => d.startsWith('qbit-')).sort()) {
for (const base of [join(root, entry), join(root, entry, entry)]) {
const dir = join(base, 'src/main/resources/openapi/qqq-v1')
if (existsSync(dir)) for (const f of readdirSync(dir)) if (f.endsWith('.yaml')) found.push(join(dir, f))
}
}
return found
}
function git(dir, ...gitArgs) {
try {
return execFileSync('git', ['-C', dir, ...gitArgs], { encoding: 'utf8' }).trim()
} catch {
return ''
}
}
const documents = candidates()
if (!documents.length) throw new Error(`No extension documents under ${root}`)
execFileSync('node', [join(repo, 'scripts/merge-spec.mjs'), '--out', out, core, ...documents], { stdio: 'inherit' })
const sources = documents.map((file) => {
const info = parse(readFileSync(file, 'utf8')).info ?? {}
const dir = dirname(file)
return {
name: String(info.title ?? '').replace(/^QQQ Middleware API extension: /, ''),
version: info.version,
document: relative(root, file),
branch: git(dir, 'rev-parse', '--abbrev-ref', 'HEAD'),
commit: git(dir, 'rev-parse', '--short', 'HEAD'),
}
})
writeFileSync(join(dirname(out), 'extension-sources.json'), JSON.stringify({ core, extensions: sources }, null, 2) + '\n')
for (const s of sources) console.log(`${s.name.padEnd(16)} ${String(s.version).padEnd(7)} ${s.branch}@${s.commit} ${s.document}`)
+25 -3
View File
@@ -16,7 +16,11 @@
* Rules: paths and operations are unioned (a duplicate method on one path is an error); component
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1); the
* core document's info, servers and securitySchemes are kept; tags are unioned; every extension's
* name and version is recorded in info.x-qqq-extensions.
* name, version, path prefix and operationIds are recorded in info.x-qqq-extensions.
*
* An extension whose operations the core input already carries (a server's merged document, e.g.
* the sample server's esb) is accepted when each such operation is identical; it is then recorded
* in info.x-qqq-extensions like any other.
*/
import { readFile, writeFile } from 'node:fs/promises'
@@ -50,6 +54,14 @@ function deriveOperationId(method, path) {
return id
}
/** The path prefix an extension's operations share, relative to /qqq/v1 (`/qbit/{name}` or an established one such as `/esb`). */
function pathPrefixOf(name, paths) {
const qbit = `/qqq/v1/qbit/${name}`
if (paths.length && paths.every((p) => p === qbit || p.startsWith(qbit + '/'))) return `/qbit/${name}`
const first = new Set(paths.map((p) => p.replace(/^\/qqq\/v1/, '').split('/')[1]))
return first.size === 1 ? `/${[...first][0]}` : undefined
}
async function load(source) {
const text = /^https?:\/\//.test(source) ? await (await fetch(source)).text() : await readFile(source, 'utf8')
return source.endsWith('.json') ? JSON.parse(text) : parse(text)
@@ -72,12 +84,22 @@ const extensions = []
for (const source of extensionSources) {
const doc = await load(source)
const name = (doc.info?.title ?? '').replace(/^QQQ Middleware API extension: /, '') || source
extensions.push({ name, version: doc.info?.version, description: doc.info?.description })
const operationIdsOfExtension = []
const paths = Object.keys(doc.paths ?? {})
extensions.push({ name, version: doc.info?.version, description: doc.info?.description, pathPrefix: pathPrefixOf(name, paths), operations: operationIdsOfExtension })
for (const [path, operations] of Object.entries(doc.paths ?? {})) {
const target = (merged.paths[path] ??= {})
for (const [method, operation] of Object.entries(operations)) {
if (target[method]) throw new Error(`duplicate operation ${method.toUpperCase()} ${path} (from ${name})`)
if (!['get', 'put', 'post', 'patch', 'delete', 'head', 'options'].includes(method)) {
target[method] ??= operation // path-level parameters, summary
continue
}
if (operation.operationId) operationIdsOfExtension.push(operation.operationId)
if (target[method]) {
if (JSON.stringify(stable(target[method])) === JSON.stringify(stable(operation))) continue
throw new Error(`duplicate operation ${method.toUpperCase()} ${path} (from ${name})`)
}
target[method] = operation
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ STATUS = {
'REC-024': (P, 'maxLength checked locally'), 'REC-025': (P, 'behaviors'), 'REC-026': (P, 'ranges checked locally'), 'REC-027': (P, 'server message'), 'REC-028': (P, 'record links name table and key, --follow opens them; URL links show the URL'), 'REC-029': (N, 'chip as [value]; colors and icons not shown (unit-tested; none in the sample)'), 'REC-030': (X, ''), 'REC-031': (N, 'raw code on view; no code editor (unit-tested)'), 'REC-032': (N, 'HTML shown as text (unit-tested)'),
'REC-033': (N, 'masked until --reveal; no copy (unit-tested; none in the sample)'), 'REC-034': (N, 'name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16'), 'REC-035': (N, 'create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample'), 'REC-036': (N, 'tooltip text in parentheses after the value (unit-tested)'), 'REC-037': (N, 'value rendered through the widget render model as text (unit-tested)'), 'REC-038': (N, 'value marked with ! (unit-tested)'), 'REC-039': (N, 'table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone'), 'REC-040': (N, 'section help with --help-content (unit-tested)'), 'REC-041': (N, 'table helpContent and helpContents slots with --help-content (unit-tested)'), 'REC-042': (N, 'qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server'), 'REC-043': (N, 'without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests)'),
'REC-044': (P, 'recordLabel'), 'REC-045': (P, 'labels resolved, defaults looked up'), 'REC-046': (P, 'server'), 'REC-047': (X, ''), 'REC-048': (P, 'fieldErrors by field'), 'REC-049': (X, ''), 'REC-050': (P, ''), 'REC-051': (P, ''), 'REC-052': (P, 'contingent fields'),
'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (N, 'get, copy and update -i read with --variant; v1 writes ignore it (server finding #18)'), 'REC-064': (N, 'table get --join adds joined fields (queryJoins); verified with an association join'), 'REC-065': (P, 'an unchanged wall-clock time keeps the original instant and its precision'),
'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (N, 'get, copy and update -i read with --variant; v1 writes ignore it (server finding #19)'), 'REC-064': (N, 'table get --join adds joined fields (queryJoins); verified with an association join'), 'REC-065': (P, 'an unchanged wall-clock time keeps the original instant and its precision'),
# SEC
'SEC-001': (P, ''), 'SEC-002': (T, 'disabled tables listed with a reason'), 'SEC-003': (P, ''), 'SEC-004': (P, ''), 'SEC-005': (P, ''), 'SEC-006': (T, 'copy'), 'SEC-007': (P, ''), 'SEC-008': (T, ''), 'SEC-009': (P, 'widget get shows the refusal'), 'SEC-010': (P, ''),
'SEC-011': (T, 'saved views and reports'), 'SEC-012': (P, 'server'), 'SEC-013': (P, 'server'), 'SEC-014': (P, ''), 'SEC-015': (P, ''), 'SEC-020': (P, 'qqq login on a MOCK server'), 'SEC-021': (P, 'qqq logout calls POST /logout'), 'SEC-022': (P, 'exit 3 with the sign-in hint'),