Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 30f8503100 release: 1.1.0
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / secrets (push) Successful in 22s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / verify (push) Failing after 10m22s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / gate (push) Failing after 20s
QRun-IO/ci-policy: ci / original-cli (push) Failing after 10m45s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 9s
qqq and @qrun-io/qqq-core go to 1.1.0 beside client 1.10.0. Conformance
stays 1.6.0: its checks validate responses against the server's own
served document and none of its contracts changed. The supported server
range stays UI API >=1.6.0 <2.0.0, since nothing in 1.1.0 needs a newer
server. The Homebrew and Scoop templates keep 1.0.0 and its sums:
update-packaging.mjs fills both from the GitHub release's SHA256SUMS
after publishing, and changing only the version would pair 1.1.0 with
1.0.0 checksums.

CHANGELOG [Unreleased] becomes [1.1.0] - 2026-11-05 with an intro and the
package versions; the two Added/Changed/Fixed groups are merged into one
of each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 06:17:44 -06:00
james.maesandClaude Fable 5.1 2f47ea4ce9 feat(client): regenerate from UI API 1.10.0 and current extension documents
The sample server on 8077 still serves UI API 1.8.0, so the core document
is now qqq's committed openapi.yaml at develop 7825261ab (UI API 1.10.0)
instead of a live server, merged with each qbit's committed extension
document at origin/develop (qbit-printing: origin/main, it has no
develop). Collaboration now comes from develop, where per-session
presence is merged; assistant moves to 1.7.0 and printing to 2.3.0.
222 operations, 156 from extensions.

data-analysis 1.5.0 still carries FilterCriteria as it was before 1.9.0
(no `options`), which made the strict schema-equality rule fail the
merge. merge-spec now accepts an extension's older copy of a schema
(same schema minus later properties and enum values) and keeps the newer
one with a warning; any other difference still fails.

The client follows the UI API version, so it becomes 1.10.0
(UI_API_VERSION too). Servers on 1.9.0 now read tableVariant on writes,
so the CLI's --variant note names the versions that ignore it. The
"newer server" version tests move to 1.11.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 06:17:37 -06:00
forge-recovery b42e4eb9b8 ci: restore original CLI capture and valid CI configuration
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 3s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / secrets (push) Successful in 14s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / verify (push) Successful in 2m28s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / gate (push) Successful in 9s
QRun-IO/ci-policy: ci / original-cli (push) Successful in 2m40s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 3s
2026-11-04 09:23:44 +00:00
forge-recoveryandCodex 074265e4d8 ci: retain unused native target notes outside CLI manifest
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / verify (pull_request) Successful in 2m33s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / original-cli (pull_request) Successful in 2m44s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 09:00:56 +00:00
forge-recoveryandCodex 2639b2cccd ci: adopt original CLI existing-tag package capture
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / verify (pull_request) Failing after 18s
QRun-IO/ci-policy: Original QQQ CLI verification and package capture / gate (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / original-cli (pull_request) Failing after 30s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 08:04:24 +00:00
james.maesandClaude Sonnet 5.5 943721edef chore(deps): stop at TypeScript 6.0.3; TypeScript 7 breaks openapi-typescript
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / node-4 (push) Successful in 26m56s
QRun-IO/ci-policy: Shared Node CI / gate (push) Successful in 17s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: Shared Node CI / verify (push) Successful in 4m8s
QRun-IO/ci-policy: Shared Node CI / secrets (push) Successful in 35s
CI (npm run generate) failed with TS 7: openapi-typescript 7.13.0 uses
ts.factory from the TypeScript JS API, which the native 7 compiler lacks.
6.0.3 builds, generates an identical schema, and passes all suites.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 17:26:16 -06:00
james.maesandClaude Sonnet 5.5 fd6f7ff6b9 chore(deps): TypeScript 7.0.2 and @types/node 26.6.6
Supersedes the Dependabot PRs for both. TypeScript 7 stops loading all
@types packages implicitly, so the base tsconfig names node; @types/node 26
rejects an undefined assertion message, fixed in one conformance test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 17:26:15 -06:00
james.maesandClaude Fable 5.1 3a4f8a6727 feat(presence): show connection counts from per-session presence
Two terminals of one user collapsed into one viewer. With collaboration
1.2.0 (qbit-collaboration #54) the snapshot says how many connections
each viewer has, and perConnection=true lists each connection.

- core: PresencePerson.connections, PresenceState.connections, an
  opt-in perConnection that fills person.leases (mode, field, start,
  this client's own lease marked self via the server's opaque id)
- TUI: the record strip, panel ("Admin x2 (you)", one line per
  terminal) and Viewing now entry show counts; the record presence
  joins with perConnection; the screen saver's Online tile counts
  connections ("2 online - you x2")
- qqq collab presence: connections column, --per-connection rows
- regenerated the client from the collaboration 1.2.0 document,
  read from the local feat/per-session-presence worktree until it
  lands on develop (extension-sources.json says so)

Servers before 1.2.0 send no connections: every person counts once and
nothing changes on screen; tests cover both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 16:45:24 -06:00
45 changed files with 29169 additions and 27143 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Original CLI existing-tag packages
on:
create:
workflow_dispatch:
permissions:
contents: read
jobs:
release:
if: github.repository == 'QRun-IO/qqq-cli' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'create' || github.event_name == 'workflow_dispatch')
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qqq-cli.yaml@qrun-release-v1
with:
runner: linux-small
capture: true
postpublish:
needs: [release]
if: needs.release.result == 'success'
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-cli-completion.yaml@qrun-release-v1
+2 -1
View File
@@ -4,4 +4,5 @@ default_branch: main
publish:
release: true
publish_context: qqq-maven-registry-credentials
platforms: [macos-arm64, windows-x64]
# Native target notes; separate binary entrypoints do not read this CI manifest.
# platforms: [macos-arm64, windows-x64]
+27 -3
View File
@@ -5,10 +5,18 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
## [Unreleased]
### Fixed
## [1.1.0] - 2026-11-05
`qqq` 1.1: the terminal UI's Shift bank and two-row F-key bar, F12 for the Assistant panel, Save as PDF
from the printer picker, a boot splash and a profile picker on start, rendered assistant answers, and the
client regenerated from UI API 1.10.0 with the current extension documents. Servers: UI API 1.6.0 up to
2.0, unchanged; servers on 1.9.0 and later also read the case-insensitive criteria option `qqq` sends, and the
table variant the CLI sends on inserts, updates and deletes (`qqq ui` does not send it on writes yet).
Versions: `@qrun-io/qqq-ui-api-client` 1.10.0, `@qrun-io/qqq-core` 1.1.0, `@qrun-io/qqq-cli` 1.1.0,
`@qrun-io/qqq-ui-api-conformance` 1.6.0 (unchanged: its checks validate against the server's own
document, so no contract changed).
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
### Added
- **F12 shows and hides the Assistant panel** on every screen (Ctrl+G, `:ask` and a click on `12Ask` do
@@ -63,11 +71,27 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
### Changed
- **`@qrun-io/qqq-ui-api-client` 1.10.0**, generated from qqq `develop` 7825261ab (UI API 1.10.0) and the
extension documents on each qbit's `develop` (printing: `main`); 222 operations, 156 from extensions
(`docs/extensions.md`, `openapi/extension-sources.json`). New since 1.8.0: `FilterCriteria.options`
(`CASE_INSENSITIVE`), `tableVariant` on insert, update and delete, `joins` on export, `isStepBack` on
step, url-encoded process bodies, a typed `statisticsGroupData`, native session login and logout
(`nativeSessionLogin`, `nativeSessionLogout`, `authenticationFlow`, `expectedNonce`,
`nativeLogoutData`); assistant 1.7.0 (`AssistantConfigResponseV1.message`); printing 2.3.0 (the
operator printer catalog, submit and request operations, and media profiles); collaboration 1.2.0 now
from `develop`, where per-session presence is merged.
- `scripts/merge-spec.mjs` accepts an extension's older copy of a core schema (the same schema without
properties or enum values added since) and keeps the newer one, with a warning: data-analysis 1.5.0
still carries the pre-1.9.0 `FilterCriteria`.
- The CLI's `--variant` note on writes now says only servers before UI API 1.9.0 ignore the variant.
- TypeScript 5.8.3 to 6.0.3 and `@types/node` 22.15.3 to 26.6.6 across the workspace, `packages/tui` and `packages/tui-spike`. TypeScript 6 no longer loads every `@types/*` package implicitly, so `tsconfig.base.json` now lists `"types": ["node"]`; `@types/node` 26 no longer accepts an `undefined` assertion message, so one conformance test passes a real fallback message. TypeScript 7 is not adopted yet: `openapi-typescript` 7.13.0 (the client generator, peer `typescript ^5.x`) calls the TypeScript JS API (`ts.factory`), which the native 7 compiler does not provide. No behaviour change.
- With several profiles and none named, `qqq ui` asks which to connect to instead of using the default
profile (`--profile` or `QQQ_PROFILE` keeps the old behaviour).
### Fixed
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
- **The assistant's answers render** instead of showing raw markdown, in the TUI panel and in
`qqq assistant ask`: HTML entities decoded (`healthy&#58;` was shown as is), headings, wrapped
paragraphs, lists, bold and code, and pipe tables laid out as a grid (CLI: sub-tables) instead of `| --- |`
+6 -7
View File
@@ -6,8 +6,7 @@ uses, so apps, tables, fields, processes, widgets and extensions all come from t
one binary works against any QQQ application.
This repository also holds the TypeScript libraries `qqq` is built on, for anyone writing their own CLI,
TUI or native client. Current release: **1.0.0** ([release notes](docs/release-notes/1.0.0.md),
[CHANGELOG](CHANGELOG.md)).
TUI or native client. Current release: **1.1.0** ([CHANGELOG](CHANGELOG.md); 1.0.0: [release notes](docs/release-notes/1.0.0.md)).
- [Install](#install)
- [The CLI in 60 seconds](#the-cli-in-60-seconds)
@@ -29,7 +28,7 @@ musl for Alpine) and Windows (x64).
```sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0 --dir /usr/local/bin
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.1.0 --dir /usr/local/bin
```
**Homebrew** (macOS and Linux, from [`QRun-IO/homebrew-tap`](https://github.com/QRun-IO/homebrew-tap);
@@ -248,7 +247,7 @@ and settings: [`packages/tui/README.md`](packages/tui/README.md).
| npm packages | Node.js 20 or later. `@qrun-io/qqq-ui-api-client` needs only `fetch` and Web Crypto (no Node built-ins), so it runs on Bun and Deno too; `@qrun-io/qqq-core` and the CLI use Node built-ins (files, keyring). |
Versioning: `qqq` and `@qrun-io/qqq-core` follow semver from 1.0.0. `@qrun-io/qqq-ui-api-client`
follows the UI API version of the document it is generated from (1.8.0 here), and
follows the UI API version of the document it is generated from (1.10.0 here), and
`@qrun-io/qqq-ui-api-conformance` has its own version. All four are released together; see
[CHANGELOG.md](CHANGELOG.md).
@@ -256,9 +255,9 @@ follows the UI API version of the document it is generated from (1.8.0 here), an
| Package | Version | What it is |
|---|---|---|
| [`@qrun-io/qqq-cli`](packages/cli) | 1.0.0 | `qqq`, the CLI, and its program as a library (`runCli`) |
| [`@qrun-io/qqq-core`](packages/core) | 1.0.0 | the client kernel: sessions and keyring, metadata cache, filter grammar, forms, processes, records, render model, output formats. No terminal code |
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | 1.8.0 | a typed TypeScript client generated from the merged UI API document (core plus 14 extensions), with OAuth2 device-flow and PKCE sign-in, token refresh and the realtime (SSE) stream |
| [`@qrun-io/qqq-cli`](packages/cli) | 1.1.0 | `qqq`, the CLI, and its program as a library (`runCli`) |
| [`@qrun-io/qqq-core`](packages/core) | 1.1.0 | the client kernel: sessions and keyring, metadata cache, filter grammar, forms, processes, records, render model, output formats. No terminal code |
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | 1.10.0 | a typed TypeScript client generated from the merged UI API document (core plus 14 extensions), with OAuth2 device-flow and PKCE sign-in, token refresh and the realtime (SSE) stream |
| [`@qrun-io/qqq-ui-api-conformance`](packages/conformance) | 1.6.0 | a read-only conformance suite for any UI API server, and `qqq-dev-idp`, a local OIDC provider for testing native clients |
| [`packages/tui`](packages/tui) | in the binaries | `qqq ui`, the full-screen terminal UI (OpenTUI on Bun), bundled into every `qqq` binary; not an npm package |
+2 -2
View File
@@ -4,8 +4,8 @@
| Version | Supported |
|---|---|
| 1.0.x (`qqq` 1.0, `@qrun-io/qqq-core` 1.0, `@qrun-io/qqq-ui-api-client` 1.8, `@qrun-io/qqq-ui-api-conformance` 1.6) | yes |
| 1.0.0 release candidates, 0.x | no; upgrade to the latest 1.0.x |
| 1.1.x (`qqq` 1.1, `@qrun-io/qqq-core` 1.1, `@qrun-io/qqq-ui-api-client` 1.10, `@qrun-io/qqq-ui-api-conformance` 1.6) | yes |
| 1.0.x, 1.0.0 release candidates, 0.x | no; upgrade to the latest 1.1.x |
Fixes are released as a new patch version of the affected packages and binaries.
+2 -2
View File
@@ -1,6 +1,6 @@
# Try qqq against the sample server
A hands-on walkthrough of qqq 1.0.0 (the CLI and `qqq ui`) from a source checkout, against a local QQQ
A hands-on walkthrough of qqq 1.1.0 (the CLI and `qqq ui`) from a source checkout, against a local QQQ
sample server. Each section says what exists and the command that runs it. To install a release instead,
see [Install](README.md#install) in the README; every command below works the same with an installed `qqq`.
@@ -28,7 +28,7 @@ and the terminal UI in one file, no Node or Bun. It is the same build a user dow
from-source launcher. Both use the same profiles and keychain items.
```sh
qqq-bin --version # qqq 1.0.0 (darwin-arm64 binary with ui, <commit>, bun 1.4.4)
qqq-bin --version # qqq 1.1.0 (darwin-arm64 binary with ui, <commit>, bun 1.4.4)
qqq-bin doctor # first line: the build, runtime and platform; then server, sign-in, terminal
qqq-bin --profile sample ui # the terminal UI from the binary
qqq-bin ui --check # the terminal check, with the build line support asks for
+31 -23
View File
@@ -1,36 +1,44 @@
# 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:
The client document (`packages/ui-api-client/openapi/qqq-v1.json`) is qqq's committed core document
(`qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml`, qqq `develop` 7825261ab, UI API
1.10.0) merged with the extension document each repository commits at `src/main/resources/openapi/qqq-v1/`
(esb and health from the same qqq commit). Rebuild it from checkouts at the refs below:
```sh
npm run spec:merge-extensions && npm run generate # writes openapi/extension-sources.json too
npm run spec:merge-extensions -- --core <qqq>/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml
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`):
Without `--core` the script reads the sample server's live document (`http://localhost:8077`), which is
only as new as the server that is running. For 1.10.0 the documents were exported (`git archive`) from
each repository's `origin/develop` (`origin/main` for qbit-printing, which has no `develop`) into one
directory passed as `--root`, and `extension-sources.json` records those refs.
Versions merged on 2026-11-05 (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 | |
| printing | 2.1.0 | `/qbit/printing` | 17 | qbit-printing `feat/print-anything` | 2.0.0 (realtime-v1); 2.1.0 adds the table-list and text prints Ctrl+P uses |
| esb | 1.1.0 | `/esb` | 5 | qqq `develop` 7825261ab (qqq-esb) | 1.1.0 (operationIds, UI-API-CHANGELOG) |
| health | 1.1.0 | `/health` | 4 | qqq `develop` 7825261ab (qqq-middleware-javalin) | 1.1.0 (same) |
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` 420cc69 | |
| assistant | 1.7.0 | `/assistant` | 16 | qbit-assistant `develop` 67bdd32 | |
| collaboration | 1.2.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 7d61f29 (per-session presence is merged there) | 1.1.0 (presence schemas, error contract); 1.2.0 adds per-connection presence (`connections`, `leaseCount`, `perConnection`), used when present |
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` f2fd8b1 | |
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` b70c234 | |
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 683995c | |
| printing | 2.3.0 | `/qbit/printing` | 20 | qbit-printing `main` 0ab07fc | 2.0.0 (realtime-v1); 2.1.0 adds the table-list and text prints Ctrl+P uses |
| qa-capture | 1.0.0 | `/qbit/qa-capture` | 2 | qbit-qa-capture `develop` 8ca3fd7 | |
| quick-search | 1.1.0 | `/qbit/quick-search` | 2 | qbit-quick-search `develop` e3aa05e | |
| release-notes | 1.0.0 | `/qbit/release-notes` | 3 | qbit-release-notes `develop` 8abbe6f | |
| secured-data | 1.1.0 | `/qbit/secured-data` | 3 | qbit-secured-data `develop` 5987cb5 | |
| sql-workbench | 1.1.0 | `/sql-workbench` | 21 | qbit-sql-workbench `develop` 1784a5b | |
219 operations in all, 153 from extensions (printing was appended on 2026-11-01 with
`scripts/merge-spec.mjs`, keeping the other documents as they were). No extension document conflicted with core or another
(identical shared schemas such as `BasicErrorResponseV1`). There is no `qbit-middleware-health`: the
222 operations in all, 156 from extensions. Shared schemas are identical (such as `BasicErrorResponseV1`)
except one: data-analysis 1.5.0 still carries `FilterCriteria` as it was before UI API 1.9.0 (without
`options`). `scripts/merge-spec.mjs` accepts an extension's older copy of a schema (the same schema
without the properties and enum values added since) and keeps the newer core one, with a warning; a copy
that differs in any other way still fails the merge. 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.
+18 -18
View File
@@ -14,8 +14,8 @@
],
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"@types/node": "26.6.6",
"typescript": "6.0.3",
"yaml": "2.9.1"
},
"engines": {
@@ -564,13 +564,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.15.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~8.9.0"
}
},
"node_modules/@types/swagger-schema-official": {
@@ -2708,9 +2708,9 @@
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2722,9 +2722,9 @@
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
@@ -2954,11 +2954,11 @@
},
"packages/cli": {
"name": "@qrun-io/qqq-cli",
"version": "1.0.0",
"version": "1.1.0",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-core": "1.1.0",
"@qrun-io/qqq-ui-api-client": "1.10.0",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
},
@@ -2986,10 +2986,10 @@
},
"packages/core": {
"name": "@qrun-io/qqq-core",
"version": "1.0.0",
"version": "1.1.0",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-client": "1.10.0",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
@@ -3002,7 +3002,7 @@
},
"packages/ui-api-client": {
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.0",
"version": "1.10.0",
"license": "Apache-2.0",
"dependencies": {
"openapi-fetch": "0.17.0"
+2 -2
View File
@@ -30,8 +30,8 @@
},
"devDependencies": {
"@stoplight/prism-cli": "5.16.0",
"@types/node": "22.15.3",
"typescript": "5.8.3",
"@types/node": "26.6.6",
"typescript": "6.0.3",
"yaml": "2.9.1"
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-cli",
"version": "1.0.0",
"version": "1.1.0",
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
"keywords": [
"qqq",
@@ -58,8 +58,8 @@
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-core": "1.1.0",
"@qrun-io/qqq-ui-api-client": "1.10.0",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
}
+23 -9
View File
@@ -10,7 +10,7 @@
*/
import type { Command } from 'commander'
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import { COLLABORATION, connectionsSuffix, type TagEntry, 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 { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
@@ -27,14 +27,15 @@ 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)`. */
/** `2 on person 1: Alice Morgan ×2 (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(', ')}`
const people = state.people.map((p) => `${p.name}${connectionsSuffix(p)} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
const connections = state.connections ?? state.count
return `${state.count} on ${where}${connections > state.count ? ` (${connections} connections)` : ''}: ${people.join(', ')}`
}
function favoriteRows(items: FavoriteEntry[]): Json[] {
@@ -71,7 +72,8 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
.option('--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 }) => {
.option('--per-connection', 'one row per connection (tab, terminal) with its mode, field and start (collaboration 1.2.0)')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean; perConnection?: boolean }) => {
const c = ctx()
const collaboration = await collab(c)
const scope = { table, pk }
@@ -81,18 +83,30 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
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: countFooter(state.count, 'person', [`on ${pk !== undefined ? `${table} ${pk}` : table}`, '--watch to join and follow'], 'people') })
const state = await collaboration.presenceSnapshot(scope, { perConnection: options.perConnection })
const connections = state.connections ?? state.count
const where = `on ${pk !== undefined ? `${table} ${pk}` : table}`
const notes = [where, ...(connections > state.count ? [`${connections} connections`] : []), '--watch to join and follow']
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', connections: p.connections ?? 1 }))
if (options.perConnection) {
// older servers send no per-connection detail: one row per person, connection unknown
const leaseRows = state.people.flatMap((p) => p.leases?.length
? p.leases.map((l) => ({ name: p.name, user: p.userId, mode: l.mode, field: l.fieldName ?? '', since: l.since ? hhmmss(l.since) : '', connection: l.connectionId }))
: [{ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', since: '', connection: '' }])
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows, leases: leaseRows }, rows: leaseRows, columns: ['name', 'user', 'mode', 'field', 'since', 'connection'], footer: countFooter(state.count, 'person', notes, 'people') })
return
}
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field', 'connections'], footer: countFooter(state.count, 'person', notes, 'people') })
return
}
let last = ''
const handle = await collaboration.joinPresence(scope, {
announce: Boolean(options.watch),
perConnection: options.perConnection,
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])
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName, p.connections, p.leases?.map((l) => [l.connectionId, l.mode, l.fieldName])]), state.error, state.live])
if (key !== last) {
last = key
emit(state)
+2 -2
View File
@@ -61,7 +61,7 @@ export async function chooseVariant(c: Context, meta: Json, typed: string | unde
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'}`)
}
if (chosen && usesVariants(meta)) rememberVariant(c.server, table, chosen)
// 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`)
// servers before UI API 1.9.0 read tableVariant on query, count, get, export and processes only (docs/server-findings.md #19)
if (options.write) c.note(`note: servers before UI API 1.9.0 ignore the ${label} on inserts, updates and deletes; see docs/server-findings.md #19`)
return chosen
}
+3 -3
View File
@@ -195,7 +195,7 @@ describe('doctor', () => {
const { versionSupport } = await import('../commands/doctor.js')
assert.equal(versionSupport('1.8.0', [1]).status, 'ok')
assert.equal(versionSupport('1.5.2', [1]).status, 'fail')
assert.equal(versionSupport('1.9.0', [1]).status, 'warn')
assert.equal(versionSupport('1.11.0', [1]).status, 'warn')
assert.equal(versionSupport('2.0.0', [2]).status, 'fail')
})
test('doctor words the supported UI API range exactly as --version does', async () => {
@@ -204,13 +204,13 @@ describe('doctor', () => {
assert.equal(UI_API_RANGE, '>=1.6.0 <2.0.0')
const version = await runCli(['--version'], box.env)
assert.match(version.stdout, new RegExp(`servers UI API ${UI_API_RANGE}`))
for (const [server, status] of [['1.6.0', 'ok'], ['1.8.0', 'ok'], ['1.9.0', 'warn'], ['1.5.2', 'fail']] as const) {
for (const [server, status] of [['1.6.0', 'ok'], ['1.8.0', 'ok'], ['1.10.0', 'ok'], ['1.11.0', 'warn'], ['1.5.2', 'fail']] as const) {
const result = versionSupport(server, [1])
assert.equal(result.status, status, server)
assert.ok(result.detail.includes(UI_API_RANGE), `${server}: ${result.detail}`)
assert.ok(!/supports \d+\.\d+\.\d+ to /.test(result.detail), `${server}: ${result.detail}`)
}
assert.match(versionSupport('1.9.0', [1]).detail, /^1\.9\.0; supported \(/)
assert.match(versionSupport('1.11.0', [1]).detail, /^1\.11\.0; supported \(/)
})
})
+1
View File
@@ -41,4 +41,5 @@ test('presence lines say who is there and since when', () => {
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/)
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 1, connections: 2, people: [{ userId: 'admin', name: 'Admin', mode: 'VIEWING', since, connections: 2 }], live: true }), '1 on person 1 (2 connections): Admin ×2 (viewing since 17:50:01)')
})
+4 -4
View File
@@ -3,17 +3,17 @@ extension noun installed status client minimum o
esb qqq esb 1.1.0 ok 1.1.0 1.1.0 5
health qqq health not installed 1.1.0 1.1.0 4
alerts qqq alerts not installed 1.2.0 6
assistant qqq assistant not installed 1.6.0 16
collaboration qqq collab not installed 1.1.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
assistant qqq assistant not installed 1.7.0 16
collaboration qqq collab not installed 1.2.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
data-analysis qqq data-analysis not installed 1.5.0 21
db-performance qqq db-performance not installed 1.0.0 4
edi qqq edi not installed 1.1.0 4
printing qqq printing not installed 2.3.0 2.0.0 20
qa-capture qqq qa-capture not installed 1.0.0 2
quick-search qqq quick-search not installed 1.1.0 2
release-notes qqq release-notes not installed 1.0.0 3
secured-data qqq secured-data not installed 1.1.0 3
sql-workbench qqq sql-workbench not installed 1.1.0 21
printing qqq printing not installed 2.1.0 2.0.0 17
(installed: esb 1.1.0; 153 extension operations in this client)
(installed: esb 1.1.0; 156 extension operations in this client)
--- stderr ---
--- exit 0
@@ -0,0 +1,68 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* `qqq collab presence` against a stub: the connections column for two terminals of one user
* (collaboration 1.2.0), `--per-connection` rows, and a 1.1.0 server that sends no connections.
*/
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { runCli, sandbox } from './harness.js'
import { startStub, type Stub } from './stub.js'
describe('qqq collab presence against a stub', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
let version = '1.2.0'
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
const admin = { userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }
before(async () => {
box = sandbox()
stub = await startStub([
[/^GET \/version$/, () => ({ apiVersion: '1.8.0', supportedMajorVersions: [1], features: [], extensions: [{ name: 'collaboration', version }] })],
[/^GET \/qbit\/collaboration\/presence$/, (r) => {
if (version === '1.1.0') return { table: 'person', viewers: [admin, { userId: 'bob', displayName: 'Bob', mode: 'EDITING' }] }
const leases = [
{ ...admin, connections: 1, connectionId: 'c0000000000000001', sessionId: 's1', since: '2026-11-01T12:00:00Z' },
{ ...admin, mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'c0000000000000002', sessionId: 's2', since: '2026-11-01T12:00:05Z' },
]
return { table: 'person', viewers: [{ ...admin, mode: 'EDITING', connections: 2 }], leaseCount: 2, ...(r.query.get('perConnection') === 'true' ? { leases } : {}) }
}],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('two terminals of one user: one person with 2 connections', async () => {
version = '1.2.0'
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^name,user,mode,field,connections\r?\nAdmin,admin,EDITING,,2/m)
const json = JSON.parse((await qqq(['collab', 'presence', 'person', '-O', 'json'])).stdout)
assert.deepEqual([json.count, json.connections, json.viewers[0].connections], [1, 2, 2])
assert.equal(stub.requests.filter((q) => q.path === '/qbit/collaboration/presence').at(-1)!.query.get('perConnection'), null)
})
test('--per-connection lists each terminal with its mode, field and start', async () => {
version = '1.2.0'
const r = await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])
assert.equal(r.code, 0, r.stderr)
const json = JSON.parse(r.stdout)
assert.deepEqual(json.leases.map((l: { mode: string; field: string; connection: string }) => [l.mode, l.field, l.connection]), [['VIEWING', '', 'c0000000000000001'], ['EDITING', 'name', 'c0000000000000002']])
assert.equal(stub.requests.at(-1)!.query.get('perConnection'), 'true')
})
test('a collaboration 1.1.0 server without connections: each person counts once', async () => {
version = '1.1.0'
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /Admin,admin,VIEWING,,1\r?\nBob,bob,EDITING,,1/)
const per = JSON.parse((await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])).stdout)
assert.deepEqual([per.count, per.connections, per.leases.length], [2, 2, 2])
})
})
+1 -1
View File
@@ -18,7 +18,7 @@ declare const QQQ_BUILD_SHA: string | undefined
declare const QQQ_BUILD_TARGET: string | undefined
declare const QQQ_BUILD_UI: boolean | undefined
export const CLI_VERSION = '1.0.0'
export const CLI_VERSION = '1.1.0'
/** The oldest UI API a server may serve for this CLI (operationIds, clientCapabilities and traceId since 1.6.0). */
export const UI_API_MIN_VERSION = '1.6.0'
+1 -1
View File
@@ -30,7 +30,7 @@ test('on a MOCK server without a token, every authenticated check (jobs too) use
const seen: Array<{ path: string; authorization?: string; cookie?: string }> = []
const results = await runConformance({ baseUrl: 'http://fake', fetch: fakeServer(seen) })
const jobs = results.find((r) => r.name === 'GET /qqq/v1/jobs validates')!
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail)
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail ?? 'jobs check did not pass')
assert.equal(seen.find((s) => s.path === '/qqq/v1/metaData')?.cookie, 'sessionUUID=s-1')
assert.equal(seen.find((s) => s.path === '/qqq/v1/jobs')?.cookie, 'sessionUUID=s-1')
assert.match(results.find((r) => r.name === 'GET /qqq/v1/metaData validates')!.detail!, /authenticated with a server session/)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-core",
"version": "1.0.0",
"version": "1.1.0",
"description": "Client kernel for QQQ terminal clients: sessions, metadata, filter grammar, records, processes, jobs, render model and output formats. No terminal code.",
"keywords": [
"qqq",
@@ -50,7 +50,7 @@
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-client": "1.10.0",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
+16 -13
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 (core and 14 extensions). Do not edit.
// Generated by scripts/gen-operations.mjs from the UI API 1.10.0 document (core and 14 extensions). Do not edit.
export interface OperationInfo {
id: string
@@ -26,24 +26,24 @@ export interface DocumentExtension {
description?: string
}
export const UI_API_DOCUMENT_VERSION = "1.8.0"
export const UI_API_DOCUMENT_VERSION = "1.10.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":"assistant","version":"1.7.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
{"name":"collaboration","version":"1.2.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":"printing","version":"2.3.0","pathPrefix":"/qbit/printing","description":"Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with "},
{"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"},
{"name":"printing","version":"2.1.0","pathPrefix":"/qbit/printing","description":"Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes."},
]
export const OPERATIONS: OperationInfo[] = [
@@ -59,7 +59,7 @@ export const OPERATIONS: OperationInfo[] = [
{"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":"dbPerformanceExplain","method":"POST","path":"/qqq/v1/qbit/db-performance/explain","summary":"Explain a query (PostgreSQL JSON or H2 text plan)","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"},
@@ -69,7 +69,7 @@ export const OPERATIONS: OperationInfo[] = [
{"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":"deleteRecord","method":"DELETE","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Delete a record from a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["tableVariant"],"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"]},
@@ -111,7 +111,7 @@ export const OPERATIONS: OperationInfo[] = [
{"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":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk","perConnection"],"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"},
@@ -140,8 +140,8 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"getTableMetaData","method":"GET","path":"/qqq/v1/metaData/table/{tableName}","summary":"Get table metaData","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
{"id":"getVersion","method":"GET","path":"/qqq/v1/version","summary":"Get the UI API version","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getWidgetData","method":"POST","path":"/qqq/v1/widget/{widgetName}","summary":"Render a widget","tag":"Widgets","pathParams":["widgetName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"initProcess","method":"POST","path":"/qqq/v1/processes/{processName}/init","summary":"Initialize a process","tag":"Processes","pathParams":["processName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
{"id":"insertRecord","method":"POST","path":"/qqq/v1/table/{tableName}","summary":"Insert a record into a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json","multipart/form-data"]},
{"id":"initProcess","method":"POST","path":"/qqq/v1/processes/{processName}/init","summary":"Initialize a process","tag":"Processes","pathParams":["processName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data","application/x-www-form-urlencoded"]},
{"id":"insertRecord","method":"POST","path":"/qqq/v1/table/{tableName}","summary":"Insert a record into a table","tag":"Tables","pathParams":["tableName"],"queryParams":["tableVariant"],"bodyTypes":["application/json","multipart/form-data"]},
{"id":"launchProcessJob","method":"POST","path":"/qqq/v1/processes/{processName}/jobs","summary":"Run a process as a durable background job","tag":"Processes","pathParams":["processName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"listDeliveryDestinations","method":"GET","path":"/qqq/v1/delivery/destinations","summary":"List authorized output destinations","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"listJobs","method":"GET","path":"/qqq/v1/jobs","summary":"Jobs: list","tag":"Processes","pathParams":[],"queryParams":["scope","state","limit","processName"],"bodyTypes":[]},
@@ -149,7 +149,7 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"listRecordDeveloperPanels","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/panels","summary":"Get a record's developer panels","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"listTableApis","method":"GET","path":"/qqq/v1/table/{tableName}/apis","summary":"List the APIs that expose a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
{"id":"listTableVariants","method":"GET","path":"/qqq/v1/table/{tableName}/variants","summary":"List a table's backend variants","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
{"id":"logout","method":"POST","path":"/qqq/v1/logout","summary":"Logout and invalidate session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"logout","method":"POST","path":"/qqq/v1/logout","summary":"Logout and invalidate session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"lookupAvatars","method":"POST","path":"/qqq/v1/avatars/lookup","summary":"Look up users' avatars","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"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":[]},
@@ -208,6 +208,9 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"printingEvent","method":"POST","path":"/qqq/v1/qbit/printing/device/jobs/{jobId}/events","summary":"Printing Event","tag":"printing","pathParams":["jobId"],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
{"id":"printingHeartbeat","method":"POST","path":"/qqq/v1/qbit/printing/device/heartbeat","summary":"Printing Heartbeat","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
{"id":"printingInventory","method":"PUT","path":"/qqq/v1/qbit/printing/device/printers","summary":"Printing Inventory","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
{"id":"printingOperatorCatalog","method":"GET","path":"/qqq/v1/qbit/printing/operator/printers","summary":"Printing OperatorCatalog","tag":"printing","pathParams":[],"queryParams":["limit","after"],"bodyTypes":[],"extension":"printing"},
{"id":"printingOperatorRequest","method":"GET","path":"/qqq/v1/qbit/printing/operator/requests/{requestId}","summary":"Printing OperatorRequest","tag":"printing","pathParams":["requestId"],"queryParams":[],"bodyTypes":[],"extension":"printing"},
{"id":"printingOperatorSubmit","method":"POST","path":"/qqq/v1/qbit/printing/operator/jobs","summary":"Printing OperatorSubmit","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
{"id":"printingPoll","method":"POST","path":"/qqq/v1/qbit/printing/device/control/poll","summary":"Printing Poll","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
{"id":"printingQueued","method":"GET","path":"/qqq/v1/qbit/printing/device/jobs/queued","summary":"Printing Queued","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"printing"},
{"id":"printingReceipt","method":"POST","path":"/qqq/v1/qbit/printing/device/control/commands/{commandId}/receipt","summary":"Printing Receipt","tag":"printing","pathParams":["commandId"],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
@@ -257,13 +260,13 @@ export const OPERATIONS: OperationInfo[] = [
{"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":"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":["isStepBack","tableVariant"],"bodyTypes":["multipart/form-data","application/x-www-form-urlencoded"]},
{"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"]},
{"id":"trackJob","method":"POST","path":"/qqq/v1/jobs/track","summary":"Jobs: track","tag":"Processes","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"updatePreferenceSection","method":"PATCH","path":"/qqq/v1/preferences/{sectionName}","summary":"Partially update the signed-in user's preferences","tag":"General","pathParams":["sectionName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"updateRealtimeSubscriptions","method":"POST","path":"/qqq/v1/realtime/subscriptions","summary":"Change realtime subscriptions","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"updateRecord","method":"PATCH","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Update a record in a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["expectedVersion"],"bodyTypes":["application/json","multipart/form-data"]},
{"id":"updateRecord","method":"PATCH","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Update a record in a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["expectedVersion","tableVariant"],"bodyTypes":["application/json","multipart/form-data"]},
{"id":"upload","method":"POST","path":"/qqq/v1/uploads","summary":"Upload a file for a job or a bulk load","tag":"Processes","pathParams":[],"queryParams":["fileName"],"bodyTypes":["multipart/form-data","application/octet-stream"]},
{"id":"validateBulkLoad","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/validate","summary":"Dry-run a bulk load profile over an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
]
+7
View File
@@ -43,6 +43,7 @@ const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
announce: true, // false: watch without appearing
})
presence.count; presence.people; presence.state.live; presence.state.error
presence.state.connections // two terminals of one user count twice (collaboration 1.2.0)
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
```
@@ -55,6 +56,12 @@ await presence.stop() // DELETE /presence, stop the heartbeat, close the s
- `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).
- Collaboration 1.2.0: each person has `connections` (their live tabs and terminals) and
`state.connections` totals them. `perConnection: true` (option of `joinPresence` and
`presenceSnapshot`) also fills `person.leases`: each connection's mode, field, start and opaque
ids, this client's own marked `self` (the server's id is `c` + 16 hex digits of SHA-256 of
user id, newline, connection id; see `opaqueConnectionId`). Older servers send neither: each
person is then one connection and `leases` stays unset.
- 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
+94 -16
View File
@@ -17,6 +17,7 @@
* there) and the other calls throw ExtensionNotInstalledError.
*/
import { createHash } from 'node:crypto'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import type { ExtensionRegistry } from './registry.js'
@@ -104,6 +105,20 @@ export interface PresenceScope {
pk?: string
}
/** One live connection (a tab or terminal) of a person, from collaboration 1.2.0's per-connection presence. */
export interface PresenceConnection {
/** Opaque per-connection id from the server (never the client's own connection id). */
connectionId: string
mode: 'VIEWING' | 'EDITING' | string
fieldName?: string | null
/** When the lease started, as the server reports it. */
since?: Date
/** Opaque id of the signed-in session behind it (never the session itself). */
sessionId?: string
/** True for this client's own connection. */
self?: boolean
}
/** One person on the scope, merged across their tabs. */
export interface PresencePerson {
userId: string
@@ -112,6 +127,10 @@ export interface PresencePerson {
fieldName?: string | null
/** When this client first saw them there (the qbit does not send join times). */
since: Date
/** Live connections (tabs, terminals) merged into this person; 1 from servers before collaboration 1.2.0 (always set by this module). */
connections?: number
/** Each connection, when the server sent per-connection presence (perConnection, or any 1.2.0 event). */
leases?: PresenceConnection[]
}
export interface PresenceState {
@@ -120,6 +139,8 @@ export interface PresenceState {
scope: PresenceScope
count: number
people: PresencePerson[]
/** Live connections behind `people` (two terminals of one user count twice); equals `count` on older servers (always set by this module). */
connections?: number
/** 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. */
@@ -140,6 +161,8 @@ export interface PresenceOptions {
announce?: boolean
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
reconnectMs?: number
/** Ask for each connection's mode, field and start (collaboration 1.2.0; ignored by older servers). */
perConnection?: boolean
}
export interface PresenceHandle {
@@ -159,12 +182,64 @@ interface Viewer {
displayName?: string
mode?: string
fieldName?: string | null
connections?: number
connectionId?: string
sessionId?: string
since?: string
}
interface Snapshot {
table?: string
pk?: string | null
viewers?: Viewer[]
leaseCount?: number
leases?: Viewer[]
}
/**
* The opaque id the server gives a connection (collaboration 1.2.0): `c` and the first 16 hex
* digits of SHA-256 of the user id, a newline and the client's connection id.
*/
export function opaqueConnectionId(userId: string, connectionId: string): string {
return `c${createHash('sha256').update(`${userId}\n${connectionId}`).digest('hex').slice(0, 16)}`
}
/**
* People and connection totals from a snapshot or event. Servers before collaboration 1.2.0 send
* neither `connections` nor `leases`: each person then counts as one connection.
*/
export function presencePeople(snapshot: Snapshot, firstSeen: (userId: string) => Date, ownConnectionId?: string): { people: PresencePerson[]; connections: number } {
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const leases = new Map<string, PresenceConnection[]>()
for (const l of snapshot.leases ?? []) {
if (l.userId === undefined || !l.connectionId) continue
const userId = String(l.userId)
const since = l.since ? new Date(l.since) : undefined
const entry: PresenceConnection = {
connectionId: l.connectionId, mode: l.mode ?? 'VIEWING', fieldName: l.fieldName ?? null,
...(since && !Number.isNaN(since.getTime()) ? { since } : {}), ...(l.sessionId ? { sessionId: l.sessionId } : {}),
...(ownConnectionId && l.connectionId === opaqueConnectionId(userId, ownConnectionId) ? { self: true } : {}),
}
leases.set(userId, [...(leases.get(userId) ?? []), entry])
}
const people = viewers.map((v) => {
const userId = String(v.userId)
const own = leases.get(userId)
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : own?.length || 1
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen(userId), connections, ...(own ? { leases: own } : {}) }
})
const sum = people.reduce((n, p) => n + p.connections, 0)
return { people, connections: typeof snapshot.leaseCount === 'number' ? Math.max(snapshot.leaseCount, people.length) : sum }
}
/** ` ×2` after a person with more than one connection, else nothing. */
export function connectionsSuffix(p: { connections?: number }): string {
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
}
/** GET /presence query for a scope; `perConnection` only when asked (older servers ignore it). */
function presenceQuery(scope: PresenceScope, perConnection?: boolean): Record<string, string | undefined> {
return { table: scope.table, pk: scope.pk, ...(perConnection ? { perConnection: 'true' } : {}) }
}
/** The channel presence events for a scope arrive on. */
@@ -281,13 +356,15 @@ export class Collaboration {
}
/** 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 }
async presenceSnapshot(scope: PresenceScope, options: { perConnection?: boolean } = {}): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, 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 snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).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
const { people, connections } = presencePeople(snapshot, () => now)
state.people = people
state.count = people.length
state.connections = connections
return state
}
@@ -297,7 +374,7 @@ export class Collaboration {
* 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 state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, 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 () => {} }
@@ -313,22 +390,22 @@ export class Collaboration {
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)))
const ids = new Set((snapshot.viewers ?? []).filter((v) => v.userId !== undefined).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)
const { people, connections } = presencePeople(snapshot, (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
return firstSeen.get(userId)!
}, announce ? connectionId : undefined)
state.people = people
state.count = people.length
state.connections = connections
state.error = undefined
notify()
}
const refresh = async () => {
try {
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
apply((await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot)
} catch (error) {
state.error = messageOf(error)
notify()
@@ -341,8 +418,9 @@ export class Collaboration {
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') {
// The qbit answers the heartbeat with the merged snapshot; older builds answer {success}.
// Per-connection detail only comes with GET, so re-read rather than drop it.
if (data && typeof data.table === 'string' && !options.perConnection) {
apply(data)
return
}
+63 -1
View File
@@ -7,7 +7,7 @@ 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 { Collaboration, connectionsSuffix, opaqueConnectionId, presenceChannel, presencePeople, 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' }] })
@@ -164,3 +164,65 @@ test('tags: vocabulary search, create, record tags and apply call the qbit opera
assert.deepEqual(calls[1]!.input.json, { table: 'person', label: 'New one', color: 'teal' })
assert.deepEqual(calls[3]!.input, { path: { table: 'person', pk: '3' }, json: { add: [9] } })
})
test('two connections of one user: one person with connections 2, and per connection each mode, field and start', async () => {
const leases = [
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-mine'), sessionId: 's1', since: '2026-11-01T12:00:00Z' },
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-other'), sessionId: 's2', since: '2026-11-01T12:00:05Z' },
]
const { calls, transport } = fake((id, input) => {
if (id === 'deleteQbitCollaborationPresence') return { success: true }
const per = (input.query as Record<string, string | undefined> | undefined)?.perConnection === 'true'
return { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 2 }], leaseCount: 2, ...(per ? { leases } : {}) }
})
const collab = new Collaboration(withCollab, transport)
const merged = await collab.presenceSnapshot({ table: 'person', pk: '1' })
assert.deepEqual([merged.count, merged.connections, merged.people[0]!.connections, merged.people[0]!.leases], [1, 2, 2, undefined])
assert.equal(connectionsSuffix(merged.people[0]!), ' ×2')
assert.equal((calls.at(-1)!.input.query as Record<string, unknown>).perConnection, undefined, 'merged by default')
const handle = await collab.joinPresence({ table: 'person', pk: '1' }, { perConnection: true, connectionId: 'cli-mine', heartbeatMs: 10000 })
try {
const admin = handle.people[0]!
assert.equal(admin.connections, 2)
assert.deepEqual(admin.leases!.map((l) => [l.mode, l.fieldName, l.since?.toISOString(), l.self ?? false]), [
['VIEWING', null, '2026-11-01T12:00:00.000Z', true],
['EDITING', 'name', '2026-11-01T12:00:05.000Z', false],
])
assert.deepEqual(calls.filter((c) => c.id === 'getQbitCollaborationPresence').at(-1)!.input.query, { table: 'person', pk: '1', perConnection: 'true' })
} finally {
await handle.stop()
}
})
test('servers before collaboration 1.2.0 send no connections: each person is one connection', async () => {
const { transport } = fake(() => ({ table: 'person', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'bob', displayName: 'Bob' }] }))
const state = await new Collaboration(withCollab, transport).presenceSnapshot({ table: 'person' }, { perConnection: true })
assert.deepEqual([state.count, state.connections], [2, 2])
assert.deepEqual(state.people.map((p) => [p.connections, p.leases, connectionsSuffix(p)]), [[1, undefined, ''], [1, undefined, '']])
const fromEvent = presencePeople({ table: 'person', viewers: [{ userId: 'x', displayName: 'X', connections: 3 }], leaseCount: 3 }, () => new Date(0))
assert.deepEqual([fromEvent.connections, fromEvent.people[0]!.connections], [3, 3])
})
test('presence.changed with leases updates connections live', async () => {
const events: RealtimeMessage[] = [
{ event: 'hello', data: { connectionId: 'sse-1' } },
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }], leaseCount: 2,
leases: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c1', since: '2026-11-01T12:00:00Z' }, { userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c2', since: '2026-11-01T12:00:01Z' }] } },
]
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
let next = 0
while (!signal.aborted) {
await tick(2)
if (next < events.length) yield events[next++]!
}
}
const { transport } = fake((id, input) => (id === 'updateRealtimeSubscriptions' ? { active: (input.json as { add: string[] }).add } : { table: 'person', pk: '1', viewers: [] }), stream)
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
try {
await until('the event with two connections', () => handle.state.connections === 2)
assert.equal(handle.people[0]!.leases?.length, 2)
} finally {
await handle.stop()
}
})
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "5.8.3"
"typescript": "6.0.3"
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ shared with the CLI. `QQQ_UI_NO_SPLASH=1` (as `--no-splash`). `QQQ_UI_NO_QUIT=1`
## Starting
`qqq ui` opens on a **splash**: Query (the qrun.io mascot) blinking, his tail wagging while it works, the
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.0.0 · UI API client 1.8.0`) and one status
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.1.0 · UI API client 1.10.0`) and one status
line that follows the boot: reading profiles, connecting to the server, signing in, loading metadata (with
the table count). The large Query needs about 34 rows; smaller terminals (80×24) get the small Query beside
the wordmark, very small ones the text alone; `--ascii` draws it all in plain characters. Any key stops the
+2 -2
View File
@@ -12,7 +12,7 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "5.8.3",
"typescript": "6.0.3",
},
},
},
@@ -73,7 +73,7 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+1 -1
View File
@@ -18,6 +18,6 @@
"devDependencies": {
"@types/bun": "1.4.4",
"@types/react": "19.3.0",
"typescript": "5.8.3"
"typescript": "6.0.3"
}
}
+2 -2
View File
@@ -210,10 +210,10 @@ export class RecordCollaboration {
}
/** Who is on the record (the actions entry): everyone the server reports, this user marked "(you)". */
export function viewingText(everyone: Array<{ name: string; self?: boolean }> | undefined): string {
export function viewingText(everyone: Array<{ name: string; self?: boolean; connections?: number }> | undefined): string {
if (!everyone) return 'Viewing now: …'
if (!everyone.length) return 'Viewing now: no one else'
const names = everyone.map((p) => (p.self ? `${p.name} (you)` : p.name))
const names = everyone.map((p) => `${p.name}${p.connections && p.connections > 1 ? ` ×${p.connections}` : ''}${p.self ? ' (you)' : ''}`)
const others = everyone.filter((p) => !p.self).length
return `Viewing now: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${others ? ` · ${others} other${others === 1 ? '' : 's'}` : ''}`
}
+25 -2
View File
@@ -20,6 +20,24 @@ export interface PresencePerson {
/** VIEWING or EDITING (the server's mode), and the field being edited when it says. */
mode?: string
fieldName?: string | null
/** Live connections (tabs, terminals) of this person; absent or 1 before collaboration 1.2.0. */
connections?: number
/** Each connection's mode, field and start when the server sends them (collaboration 1.2.0). */
terminals?: PresenceTerminal[]
}
/** One connection of a person: what that tab or terminal does. */
export interface PresenceTerminal {
mode?: string
fieldName?: string | null
since?: string
/** This terminal. */
self?: boolean
}
/** ` ×2` after a person with more than one connection. */
export function timesText(p: { connections?: number }): string {
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
}
export interface Presence {
@@ -64,7 +82,7 @@ export function usePresence(scope: string | undefined): Presence {
/** Title-bar text for presence, empty when nobody else is there. */
export function presenceText(p: Presence): string {
if (!p.count) return ''
const names = p.people.slice(0, 3).map((x) => x.name).join(', ')
const names = p.people.slice(0, 3).map((x) => `${x.name}${timesText(x)}`).join(', ')
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
}
@@ -94,8 +112,13 @@ export function collaborationPresence(registry: ExtensionRegistry, client: TuiCl
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
current = entry
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
perConnection: true,
onChange: (state) => {
const everyone = state.people.map((p) => ({ name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName }))
const everyone = state.people.map((p) => ({
name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName,
...(p.connections && p.connections > 1 ? { connections: p.connections } : {}),
...(p.leases?.length ? { terminals: p.leases.map((l) => ({ mode: l.mode, fieldName: l.fieldName, since: l.since?.toISOString(), ...(l.self ? { self: true } : {}) })) } : {}),
}))
const people = everyone.filter((p) => !p.self).map(({ self: _self, ...person }) => person)
entry.value = { count: people.length, people, everyone }
notify()
+25 -10
View File
@@ -51,6 +51,8 @@ interface Viewer {
userId: string
displayName: string
mode?: string
/** Live connections (tabs, terminals) of this user on the table; absent before collaboration 1.2.0. */
connections?: number
}
/** Sessions that count as online: active in the last 10 minutes. */
@@ -64,11 +66,13 @@ function userLabel(userId: string): string {
* Who is online: signed-in sessions from the session store's table when the server has one and it
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
* while the viewer is signed in.
* (the saver keeps its lease, so the signed-in viewer is always one of them). Collaboration 1.2.0
* counts connections, so two terminals of one user are "2 online · you ×2"; older servers count each
* person once. Never "nobody around" while the viewer is signed in.
*/
export function presenceSource(ctx: SaverContext): TileSource {
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
// keyed by user and table: a user's terminals on different tables add up
const seen = new Map<string, { userId: string; name: string; table: string; mode?: string; connections?: number; self: boolean; at: number }>()
const skip = new Set<string>()
let cursor = 0
let sessionsGone = false
@@ -130,10 +134,9 @@ export function presenceSource(ctx: SaverContext): TileSource {
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
for (const v of snap.viewers ?? []) {
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
// this terminal is you (it keeps its lease while the saver shows): not a second person
if (you) continue
names.set(v.userId, v.displayName || v.userId)
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : undefined
if (!you) names.set(v.userId, v.displayName || v.userId)
seen.set(`${v.userId}\u0000${table}`, { userId: v.userId, name: v.displayName || v.userId, table, mode: v.mode, connections, self: you, at: t })
}
} catch (error) {
const status = (error as { status?: number }).status
@@ -146,9 +149,21 @@ export function presenceSource(ctx: SaverContext): TileSource {
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
const t = now()
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
const total = others.length + (signedIn ? 1 : 0)
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
// one entry per person, their connections summed over the tables they are on; a server
// before collaboration 1.2.0 sends no connections, and then each person counts once
const people = new Map<string, { name: string; table: string; mode?: string; connections: number; counted: boolean; self: boolean }>()
for (const v of seen.values()) {
const p = people.get(v.userId)
if (!p) people.set(v.userId, { name: v.name, table: v.table, mode: v.mode, connections: v.connections ?? 1, counted: v.connections !== undefined, self: v.self })
else if (p.counted && v.connections !== undefined) p.connections += v.connections
else p.connections = 1, p.counted = false
}
const others = [...people.values()].filter((p) => !p.self).sort((a, b) => a.name.localeCompare(b.name))
// this terminal is you (it keeps its lease while the saver shows); your other terminals count too
const yours = signedIn ? Math.max(1, [...people.values()].filter((p) => p.self).reduce((n, p) => n + p.connections, 0)) : 0
const times = (n: number) => (n > 1 ? ` ×${n}` : '')
const total = others.reduce((n, p) => n + p.connections, 0) + yours
const parts = [...(signedIn ? [`you${times(yours)}`] : []), ...others.map((p) => `${p.name}${times(p.connections)}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
},
+16 -5
View File
@@ -12,7 +12,7 @@
import type { TagEntry } from '@qrun-io/qqq-core'
import type { CollabSummary } from '../collab'
import type { PresencePerson } from '../presence'
import { timesText, type PresencePerson } from '../presence'
import { ago } from '../servers'
import { KEY_SEP, cellWidth, pad, trunc } from '../util/text'
@@ -77,7 +77,7 @@ export function tagsText(tags: TagEntry[] | undefined): string {
}
/** What a person is doing on the record: "editing email", "editing", "viewing". */
export function personState(p: PresencePerson): string {
export function personState(p: Pick<PresencePerson, 'mode' | 'fieldName'>): string {
return /edit/i.test(p.mode ?? '') ? `editing${p.fieldName ? ` ${p.fieldName}` : ''}` : 'viewing'
}
@@ -92,11 +92,11 @@ function viewingParts(state: CollabState): StripPart[] {
const others = othersOf(state)
// others (with what they do) take the place of "(you)": the strip is about who else is here
if (others.length) {
const named = others.slice(0, 2).map((p) => `${p.name} (${personState(p)})`).join(', ')
const named = others.slice(0, 2).map((p) => `${p.name}${timesText(p)} (${personState(p)})`).join(', ')
return [{ text: 'viewing: ' }, { text: named + (others.length > 2 ? ` +${others.length - 2}` : ''), em: true }]
}
const self = everyone.find((p) => p.self)
return [{ text: 'viewing: ' }, { text: self ? self.name : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
return [{ text: 'viewing: ' }, { text: self ? `${self.name}${timesText(self)}` : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
}
const count = (n: number | undefined, one: string, many = `${one}s`): StripPart[] => [{ text: n === undefined ? '…' : String(n), em: true }, { text: ` ${n === 1 ? one : many}` }]
@@ -193,10 +193,21 @@ export function panelRows(state: CollabState, width: number, height: number, now
else if (!everyone.length) add(' no one else', 'dim', 'viewing')
// others first, with what they are doing; this user last
const people: Array<PresencePerson & { self?: boolean }> = [...othersOf(state), ...(everyone ?? []).filter((x) => x.self)]
for (const p of people) add(` ${p.name}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
for (const p of people) {
add(` ${p.name}${timesText(p)}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
// with more than one terminal, what each does (this one first)
const terminals = p.terminals && p.terminals.length > 1 ? [...p.terminals].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self))) : []
terminals.forEach((x, i) => add(` ${x.self ? 'this terminal' : `#${i + 1}`} · ${personState(x)}${x.since ? ` · ${hhmm(x.since)}` : ''}`, 'dim', 'viewing'))
}
return rows.slice(0, Math.max(0, height))
}
/** A lease start as local "17:50". */
function hhmm(iso: string): string {
const at = new Date(iso)
return Number.isNaN(at.getTime()) ? iso : at.toTimeString().slice(0, 5)
}
/** A due date as "3 Nov" (and the year when it is not this one). */
function dueText(iso: string): string {
const at = new Date(iso)
+5 -3
View File
@@ -12,7 +12,7 @@ import { actionsOverlay } from '../overlays/actions'
import { TagOverlay, collaborationOf } from '../overlays/tags'
import { RecordCollaboration, uploadBlocked, viewingText, type CollabSummary } from '../collab'
import { CollabScreen, sharePreview, toggleFollow, uploadAttachment, type CollabView } from './collab'
import { presenceSource } from '../presence'
import { presenceSource, timesText } from '../presence'
import { canPrintTable, printScreen } from '../printing'
import { AuditScreen } from './audits'
import { DeveloperScreen } from './developer'
@@ -303,7 +303,9 @@ export class RecordScreen implements Screen {
/** Who else is on the record, in a dialog. */
showViewers(): void {
const viewers = this.viewers()
this.store.message(viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`, 'info', 'Presence')
const self = presenceSource().get(this.presenceScope()).everyone?.find((p) => p.self)
const yours = self?.connections && self.connections > 1 ? `\n\nYou have it open in ${self.connections} terminals.` : ''
this.store.message((viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`) + yours, 'info', 'Presence')
}
build(): void {
@@ -644,7 +646,7 @@ export class RecordScreen implements Screen {
/** The names of the others viewing this record (presence; this client is not counted). */
viewers(): string[] {
return presenceSource().get(this.presenceScope()).people.map((p) => p.name)
return presenceSource().get(this.presenceScope()).people.map((p) => `${p.name}${timesText(p)}`)
}
/** Opens comments, attachments, the timeline or tasks. */
+1 -1
View File
@@ -41,7 +41,7 @@ export interface SplashSeg {
export const SPLASH_TAGLINE = 'Terminal client for QQQ'
/** `qqq 1.0.0 · UI API client 1.8.0` */
/** `qqq 1.1.0 · UI API client 1.10.0` */
export function versionLine(): string {
return `qqq ${CLI_VERSION} · UI API client ${UI_API_VERSION}`
}
+1
View File
@@ -243,6 +243,7 @@ test('collaboration: who is viewing (this user marked) and comment lines', () =>
expect(viewingText([{ name: 'Admin', self: true }])).toBe('Viewing now: Admin (you)')
expect(viewingText([{ name: 'Tester', self: true }, { name: 'Pat Smith' }])).toBe('Viewing now: Tester (you), Pat Smith · 1 other')
expect(viewingText(undefined)).toBe('Viewing now: …')
expect(viewingText([{ name: 'Admin', self: true, connections: 2 }, { name: 'Pat Smith', connections: 1 }])).toBe('Viewing now: Admin ×2 (you), Pat Smith · 1 other')
expect(commentLines({ authorDisplayName: 'Pat', body: 'hi @[Lee](user:7)', createdAt: undefined, replies: [{ authorDisplayName: 'Lee', body: 'ok' }] }, () => 'now')).toEqual(['Pat · now: hi @Lee', ' ↳ Lee · now: ok'])
})
@@ -0,0 +1,60 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { expect, test } from 'bun:test'
import { ExtensionRegistry, opaqueConnectionId } from '@qrun-io/qqq-core'
import { collaborationPresence, presenceText } from '../src/presence'
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.2.0' }] })
/** A client stub answering presence calls; records the GET queries. */
function client(answer: (query: Record<string, string | undefined>, own: string | undefined) => unknown) {
const queries: Array<Record<string, string | undefined>> = []
let own: string | undefined
return {
queries,
call: async (id: string, input: { query?: Record<string, string | undefined>; json?: { connectionId?: string } }) => {
if (input.json?.connectionId) own = input.json.connectionId
if (id === 'getQbitCollaborationPresence') queries.push(input.query ?? {})
const data = id === 'getQbitCollaborationPresence' ? answer(input.query ?? {}, own) : id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '7', viewers: [] }
return { status: 200, headers: new Headers(), data, contentType: 'application/json' }
},
}
}
async function until(condition: () => boolean): Promise<void> {
for (let i = 0; i < 500 && !condition(); i++) await new Promise((r) => setTimeout(r, 2))
}
test('the record presence asks per connection: "Admin ×2", this terminal marked among the terminals', async () => {
const c = client((query, own) => ({
table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }, { userId: 'pat', displayName: 'Pat', mode: 'VIEWING', connections: 2 }], leaseCount: 4,
...(query.perConnection === 'true' ? { leases: [
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', own ?? ''), since: '2026-11-01T12:00:00Z' },
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'cffff', since: '2026-11-01T12:00:01Z' },
] } : {}),
}))
const source = collaborationPresence(registry, c as never, 'Admin')!
source.enter!('record:person:7')
await until(() => Boolean(source.get('record:person:7').everyone?.length))
const p = source.get('record:person:7')
expect(c.queries[0]!.perConnection).toBe('true')
const admin = p.everyone!.find((x) => x.self)!
expect(admin.connections).toBe(2)
expect(admin.terminals!.map((t) => [t.mode, t.self ?? false])).toEqual([['VIEWING', true], ['EDITING', false]])
expect(presenceText(p)).toBe('1 other viewing: Pat ×2')
source.enter!(undefined)
})
test('a server without connections (collaboration 1.1.0) still shows everyone, once each', async () => {
const c = client(() => ({ table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'pat', displayName: 'Pat', mode: 'EDITING' }] }))
const source = collaborationPresence(registry, c as never, 'Admin')!
source.enter!('record:person:7')
await until(() => Boolean(source.get('record:person:7').everyone?.length))
const p = source.get('record:person:7')
expect(p.everyone!.map((x) => [x.name, x.connections, x.terminals])).toEqual([['Admin', undefined, undefined], ['Pat', undefined, undefined]])
expect(presenceText(p)).toBe('1 other viewing: Pat')
source.enter!(undefined)
})
+26
View File
@@ -93,3 +93,29 @@ test('panel rows: tags, latest two comments, tasks with the next due, files, fol
expect(rows.filter((r) => r.key === 'C').length).toBe(5)
expect(panelRows(busy, PANEL_WIDTH - 1, 5)).toHaveLength(5)
})
test('two terminals of one user: "×2" on the strip and in the panel, each terminal with its mode and start', () => {
const since = '2026-11-01T12:00:05Z'
const local = new Date(since).toTimeString().slice(0, 5)
const twice: CollabState = {
...alone,
everyone: [
{ name: 'Admin', self: true, mode: 'EDITING', fieldName: 'name', connections: 2, terminals: [{ mode: 'EDITING', fieldName: 'name', since }, { mode: 'VIEWING', self: true }] },
{ name: 'Pat Smith', mode: 'VIEWING', connections: 3 },
],
}
expect(text(twice, 200)).toContain('viewing: Pat Smith ×3 (viewing)')
expect(text({ ...alone, everyone: [twice.everyone![0]!] }, 200).trimEnd()).toEndWith('viewing: Admin ×2 (you)')
const lines = panelRows(twice, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
expect(lines.slice(lines.indexOf(' Viewing now'))).toEqual([
' Viewing now',
' Pat Smith ×3 · viewing',
' Admin ×2 (you) · editing name',
' this terminal · viewing',
` #2 · editing name · ${local}`,
])
// a server before collaboration 1.2.0 sends no connections: no "×", no terminal lines
const old = panelRows(busy, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
expect(old.join('\n')).not.toContain('×')
expect(old.join('\n')).not.toContain('this terminal')
})
+16
View File
@@ -134,6 +134,22 @@ describe('sources', () => {
expect((await s.load()).value).toBe('1')
})
test('presence counts connections: two terminals of one user are 2 online; older servers count each person once', async () => {
const viewers: Record<string, Array<{ userId: string; displayName: string; mode: string; connections?: number }>> = {
person: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }, { userId: 'u:bob', displayName: 'Bob', mode: 'VIEWING', connections: 2 }],
order: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }],
}
const s = presenceSource(ctx({ data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
const v = await s.load()
expect(v.value).toBe('4')
expect(v.detail).toBe('4 online · you ×2, Bob ×2 · PERSON')
// only this terminal's own lease, no "connections" (collaboration 1.1.0): you, once
const old = presenceSource(ctx({ data: () => ({ viewers: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING' }] }) }))
const o = await old.load()
expect(o.value).toBe('1')
expect(o.detail).toBe('1 online · you')
})
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
const refuse = () => { throw new UiApiError(404, {}) }
// signed out: nothing to show once every table refuses
+1 -1
View File
@@ -108,7 +108,7 @@ for await (const message of openRealtimeStream({ baseUrl, token: () => tokens.ac
## Compatibility and versioning
- The package version follows the UI API version of the document it was generated from
(`UI_API_VERSION`, 1.8.0 for this release). The client works with any server on UI API 1.6.0 up to 2.0:
(`UI_API_VERSION`, 1.10.0 for this release). The client works with any server on UI API 1.6.0 up to 2.0:
operations newer than a server answer 404, and unknown enum values and extra fields are tolerated.
`GET /qqq/v1/version` tells you the server's version, features and installed extensions.
- A new UI API minor version means a new minor version of this package; a 2.0 UI API would be a new major.
@@ -1,103 +1,103 @@
{
"core": "http://localhost:8077/qqq/v1/openapi.json",
"core": "qqq/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml (qqq origin/develop 7825261ab)",
"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"
"document": "qqq/qqq-esb/src/main/resources/openapi/qqq-v1/esb.yaml",
"branch": "develop",
"commit": "7825261ab"
},
{
"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"
"document": "qqq/qqq-middleware-javalin/src/main/resources/openapi/qqq-v1/health.yaml",
"branch": "develop",
"commit": "7825261ab"
},
{
"name": "alerts",
"version": "1.2.0",
"document": "qbit-alerts/src/main/resources/openapi/qqq-v1/alerts.yaml",
"branch": "develop",
"commit": "a3fc6ac"
"commit": "420cc69"
},
{
"name": "assistant",
"version": "1.6.0",
"version": "1.7.0",
"document": "qbit-assistant/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml",
"branch": "develop",
"commit": "bcf173f"
"commit": "67bdd32"
},
{
"name": "collaboration",
"version": "1.1.0",
"version": "1.2.0",
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "develop",
"commit": "03407ae"
"commit": "7d61f29"
},
{
"name": "data-analysis",
"version": "1.5.0",
"document": "qbit-data-analysis/src/main/resources/openapi/qqq-v1/data-analysis.yaml",
"branch": "develop",
"commit": "368d2c0"
"commit": "f2fd8b1"
},
{
"name": "db-performance",
"version": "1.0.0",
"document": "qbit-db-performance/src/main/resources/openapi/qqq-v1/db-performance.yaml",
"branch": "develop",
"commit": "4fd2c30"
"commit": "b70c234"
},
{
"name": "edi",
"version": "1.1.0",
"document": "qbit-edi/src/main/resources/openapi/qqq-v1/edi.yaml",
"branch": "develop",
"commit": "75a4e6b"
"commit": "683995c"
},
{
"name": "printing",
"version": "2.3.0",
"document": "qbit-printing/src/main/resources/openapi/qqq-v1/printing.yaml",
"branch": "main",
"commit": "0ab07fc"
},
{
"name": "qa-capture",
"version": "1.0.0",
"document": "qbit-qa-capture/src/main/resources/openapi/qqq-v1/qa-capture.yaml",
"branch": "develop",
"commit": "1aee6cb"
"commit": "8ca3fd7"
},
{
"name": "quick-search",
"version": "1.1.0",
"document": "qbit-quick-search/src/main/resources/openapi/qqq-v1/quick-search.yaml",
"branch": "develop",
"commit": "bd50e1f"
"commit": "e3aa05e"
},
{
"name": "release-notes",
"version": "1.0.0",
"document": "qbit-release-notes/src/main/resources/openapi/qqq-v1/release-notes.yaml",
"branch": "develop",
"commit": "8fed18e"
"commit": "8abbe6f"
},
{
"name": "secured-data",
"version": "1.1.0",
"document": "qbit-secured-data/src/main/resources/openapi/qqq-v1/secured-data.yaml",
"branch": "develop",
"commit": "7b302ed"
"commit": "5987cb5"
},
{
"name": "sql-workbench",
"version": "1.1.0",
"document": "qbit-sql-workbench/src/main/resources/openapi/qqq-v1/sql-workbench.yaml",
"branch": "develop",
"commit": "a182076"
},
{
"name": "printing",
"version": "2.1.0",
"document": "qbit-printing/src/main/resources/openapi/qqq-v1/printing.yaml",
"branch": "feat/print-anything",
"commit": "bda172a"
"commit": "1784a5b"
}
]
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.0",
"version": "1.10.0",
"description": "Typed TypeScript client for the QQQ UI API (/qqq/v1), generated from the merged OpenAPI document (core + extensions), with OAuth2 device-flow sign-in, token refresh and a realtime (SSE) reader.",
"keywords": [
"qqq",
+1 -1
View File
@@ -14,4 +14,4 @@ export type { RealtimeMessage, RealtimeStreamOptions } from './realtime.js'
export type { paths, components, operations } from './schema.js'
/** The UI API version this client was generated from (info.version of the merged document). */
export const UI_API_VERSION = '1.8.0'
export const UI_API_VERSION = '1.10.0'
File diff suppressed because it is too large. Load diff
+26 -1
View File
@@ -14,7 +14,10 @@
* A server's own GET /qqq/v1/openapi.json is already merged and can be used directly instead.
*
* 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
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1, or an
* extension's copy may be an older copy of the one already merged: the same schema without the
* properties and enum values added since, which a MINOR UI API version may add; the newer one is
* kept and a warning printed); the
* core document's info, servers and securitySchemes are kept; tags are unioned; every extension's
* name, version, path prefix and operationIds are recorded in info.x-qqq-extensions.
*
@@ -73,6 +76,24 @@ function stable(value) {
return value
}
/**
* Whether `older` is `newer` as it was before additive (MINOR) changes: every key of `older` is in
* `newer` with an older-or-equal value, `newer` may have extra entries only under `properties`, and
* an array in `older` (an enum, `required` excepted) may lack values `newer` has.
*/
function isOlderCopy(newer, older, key) {
if (Array.isArray(older) && Array.isArray(newer)) {
const have = new Set(newer.map((v) => JSON.stringify(stable(v))))
if (key === 'required') return older.length === newer.length && older.every((v) => have.has(JSON.stringify(stable(v))))
return older.every((v) => have.has(JSON.stringify(stable(v))))
}
if (older && newer && typeof older === 'object' && typeof newer === 'object' && !Array.isArray(older) && !Array.isArray(newer)) {
for (const k of Object.keys(older)) if (!(k in newer) || !isOlderCopy(newer[k], older[k], k)) return false
return key === 'properties' || Object.keys(newer).every((k) => k in older)
}
return JSON.stringify(stable(newer)) === JSON.stringify(stable(older))
}
const [coreSource, ...extensionSources] = inputs
const merged = await load(coreSource)
merged.paths ??= {}
@@ -107,6 +128,10 @@ for (const source of extensionSources) {
for (const [schemaName, schema] of Object.entries(doc.components?.schemas ?? {})) {
const existing = merged.components.schemas[schemaName]
if (existing && JSON.stringify(stable(existing)) !== JSON.stringify(stable(schema))) {
if (isOlderCopy(existing, schema)) {
console.warn(`warning: ${name} carries an older copy of schema ${schemaName} (without later additions); keeping the newer one`)
continue
}
throw new Error(`schema ${schemaName} differs between the core document and ${name}`)
}
merged.components.schemas[schemaName] = schema
+1
View File
@@ -4,6 +4,7 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,