Merge feat/m1-kernel-cli (ee855eb) into feat/m1-process-components

Brings in the extensions, related-records and query-gaps streams. The parity
matrix is regenerated from the merged STATUS table; process downloads already
use the shared safeFileName/saveDownload helpers from the base.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-10-31 18:06:15 -05:00
commit f364a3c8de
51 files changed
+64523 -219

No files matched your search

+95 -3
View File
@@ -105,6 +105,15 @@ qqq table copy pet 1 --set name=Copycat # copy a record (--dry-run shows
qqq apps show greetingsApp # an app's home: actions, reports, tables with record counts, widgets
qqq report list # the sample app has no reports
qqq help show table person [field] ; qqq help show process greet # help content from the metadata (none in the sample)
qqq table query person --join pet --select -l 4 # joins: exposed joins (none in the sample), else the table's associations
qqq table query person --fields id,firstName,pet.name -w "pet.name STARTS_WITH C" -o pet.name
qqq table count person --join pet # many-side join: count and distinctCount
qqq table get person 1 --join pet
qqq table variants person # none in the sample; --variant <id|name|type:id> on table commands and process run
qqq table stats person annualSalary --local -l 3 # column statistics; the sample has no columnStats process, --local computes them
qqq saved-view list person ; qqq saved-view open person <view> ; qqq table query person --view <view> -w … # saved views (none in the sample)
qqq saved-view save person --name Mine -w "lastName = Sample" -o -id --fields id,firstName -l 50 ; saved-view rename|delete person Mine …
qqq audit list person 1 # audit history (the sample audits nothing: exit 2 with the reason)
qqq process list ; qqq process describe sleepInteractive
qqq process run sleepInteractive -v sleepMillis=1500 # async job, polled, progress on stderr
qqq process run clonePeople -r 1,2 # records by primary key
@@ -114,6 +123,7 @@ qqq widget get SampleStackedBarChartWidget --output json # the raw payload
qqq prefs list ; qqq prefs get regional ; qqq prefs set regional --set weekStartsOn=MONDAY
qqq help-search person ; qqq report run <name> --format xlsx
qqq search Avery ; qqq tail
qqq extensions ; qqq esb table person # extensions (qbits): see "Extensions (qbits)" below
```
Profiles name servers, as `aws --profile`; sign-ins live in the OS keyring (macOS Keychain here, Secret Service on
@@ -200,6 +210,26 @@ lists the candidates. Validation (required, type, length, range, pattern) runs l
same codes and messages the server would give. Form adjusters (on-load and on-change rules an application declares)
are called when the server has them; this sample app has none.
Related records and the record view. `table get` shows sections in metadata order (hidden sections and fields never),
empty values as `--`, and adornments as text: a record link names its table and key (`Avery Sample → person 1`),
a chip is `[value]`, a REVEAL value is masked until `--reveal`, an error value starts with `!`, a tooltip follows in
parentheses, HTML is shown as text, a stored file shows its size or name and the download command.
```sh
qqq table get person 1 --associations # every association as tables, all levels: Pet [pets], then each pet's notes
qqq table get pet 1 --links # the records this one links to, with the command that opens each
qqq table get pet 1 --follow personId # open the related record (LINK adornment or possible-value table)
qqq table children person 1 # the record's associations and how many children each has
qqq table children person 1 pets --limit 2 --skip 2 # one page of a child list, with the count and the next --skip
qqq table children pet 1 notes --all
qqq table create pet --parent person=2 --set name=Rexley --set speciesId=Dog # a child linked to its parent
qqq table create petNote --parent pet=5 --set "note=Vet visit" # then: qqq table get person 2 --associations
qqq table create fieldLab --set name=withfile --upload blobValue=./notes.txt # multipart/form-data
qqq table update fieldLab 1 --upload blobValue=./other.txt ; qqq table update fieldLab 1 --set blobValue= # replace, remove
qqq table download fieldLab 1 blobValue -o copy.txt # -o - for stdout
qqq table get person 1 --help-content # table, section and field help inline (this sample app declares none; qqq help show for help alone)
```
DATE_TIME values are typed and shown in your zone (the `TZ` environment) and sent as UTC instants, as the web
UI does; `--utc` shows the raw instants. A time skipped by a spring-forward change moves forward by the gap; a
time repeated by a fall-back change means its first occurrence. In filters, a date alone on a DATE_TIME field
@@ -218,9 +248,71 @@ Filter values can be expressions: `now()`, `now(-7d)`, `thisPeriod(month)`, `las
`startOf(quarter,-1)`, `me()`, `${variable.x}`; quote values with commas: `city IN "St. Louis, MO",Chicago`.
Where M1 stands: `docs/parity/feature-matrix.md` has the CLI status of every Next UI acceptance row. Not yet in
the CLI: bulk load (the sample server cannot accept uploads, see `docs/server-findings.md` #10), saved views,
column statistics, joins, variants, audits, jobs on a durable-jobs server,
TABLE_BASED sign-in.
the CLI: bulk load (the sample server cannot accept uploads, see `docs/server-findings.md` #10), jobs on a
durable-jobs server, TABLE_BASED sign-in. Built but not verifiable against this sample, which has none of them:
saved views, server-side column statistics (columnStats), audits, a variant-backed table, exposed joins (joins were
checked through an association join instead); node tests and a local stub server cover those.
## Extensions (qbits)
An extension is a qbit or module that adds UI API operations. The CLI knows every extension whose
document was merged into the client (13 today, listed in `docs/extensions.md`); a server installs
some of them and lists them in `GET /version`. Each has a noun:
| Noun | Extension | First-class verbs | Generated verbs (one per operation) |
|---|---|---|---|
| `qqq esb` | esb (qqq) | `overview`, `table <t>`, `process <p>`, `dead-letters <trigger>`, `messages <destination> [--trigger t]` | `get-overview`, `get-table`, ... |
| `qqq collab` | collaboration | `presence <table> [pk] [--watch\|--observe]`, `comments list\|add`, `favorites list\|add\|remove`, `recents`, `follow [--status]`, `unfollow`, `my-work`, `people [q]` | `get-records-timeline <table> <pk>`, `get-records-tags`, `put-records-assignment`, ... (45) |
| `qqq health`, `alerts`, `assistant`, `data-analysis`, `db-performance`, `edi`, `qa-capture`, `quick-search`, `release-notes`, `secured-data`, `sql-workbench` | the qbit of that name | none yet | every operation, e.g. `qqq sql-workbench get-capabilities` |
Generated verbs are the method plus the literal path segments after the extension's prefix. They take
path parameters as arguments or `--path name=value`, plus `--param`, `--body-json`, `--form` and `--out`.
`qqq <noun> --help` lists them with the operation behind each.
What happens:
- **Installed.** The command runs. `qqq whoami` and `qqq extensions` show it with its version.
- **Not installed.** Every verb of the noun, and `qqq api <operation>` for any of its operations, stops
before calling the server, with exit 5: `extension collaboration is not installed on this server
(installed: esb 1.1.0)`.
- **Installed below the minimum the client needs** (esb, health and collaboration need 1.1.0): exit 5,
`this client needs extension esb 1.1.0 or later; this server has 1.0.0`.
- **Installed but unknown to this client** (a newer qbit): listed by `qqq whoami` and `qqq extensions`
as `unknown to this client`, never dropped. It has no noun until the client document is regenerated.
The installed list comes from `GET /version`, cached with the metadata for five minutes (`qqq cache
refresh` rereads it). `QQQ_EXTENSION_CHECK=off` skips the check, for a Prism mock whose `/version` is
only an example.
```sh
qqq extensions # known vs installed, versions, minimums, operations
qqq api --list --extension collaboration # an extension's operations (the list has an extension column)
qqq esb table person # sample server: what person publishes, which processes it triggers
qqq esb process syncPerson ; qqq esb messages personEvents --trigger syncPerson.personEvents
qqq collab presence person 1 # sample server: exit 5, collaboration is not installed
```
Collaboration needs a server with qbit-collaboration. Its repo has a demo server (memory backend, MOCK
users alice, bob and admin); it runs on port 8098 beside the sample server:
```sh
cd ../qbit-collaboration && ./mvnw -q test-compile dependency:build-classpath -Dmdep.includeScope=test -Dmdep.outputFile=target/demo-classpath.txt
mkdir -p /tmp/fe/next-dashboard && echo '<html></html>' > /tmp/fe/next-dashboard/index.html # it insists on a Next export
java -Dqqq.javalin.frontend=next -Dcollab.dev.port=8098 -cp "target/test-classes:target/classes:$(cat target/demo-classpath.txt):/tmp/fe" \
com.kingsrook.qbits.collaboration.devserver.CollaborationDevServer
qqq login --server http://localhost:8098 --profile collab # alice
qqq --profile collab collab presence person 1 --watch # join; prints each change until Ctrl+C
qqq --profile collab collab presence person --observe # watch the table without appearing
qqq --profile collab collab comments list person 1 ; qqq --profile collab collab comments add person 1 "Looks right"
qqq --profile collab collab favorites list ; qqq --profile collab collab favorites add person 4
qqq --profile collab collab follow person 2 --comments off ; qqq --profile collab collab my-work
```
To see someone else, open `http://localhost:8098/?createMockSession=true&user=bob` in a browser on a
person (needs a real Next export), or hold a lease as bob with curl (`POST /qqq/v1/qbit/collaboration/presence?user=bob`).
Presence lists everyone holding a lease on the record or table, you included while you `--watch`;
`since` is when the CLI first saw them.
## Reporting what you find
+37
View File
@@ -0,0 +1,37 @@
# Extension documents in the client
The client document (`packages/ui-api-client/openapi/qqq-v1.json`) is the sample server's live
document for core (QQQ 4.1.0-SNAPSHOT, `develop` 6ac64fc66, UI API 1.8.0, which already carries esb)
merged with the extension document each repository commits at `src/main/resources/openapi/qqq-v1/`.
Rebuild it with the sample server running:
```sh
npm run spec:merge-extensions && npm run generate # writes openapi/extension-sources.json too
```
Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/openapi/extension-sources.json`):
| Extension | Version | Prefix | Operations | Repository at | Minimum the client needs |
|---|---|---|---|---|---|
| esb | 1.1.0 | `/esb` | 5 | qqq `develop` 6ac64fc66 (qqq-esb) | 1.1.0 (operationIds, UI-API-CHANGELOG) |
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
| qa-capture | 1.0.0 | `/qbit/qa-capture` | 2 | qbit-qa-capture `develop` 1aee6cb | |
| quick-search | 1.1.0 | `/qbit/quick-search` | 2 | qbit-quick-search `develop` bd50e1f | |
| release-notes | 1.0.0 | `/qbit/release-notes` | 3 | qbit-release-notes `develop` 8fed18e | |
| secured-data | 1.1.0 | `/qbit/secured-data` | 3 | qbit-secured-data `develop` 7b302ed | |
| sql-workbench | 1.1.0 | `/sql-workbench` | 21 | qbit-sql-workbench `develop` a182076 | |
202 operations in all, 136 from extensions. No extension document conflicted with core or another
(identical shared schemas such as `BasicErrorResponseV1`). There is no `qbit-middleware-health`: the
health extension lives in qqq's `qqq-middleware-javalin`. Other qbits in the workspace (authentik,
jobs, help, middleware-mcp, ...) register no UI API extension.
Minimums are only set where an extension's own docs state one; the rest accept any version the
server lists. Each extension's minimum and realtime channels live in
`packages/core/src/extensions/registry.ts` (`FACTS`).
+47 -47
View File
@@ -14,13 +14,13 @@ The TUI column fills in during M3.
| NAV | 40 | 9 | 3 | 1 | 27 |
| PRC | 60 | 35 | 11 | 2 | 12 |
| PRF | 5 | 4 | 1 | 0 | 0 |
| QRY | 70 | 28 | 19 | 0 | 23 |
| REC | 65 | 30 | 23 | 0 | 12 |
| REL | 9 | 4 | 5 | 0 | 0 |
| QRY | 70 | 30 | 6 | 13 | 21 |
| REC | 65 | 31 | 6 | 17 | 11 |
| REL | 9 | 6 | 0 | 3 | 0 |
| RPT | 20 | 1 | 19 | 0 | 0 |
| SEC | 43 | 21 | 10 | 0 | 12 |
| WID | 69 | 29 | 27 | 0 | 13 |
| **all** | 401 | 167 | 118 | 7 | 109 |
| **all** | 401 | 172 | 83 | 40 | 106 |
## Rows
@@ -134,10 +134,10 @@ The TUI column fills in during M3.
| QRY-090 | Column menu sorts, pins, opens a field filter and hides a column | n/a | todo | saved views and column UI |
| QRY-091 | Column menu copies page or full query values in backend sort order | n/a | todo | saved views and column UI |
| QRY-092 | Column chooser searches, groups, counts and marks changed layouts | n/a | todo | saved views and column UI |
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | n/a | todo | saved views and column UI |
| QRY-094 | Counted quick views render and open their saved-view route | n/a | todo | saved views and column UI |
| QRY-093 | Last saved view and unsaved edits survive reopening and reload | n/a | todo | no screen state to keep: views are opened by name |
| QRY-094 | Counted quick views render and open their saved-view route | partial | todo | saved-view list counts quick views with doCount (--counts counts all) |
| QRY-095 | A new query defaults to fifty rows per page | n/a | todo | saved views and column UI |
| QRY-096 | A saved view reconciles removed fields and warns the user | n/a | todo | saved views and column UI |
| QRY-096 | A saved view reconciles removed fields and warns the user | partial | todo | opening a view drops removed fields with Material's warnings on stderr |
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | todo | default order by primary key |
| QRY-002 | Pagination with Material page sizes and URL state | pass | todo | --limit, --skip, --all |
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | todo | --order, -field for descending |
@@ -155,10 +155,10 @@ The TUI column fills in during M3.
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | todo | --print-filter gives the JSON the web links accept |
| QRY-018 | Backend-only operators from links render and filter correctly | pass | todo | any operator in --where |
| QRY-019 | Quick search combines with the advanced filter | pass | todo | --search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | todo | todo | joins |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | todo | todo | joins |
| QRY-022 | Column statistics aggregate the filtered rows | todo | todo | column statistics |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | todo | todo | |
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | partial | todo | --join and table.field in --fields/--where/--order, labelled "Join: Field"; the sample exposes no joins, verified through an association join (person to pet) |
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | pass | todo | a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct) |
| QRY-022 | Column statistics aggregate the filtered rows | partial | todo | qqq table stats <t> <field> [-w …] through the columnStats process; the sample has neither QUERY_STATS nor the process, so --local (same figures from the matching rows) is what was verified |
| QRY-023 | Tables without QUERY_STATS offer no column statistics | pass | todo | without QUERY_STATS (or the process) table stats exits 2 with the reason; --local is an explicit opt-in |
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | todo | -r ids, --filter-json, --limit |
| QRY-031 | Bulk Edit launches with the checked record ids | todo | todo | bulk edit |
| QRY-032 | Bulk Delete of the full query result sends the whole filter | todo | todo | bulk delete |
@@ -169,23 +169,23 @@ The TUI column fills in during M3.
| QRY-041 | XLSX and JSON exports carry the same rows | pass | todo | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
| QRY-042 | Nothing to export disables the formats | n/a | todo | nothing to disable |
| QRY-043 | Backend refuses exports of unreadable tables | pass | todo | server refuses |
| QRY-050 | Your saved views are listed and open on their own route | todo | todo | saved views |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | todo | todo | |
| QRY-052 | Update, rename and delete your view | todo | todo | |
| QRY-053 | Duplicate names and unknown views are reported without changes | todo | todo | |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | todo | todo | |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | todo | todo | table variants |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | todo | todo | |
| QRY-050 | Your saved views are listed and open on their own route | partial | todo | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server |
| QRY-051 | Saving a new view stores filter, sort, columns and page size | partial | todo | saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView |
| QRY-052 | Update, rename and delete your view | partial | todo | saved-view save --replace, rename, delete (asks; --yes) |
| QRY-053 | Duplicate names and unknown views are reported without changes | partial | todo | a name in use is refused before any change; an unknown view exits 2 with the list |
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | partial | todo | views shared with you are listed as shared by their owner; changes to them are the server's to refuse, its message shown |
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | partial | todo | table variants; --variant on query, count, get, copy, export, writes and process run; a terminal asks; not remembered between commands; no variant table in the sample |
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | partial | todo | no --variant and no terminal (or no answer): exit 2, nothing queried; no variant table in the sample |
| QRY-062 | A table without count, export or write capabilities offers none of them | pass | todo | describe shows capabilities; server refuses |
| QRY-063 | Enum-backed Pet Species table is read-only | pass | todo | server refuses writes |
| QRY-064 | Backend refuses count and export for a table without those capabilities | pass | todo | |
| QRY-065 | Export streams from the v1 export route | pass | todo | POST /table/{t}/export |
| QRY-066 | Variant options come from the v1 variants route | todo | todo | variants route |
| QRY-066 | Variant options come from the v1 variants route | partial | todo | GET /table/{t}/variants (called live; the sample lists none) |
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | pass | todo | table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note |
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | todo | default columns follow sections by tier |
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | n/a | todo | locale formatting |
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | todo | shortcuts |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | todo | todo | tableVariant on processes |
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | partial | todo | process run --variant: tableVariant on init, every step and status; sent live, no variant table to observe |
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | n/a | todo | quick filters UI |
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | n/a | todo | |
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | n/a | todo | |
@@ -201,17 +201,17 @@ The TUI column fills in during M3.
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | n/a | n/a | phone layout |
| QRY-036 | Bulk actions launch from a phone card selection | n/a | n/a | phone layout |
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | n/a | n/a | phone layout |
| REL-001 | Possible-value cells show the related record label and link to it | pass | todo | display values and labels |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | todo | todo | child lists: table get --associations partial |
| REL-003 | Adding a child from its parent links it and shows it immediately | todo | todo | create child |
| REL-004 | Three-level associations: each pet shows only its own notes | todo | todo | |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | todo | server |
| REL-006 | Denied child reads show an unavailable panel without data or actions | pass | todo | server |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | todo | todo | |
| REL-008 | Adding through a composite association stores every join value | todo | todo | |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | pass | todo | server |
| REL-001 | Possible-value cells show the related record label and link to it | pass | todo | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one |
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | todo | table get --associations lists exactly the record's children; table children pages a list with its count (View All) |
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | todo | table create <child> --parent table=pk; table get --associations shows it at once |
| REL-004 | Three-level associations: each pet shows only its own notes | pass | todo | person > pets > notes: each pet lists only its own notes |
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | todo | records are never cached: update or delete a child, the next get/children shows the current rows |
| REL-006 | Denied child reads show an unavailable panel without data or actions | partial | todo | unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested) |
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | partial | todo | composite joins and widget-section labels in the kernel, unit-tested; the sample declares none |
| REL-008 | Adding through a composite association stores every join value | partial | todo | --parent sets every join value of a composite association, unit-tested; the sample declares none |
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | pass | todo | table delete of a parent: its pets are gone, other parents keep theirs |
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | todo | table get by section and tier |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | todo | todo | hidden sections |
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | partial | todo | empty values show --; hidden sections and fields are dropped by the kernel record view (unit-tested; the sample has none) |
| REC-003 | Record view of a missing record shows Record Not Found and a way back | pass | todo | exit 5 |
| REC-004 | Record view tabs and list view expose every visible section | n/a | todo | tabs |
| REC-005 | Create form validates required fields in the browser before any request | pass | todo | validated locally before any request |
@@ -237,22 +237,22 @@ The TUI column fills in during M3.
| REC-025 | Case and whitespace normalization on save | pass | todo | behaviors |
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | pass | todo | ranges checked locally |
| REC-027 | Unique keys are enforced with the backend message on create and edit | pass | todo | server message |
| REC-028 | LINK adornment: record links and URL links | todo | todo | adornments |
| REC-029 | CHIP adornment colors and icons by value | todo | todo | |
| REC-028 | LINK adornment: record links and URL links | pass | todo | record links name table and key, --follow opens them; URL links show the URL |
| REC-029 | CHIP adornment colors and icons by value | partial | todo | chip as [value]; colors and icons not shown (unit-tested; none in the sample) |
| REC-030 | SIZE adornment sets the query grid column width | n/a | todo | |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | todo | todo | |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | todo | todo | |
| REC-033 | REVEAL adornment masks, reveals and copies the value | todo | todo | reveal |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | todo | todo | file download |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | todo | todo | file upload |
| REC-036 | TOOLTIP adornment shows its text on the value | n/a | todo | |
| REC-037 | WIDGET adornment renders the field value as the named widget | todo | todo | |
| REC-038 | ERROR adornment highlights the value | todo | todo | |
| REC-039 | Field help content follows screen roles and formats | todo | todo | help |
| REC-040 | Section help content on the record view | todo | todo | |
| REC-041 | Table help content slots are delivered in table metadata | todo | todo | |
| REC-042 | Audit history lists who changed what, newest first | todo | todo | audits |
| REC-043 | Audit history without process permission reads the audit table | todo | todo | |
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | partial | todo | raw code on view; no code editor (unit-tested) |
| REC-032 | RENDER_HTML adornment renders sanitized HTML | partial | todo | HTML shown as text (unit-tested) |
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | todo | masked until --reveal; no copy (unit-tested; none in the sample) |
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | todo | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16 |
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | partial | todo | create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample |
| REC-036 | TOOLTIP adornment shows its text on the value | partial | todo | tooltip text in parentheses after the value (unit-tested) |
| REC-037 | WIDGET adornment renders the field value as the named widget | partial | todo | value rendered through the widget render model as text (unit-tested) |
| REC-038 | ERROR adornment highlights the value | partial | todo | value marked with ! (unit-tested) |
| REC-039 | Field help content follows screen roles and formats | partial | todo | table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone |
| REC-040 | Section help content on the record view | partial | todo | section help with --help-content (unit-tested) |
| REC-041 | Table help content slots are delivered in table metadata | partial | todo | table helpContent and helpContents slots with --help-content (unit-tested) |
| REC-042 | Audit history lists who changed what, newest first | partial | todo | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server |
| REC-043 | Audit history without process permission reads the audit table | partial | todo | without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests) |
| REC-044 | Record label format is used for the record's title and dialogs | pass | todo | recordLabel |
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | todo | labels resolved, defaults looked up |
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | pass | todo | server |
@@ -272,8 +272,8 @@ The TUI column fills in during M3.
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON re | todo | todo | |
| REC-062 | Inline enum possible values filter by label prefix without a lookup request; CHIP adornments style options and | pass | todo | inline enums |
| REC-060 | Material table and field form adjusters and field rules operate against the real QQQ sample server | todo | todo | adjusters wired, no sample to test |
| REC-064 | Record sections render qualified fields from readable exposed joins | todo | todo | joins |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | todo | todo | variants |
| REC-064 | Record sections render qualified fields from readable exposed joins | partial | todo | table get --join adds joined fields (queryJoins); verified with an association join |
| REC-063 | Record view, edit and copy load from the table variant selected on the query screen | partial | todo | get, copy and update -i read with --variant; v1 writes ignore it (server finding #19) |
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | todo | an unchanged wall-clock time keeps the original instant and its precision |
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | todo | |
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | todo | todo | disabled tables listed with a reason |
+5
View File
@@ -20,3 +20,8 @@ the sample app and MOCK authentication. Each is a candidate issue for `QRun-IO/q
| 13 | `POST /processes/{p}/{uuid}/cancel` answers `200 {}` for a run that declares no cancel step, and the run can still be stepped afterwards (its state is kept). A malformed UUID answers 500 `INTERNAL_ERROR` (`IllegalArgumentException`) instead of 400. | init `sleepInteractive`, cancel, then step `screen0`: it runs. `POST …/nonexistent-uuid/cancel`. | `qqq process run` treats 200 as cancelled and stops; nothing to do for the 500. A cancelled run should refuse further steps (409 or 404). |
| 14 | The `isStepBack=true` query parameter of `stepProcess` (going back to `backStep`) is described in the `ProcessStepComplete.backStep` schema text but is not a declared parameter of the operation, so a spec-driven client cannot know it exists. | Spec: `stepProcess` lists only `tableVariant`. | The kernel sends `isStepBack=true` as the Next UI does. Declare the parameter. |
| 15 | A step that fails on missing input answers `ERROR` with only `error` (`Error message: Unable to get Cities by state. Missing required input.`), no `userFacingError`, and the field is not marked required in the step metadata, so a client cannot tell an input problem from a crash or ask for the field first. | `qqq process run placeAddressPick --no-prompt`. | The CLI exits 6 (validation) when the message says required input is missing or the error is user-facing, 1 otherwise. Mark the fields required, or return `userFacingError` (or a 400 with `fieldErrors`). |
| 16 | `downloadRecordFieldFile` on a BLOB field without adornments answers 500 `NullPointerException (Cannot invoke "java.util.List.stream()" because the return value of "QFieldMetaData.getAdornments()" is null)`. | `qqq table create fieldLab --set name=x --upload blobValue=./a.txt`, then `qqq api downloadRecordFieldFile --path tableName=fieldLab --path primaryKey=<id> --path fieldName=blobValue --path filename=a.txt -o out`. | `qqq table download` uses the route only for FILE_DOWNLOAD fields and decodes the record's base64 BLOB value otherwise. The route should treat no adornments as none (or answer 404/400). |
| 17 | The sample app declares no field adornments other than LINK, no hidden fields or sections, no help content, no exposed joins, no composite or aliased associations and no table the mock user may not read, so CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP, help display, denied child reads and composite associations can only be checked with unit tests against the client. | `GET /metaData/table/{t}` for every sample table. | None needed in the client; a `fieldLab`-style adornment table in the sample (one field per adornment, a hidden section, help on a field, section and table, a composite association) would make these rows checkable live. |
| 18 | qbit-collaboration 1.1.0: when the last viewer leaves, `presence.changed` (and the table snapshot) is `{"table":"person"}` with no `viewers`, although `CollaborationPresenceSnapshotV1.viewers` is a non-nullable array. A client that waits for `viewers` keeps showing the last person. Seen on the qbit's `CollaborationDevServer` (QQQ 4.1.0-RC.15). | Two sessions (`?user=bob` and alice); bob POSTs then DELETEs `/qbit/collaboration/presence` while alice is subscribed to `table:person`. | The kernel treats a payload with `table` as the snapshot and missing `viewers` as none. The qbit should send `"viewers": []`. |
| 19 | The v1 insert, update and delete routes do not read a table variant: `TableInsertExecutor`, `TableUpdateExecutor` and `TableDeleteExecutor` never call `ExecutorSessionUtils.setTableVariantInSession`, and the spec gives them no `tableVariant` (query, count, get, export and the process routes have it). A client cannot write to a variant-backed table over v1. | Read `executors/Table{Insert,Update,Delete}Executor.java`; the legacy `QJavalinImplementation.setupSession` read a `tableVariant` form or query parameter for every route. | `qqq table create/update/delete/copy --variant` send `tableVariant` as a query parameter (as the get route takes it) and say the server may ignore it. Add the parameter to the three routes. |
| 20 | Joins do not reach the export route: `exportTable` has no `joins` member and a joined name in `fieldNames` (`pet.name` on `person`, a join the query route accepts) answers 500 `Field name pet.name was not found on the Person table.` The web UI exports joined columns by name; unclear whether exposed joins work there. | `qqq api exportTable --path tableName=person --body-json '{"format":"csv","fieldNames":["id","pet.name"]}'` | `qqq table export` accepts joined columns of exposed joins only and checks them before sending. |
+1
View File
@@ -15,6 +15,7 @@
],
"scripts": {
"spec:merge": "node scripts/merge-spec.mjs --out packages/ui-api-client/openapi/qqq-v1.json",
"spec:merge-extensions": "node scripts/merge-extensions.mjs",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present",
+52 -29
View File
@@ -6,7 +6,7 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { basename } from 'node:path'
import type { Command } from 'commander'
import { OPERATIONS, operation } from '@qrun-io/qqq-core'
import { OPERATIONS, operation, type OperationInfo } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { parsePairs, readJson } from './shared.js'
@@ -21,39 +21,62 @@ export function registerApi(program: Command, ctx: () => Context): void {
.option('--bytes <file>', 'raw request body (application/octet-stream)')
.option('-o, --out <file>', 'write a binary response to a file')
.option('-l, --list', 'list the operations instead of calling one')
.action(async (operationId: string | undefined, options: { path?: string[]; param?: string[]; bodyJson?: string; form?: string[]; bytes?: string; out?: string; list?: boolean }) => {
.option('-e, --extension <name>', 'with --list: only this extension\'s operations (`core` for core ones)')
.action(async (operationId: string | undefined, options: OperationOptions & { list?: boolean; extension?: string }) => {
const c = ctx()
if (options.list || !operationId) {
const rows = OPERATIONS.map((o) => ({ operationId: o.id, method: o.method, path: o.path, body: o.bodyTypes.join('|'), summary: o.summary }))
c.print({ data: rows, columns: ['operationId', 'method', 'path', 'body'], footer: `(${rows.length} operations; qqq api <operationId> --path k=v --param k=v --body-json '{...}')` })
const selected = OPERATIONS.filter((o) => !options.extension || (options.extension === 'core' ? !o.extension : o.extension === options.extension))
const rows = selected.map((o) => ({ operationId: o.id, extension: o.extension ?? '', method: o.method, path: o.path, body: o.bodyTypes.join('|'), summary: o.summary }))
const extensions = new Set(selected.map((o) => o.extension).filter(Boolean)).size
c.print({ data: rows, columns: ['operationId', 'extension', 'method', 'path', 'body'], footer: `(${rows.length} operations, ${extensions} extensions; qqq api <operationId> --path k=v --param k=v --body-json '{...}'; whether an extension is installed: qqq whoami)` })
return
}
const op = operation(operationId)
if (!op) throw new UsageError(`No operation "${operationId}"; \`qqq api --list\` shows them.`)
let form: Record<string, string | Blob> | undefined
if (options.form) {
form = {}
for (const [k, v] of Object.entries(parsePairs(options.form, '--form'))) {
form[k] = v.startsWith('@') ? new File([readFileSync(v.slice(1))], basename(v.slice(1))) : v
}
}
const result = await c.call(operationId, {
path: parsePairs(options.path, '--path'),
query: parsePairs(options.param, '--param'),
json: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined,
form,
bytes: options.bytes ? new Uint8Array(readFileSync(options.bytes)) : undefined,
})
if (result.bytes) {
if (!options.out) throw new UsageError(`${operationId} returned ${result.contentType} (${result.bytes.length} bytes); pass --out <file>`)
writeFileSync(options.out, result.bytes)
c.note(`wrote ${options.out} (${result.bytes.length} bytes, ${result.contentType})`)
return
}
if (typeof result.data === 'string') {
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data })
await runOperation(c, op, options)
})
}
/** The options every generic operation command takes. */
export interface OperationOptions {
path?: string[]
param?: string[]
bodyJson?: string
form?: string[]
bytes?: string
out?: string
}
/**
* Calls one operation from command options and prints the result. An operation of an extension
* the server does not list is refused before the request (ExtensionNotInstalledError, exit 5).
*/
export async function runOperation(c: Context, op: OperationInfo, options: OperationOptions, pathValues: Record<string, string> = {}): Promise<void> {
if (op.extension) (await c.extensions()).requireOperation(op.id)
const operationId = op.id
let form: Record<string, string | Blob> | undefined
if (options.form) {
form = {}
for (const [k, v] of Object.entries(parsePairs(options.form, '--form'))) {
form[k] = v.startsWith('@') ? new File([readFileSync(v.slice(1))], basename(v.slice(1))) : v
}
}
const result = await c.call(operationId, {
path: { ...pathValues, ...parsePairs(options.path, '--path') },
query: parsePairs(options.param, '--param'),
json: options.bodyJson !== undefined ? readJson(options.bodyJson, '--body-json') : undefined,
form,
bytes: options.bytes ? new Uint8Array(readFileSync(options.bytes)) : undefined,
})
if (result.bytes) {
if (!options.out) throw new UsageError(`${operationId} returned ${result.contentType} (${result.bytes.length} bytes); pass --out <file>`)
writeFileSync(options.out, result.bytes)
c.note(`wrote ${options.out} (${result.bytes.length} bytes, ${result.contentType})`)
return
}
if (typeof result.data === 'string') {
process.stdout.write(result.data.endsWith('\n') ? result.data : result.data + '\n')
return
}
c.print({ data: result.data })
}
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { auditSource, getAudits } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch } from '../session.js'
import type { Json } from './shared.js'
export function registerAudit(program: Command, ctx: () => Context): void {
program.command('audit').description('Audit history: list.').command('list <table> <primaryKey>')
.description("A record's audit history: who changed what, newest first (the audit process, or the audit table when you may not run it).")
.option('--asc', 'oldest first')
.action(async (table: string, primaryKey: string, options: { asc?: boolean }) => {
const c = ctx()
const meta = await c.tableMeta<Json>(table)
const source = auditSource(meta, await c.metaData() as unknown as Json)
if (!source) throw new UsageError(`No audits you may read for ${meta.label} on this server (no audit process or table).`)
const fields = (meta.fields ?? {}) as Record<string, Json>
const { entries, total } = await getAudits(c.server, authenticatedFetch(c.target), source, table, primaryKey, { ascending: options.asc })
const rows = entries.flatMap((e) => (e.changes.length ? e.changes : [undefined]).map((change, i) => ({
when: i ? '' : e.timestamp,
user: i ? '' : e.user,
action: i ? '' : e.action.toLowerCase(),
change: change ? (change.message ?? `${String(fields[change.fieldName]?.label ?? change.fieldName)}: ${String(change.oldValue ?? '')} -> ${String(change.newValue ?? '')}`) : e.message,
})))
c.print({ data: entries, rows, columns: ['when', 'user', 'action', 'change'], footer: `(${entries.length} audit${entries.length === 1 ? '' : 's'}${total !== null && total > entries.length ? ` of ${total}` : ''}; read through the ${source.kind === 'process' ? `${source.processName} process` : `${source.tableName} table`})` })
})
}
+1 -1
View File
@@ -113,7 +113,7 @@ export function registerAuth(program: Command, ctx: () => Context): void {
tokenExpires: login && login.kind !== 'session' ? new Date(login.tokens.expiresAt).toISOString() : '',
apiVersion: version.apiVersion,
features: ((version.features ?? []) as string[]).join(', '),
extensions: ((version.extensions ?? []) as Json[]).map((e) => `${e.name} ${e.version}`).join(', '),
extensions: (await c.extensions({ refresh: true })).describe(),
apps: Object.keys((metaData.apps ?? {}) as Json).length,
tables: Object.keys((metaData.tables ?? {}) as Json).length,
processes: Object.keys((metaData.processes ?? {}) as Json).length,
+213
View File
@@ -0,0 +1,213 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* First-class commands of `qqq collab` (the collaboration extension): presence, comments,
* favorites, recents, follow, my work and people. The kernel's Collaboration module does the
* calls; every command requires the extension first (exit 5 with the installed list).
*/
import type { Command } from 'commander'
import { COLLABORATION, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import type { Json } from './shared.js'
const TARGET_TYPES = ['TABLE', 'APP', 'RECORD', 'PROCESS', 'REPORT', 'SAVED_VIEW']
async function collab(c: Context) {
;(await c.extensions()).require(COLLABORATION)
return c.collaboration()
}
function hhmmss(date: Date): string {
return date.toTimeString().slice(0, 8)
}
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
export function presenceLine(state: PresenceState): string {
if (!state.available) return 'presence: the collaboration extension is not installed'
if (state.error) return `presence: ${state.error}`
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
if (!state.count) return `nobody on ${where}`
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
return `${state.count} on ${where}: ${people.join(', ')}`
}
function favoriteRows(items: FavoriteEntry[]): Json[] {
return items.map((f) => ({ id: f.id ?? '', type: f.targetType ?? '', name: f.targetName ?? '', record: f.recordId ?? '', label: f.label ?? '', folder: f.folder ?? '', available: f.available ?? '', href: f.href ?? '' }))
}
function commentRows(threads: CommentEntry[]): Json[] {
const rows: Json[] = []
const add = (comment: CommentEntry, depth: number) => {
rows.push({
id: comment.id ?? '',
author: comment.authorDisplayName ?? comment.authorUserId ?? '',
at: String(comment.createdAt ?? '').slice(0, 16).replace('T', ' '),
state: comment.deleted ? 'deleted' : comment.resolvedAt ? 'resolved' : '',
body: `${depth ? ' > ' : ''}${String(comment.body ?? '').replace(/\s+/g, ' ').slice(0, 100)}`,
})
for (const reply of comment.replies ?? []) add(reply, depth + 1)
}
for (const thread of threads) add(thread, 0)
return rows
}
/** `favorites add person 1` is a RECORD; `favorites add person` a TABLE; --type for the rest. */
function targetOf(name: string, recordId: string | undefined, type: string | undefined): CollaborationTarget {
const targetType = (type ?? (recordId !== undefined ? 'RECORD' : 'TABLE')).toUpperCase()
if (!TARGET_TYPES.includes(targetType)) throw new UsageError(`--type must be one of ${TARGET_TYPES.join(', ')}`)
if (targetType === 'RECORD' && recordId === undefined) throw new UsageError('A RECORD favorite needs the record id: favorites add <table> <id>')
return { targetType: targetType as CollaborationTarget['targetType'], targetName: name, ...(targetType === 'RECORD' ? { recordId } : {}) }
}
export function registerCollab(noun: Command, ctx: () => Context): void {
noun.command('presence <table> [pk]')
.description('Who is on a table or record right now. --watch joins (you appear too) and prints each change until Ctrl+C; --observe watches without joining.')
.option('-w, --watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
.option('--observe', 'follow changes without joining')
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
const c = ctx()
const collaboration = await collab(c)
const scope = { table, pk }
const json = c.format === 'json' || c.format === 'yaml'
const emit = (state: PresenceState) => {
if (json) process.stdout.write(JSON.stringify({ ...state, people: state.people.map((p) => ({ ...p, since: p.since.toISOString() })) }) + '\n')
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
}
if (!options.watch && !options.observe) {
const state = await collaboration.presenceSnapshot(scope)
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: `(${state.count} on ${pk !== undefined ? `${table} ${pk}` : table}; --watch to join and follow)` })
return
}
let last = ''
const handle = await collaboration.joinPresence(scope, {
announce: Boolean(options.watch),
mode: options.editing ? 'EDITING' : 'VIEWING',
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
onChange: (state) => {
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
if (key !== last) {
last = key
emit(state)
}
},
})
if (handle.state.error) throw new Error(`presence on ${table}${pk !== undefined ? ` ${pk}` : ''}: ${handle.state.error}`)
if (!json) c.note(options.watch ? 'Joined; Ctrl+C leaves.' : 'Observing; Ctrl+C stops.')
await new Promise<void>((resolve) => {
const leave = () => {
void handle.stop().finally(resolve)
}
process.once('SIGINT', leave)
process.once('SIGTERM', leave)
})
})
const comments = noun.command('comments').description("A record's comment threads.")
comments.command('list <table> <pk>')
.description('Comment threads by latest activity, replies under their thread.')
.option('-l, --limit <n>', 'threads per page (1-50)', '20')
.option('--cursor <token>', 'continue from a previous page')
.action(async (table: string, pk: string, options: { limit: string; cursor?: string }) => {
const c = ctx()
const page = await (await collab(c)).comments(table, pk, { limit: Number(options.limit), cursor: options.cursor })
c.print({ data: page, rows: commentRows(page.threads), columns: ['id', 'author', 'at', 'state', 'body'], footer: `(${page.threads.length} threads${page.nextCursor ? `; more: --cursor ${page.nextCursor}` : ''})` })
})
comments.command('add <table> <pk> <body...>')
.description('Post a comment (mentions: @[Name](user:id)); --reply-to answers a thread.')
.option('-r, --reply-to <commentId>', 'reply to this comment')
.option('--field <name>', 'anchor the comment on a field')
.action(async (table: string, pk: string, body: string[], options: { replyTo?: string; field?: string }) => {
const c = ctx()
const comment = await (await collab(c)).addComment(table, pk, body.join(' '), { parentCommentId: options.replyTo ? Number(options.replyTo) : undefined, fieldName: options.field })
c.print({ data: comment, rows: commentRows([comment]), columns: ['id', 'author', 'at', 'state', 'body'] })
})
const favorites = noun.command('favorites').description('Your favorites (starred tables, records, apps, processes, reports, saved views).')
favorites.command('list').description('Your favorites in order.').action(async () => {
const c = ctx()
const items = await (await collab(c)).favorites()
c.print({ data: items, rows: favoriteRows(items), columns: ['id', 'type', 'name', 'record', 'label', 'folder', 'available'] })
})
favorites.command('add <name> [recordId]')
.description('Star a record (`add person 1`), a table (`add person`), or --type APP|PROCESS|REPORT|SAVED_VIEW.')
.option('-t, --type <type>', TARGET_TYPES.join('|'))
.action(async (name: string, recordId: string | undefined, options: { type?: string }) => {
const c = ctx()
const target = targetOf(name, recordId, options.type)
await (await collab(c)).addFavorite(target)
c.print({ data: { starred: target }, rows: [{ starred: `${target.targetType} ${name}${target.recordId ? ` ${target.recordId}` : ''}` }], columns: ['starred'] })
})
favorites.command('remove <name> [recordId]')
.description('Unstar a target (same arguments as add).')
.option('-t, --type <type>', TARGET_TYPES.join('|'))
.action(async (name: string, recordId: string | undefined, options: { type?: string }) => {
const c = ctx()
const target = targetOf(name, recordId, options.type)
await (await collab(c)).removeFavorite(target)
c.print({ data: { unstarred: target }, rows: [{ unstarred: `${target.targetType} ${name}${target.recordId ? ` ${target.recordId}` : ''}` }], columns: ['unstarred'] })
})
noun.command('recents')
.description('What you viewed recently, newest first.')
.option('-l, --limit <n>', 'most entries (1-50)', '20')
.action(async (options: { limit: string }) => {
const c = ctx()
const items = await (await collab(c)).recents(Number(options.limit))
c.print({ data: items, rows: favoriteRows(items), columns: ['type', 'name', 'record', 'label', 'available'] })
})
const followRows = (state: { following: boolean; follow?: Json | null }) => [{ following: state.following, notifyOnChange: state.follow?.notifyOnChange ?? '', notifyOnComment: state.follow?.notifyOnComment ?? '', reason: state.follow?.reason ?? '' }]
noun.command('follow <table> <pk>')
.description('Follow a record (notifications on changes and comments); --status only shows the state.')
.option('--status', 'show whether you follow it, change nothing')
.option('--changes <on|off>', 'notify on changes')
.option('--comments <on|off>', 'notify on comments')
.action(async (table: string, pk: string, options: { status?: boolean; changes?: string; comments?: string }) => {
const c = ctx()
const collaboration = await collab(c)
const flag = (v: string | undefined, name: string) => {
if (v === undefined) return undefined
if (!/^(on|off|true|false)$/i.test(v)) throw new UsageError(`${name} takes on or off`)
return /^(on|true)$/i.test(v)
}
const state = options.status ? await collaboration.followState(table, pk) : await collaboration.follow(table, pk, { notifyOnChange: flag(options.changes, '--changes'), notifyOnComment: flag(options.comments, '--comments') })
c.print({ data: state, rows: followRows(state as never), columns: ['following', 'notifyOnChange', 'notifyOnComment', 'reason'] })
})
noun.command('unfollow <table> <pk>')
.description('Stop following a record.')
.action(async (table: string, pk: string) => {
const c = ctx()
const state = await (await collab(c)).unfollow(table, pk)
c.print({ data: state, rows: followRows(state as never), columns: ['following', 'notifyOnChange', 'notifyOnComment', 'reason'] })
})
noun.command('my-work')
.description('Your open tasks, records assigned to you per table, and unread mentions.')
.action(async () => {
const c = ctx()
const work = await (await collab(c)).myWork()
const rows: Json[] = [
{ item: 'open tasks', value: work.openTaskCount ?? 0 },
{ item: 'unread mentions', value: work.unreadMentionCount ?? 0 },
...Object.entries(work.assignedRecordsByTable ?? {}).map(([table, n]) => ({ item: `assigned: ${table}`, value: n })),
...(work.tasks ?? []).map((t) => ({ item: `task ${t.id ?? ''}${t.dueAt ? ` due ${String(t.dueAt).slice(0, 10)}` : ''}`, value: `${t.title ?? ''}${t.tableName ? ` (${t.tableName} ${t.recordId ?? ''})` : ''}` })),
]
c.print({ data: work, rows, columns: ['item', 'value'] })
})
noun.command('people [query]')
.description('Search the people directory by name or email.')
.option('-l, --limit <n>', 'most people (1-100)', '20')
.action(async (query: string | undefined, options: { limit: string }) => {
const c = ctx()
const people = await (await collab(c)).people(query, Number(options.limit))
c.print({ data: people, rows: people.map((p) => ({ user: p.userId ?? '', name: p.displayName ?? '', email: p.email ?? '', lastSeen: String(p.lastSeenAt ?? '').slice(0, 16).replace('T', ' ') })), columns: ['user', 'name', 'email', 'lastSeen'] })
})
}
+1 -1
View File
@@ -108,7 +108,7 @@ export function registerCompletion(program: Command, ctx: () => Context): void {
return undefined
}
}
const tableInWords = () => rest.find((w) => !w.startsWith('-')) ?? before.find((w, i) => i > 0 && !w.startsWith('-') && (before[i - 1] === 'query' || before[i - 1] === 'get' || before[i - 1] === 'create' || before[i - 1] === 'update' || before[i - 1] === 'count' || before[i - 1] === 'describe' || before[i - 1] === 'delete' || before[i - 1] === 'export'))
const tableInWords = () => rest.find((w) => !w.startsWith('-')) ?? before.find((w, i) => i > 0 && !w.startsWith('-') && (before[i - 1] === 'query' || before[i - 1] === 'get' || before[i - 1] === 'create' || before[i - 1] === 'update' || before[i - 1] === 'count' || before[i - 1] === 'describe' || before[i - 1] === 'delete' || before[i - 1] === 'export' || before[i - 1] === 'children' || before[i - 1] === 'download'))
if (current.startsWith('-')) {
add(command.options.flatMap((o) => [o.long, o.short].filter(Boolean) as string[]))
add(program.options.flatMap((o) => [o.long].filter(Boolean) as string[]))
+183
View File
@@ -0,0 +1,183 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* One noun per extension (qbit or module): `qqq esb ...`, `qqq collab ...`, `qqq sql-workbench ...`.
* Every operation of an extension is a subcommand, generated from the operation index
* (`get-overview`, `put-favorites`, `get-records-comments <table> <pk>`), taking its path parameters
* as arguments or --path, plus --param and --body-json. First-class commands (esb, collab) sit next
* to the generated ones. Every subcommand checks the server's GET /version first: an extension that
* is not installed stops with exit 5 and the list of what is installed.
*/
import type { Command } from 'commander'
import { KNOWN_EXTENSIONS, OPERATIONS, operationsOf, type OperationInfo } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { runOperation, type OperationOptions } from './api.js'
import type { Json } from './shared.js'
import { registerCollab } from './collab.js'
/** The noun of each extension; the extension's own name unless it is long. */
export const NOUNS: Record<string, string> = { collaboration: 'collab' }
export function nounOf(extension: string): string {
return NOUNS[extension] ?? extension
}
function kebab(text: string): string {
return text.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/[^A-Za-z0-9]+/g, '-').toLowerCase()
}
/**
* Subcommand names for an extension's operations: the method and the literal path segments after
* the extension's prefix (`GET /qbit/collaboration/records/{table}/{pk}/comments` is
* `get-records-comments`); names that would collide get `-by-<param>`, then the operationId.
*/
export function subcommandNames(extension: string, pathPrefix: string, operations: OperationInfo[]): Map<string, OperationInfo> {
const base = `/qqq/v1${pathPrefix}`
const first = operations.map((op) => {
const segments = op.path.startsWith(base) ? op.path.slice(base.length).split('/').filter(Boolean) : op.path.split('/').filter(Boolean)
const literals = segments.filter((s) => !s.startsWith('{'))
return { op, name: `${op.method.toLowerCase()}-${literals.length ? literals.map(kebab).join('-') : extension}` }
})
const count = (name: string, list: { name: string }[]) => list.filter((x) => x.name === name).length
const second = first.map((x) => (count(x.name, first) > 1 && x.op.pathParams.length ? { op: x.op, name: `${x.name}-by-${x.op.pathParams.map(kebab).join('-')}` } : x))
const names = new Map<string, OperationInfo>()
for (const x of second) names.set(count(x.name, second) > 1 ? kebab(x.op.id) : x.name, x.op)
return names
}
/** Adds one generated subcommand per operation of an extension to its noun. */
export function addOperationCommands(noun: Command, extension: string, pathPrefix: string, ctx: () => Context): void {
const taken = new Set(noun.commands.map((c) => c.name()))
for (const [name, op] of subcommandNames(extension, pathPrefix, operationsOf(extension))) {
if (taken.has(name)) continue
const args = op.pathParams.map((p) => ` [${p}]`).join('')
const cmd = noun.command(`${name}${args}`)
.description(`${op.summary || op.id} (${op.method} ${op.path.replace('/qqq/v1', '')}; operation ${op.id})`)
.option('-p, --path <name=value...>', 'path parameters (or give them as arguments)')
.option('-q, --param <name=value...>', `query parameters${op.queryParams.length ? `: ${op.queryParams.join(', ')}` : ''}`)
if (op.bodyTypes.includes('application/json') || op.bodyTypes.length === 0) cmd.option('-b, --body-json <json|file|->', 'JSON request body (inline, a file, or - for stdin)')
if (op.bodyTypes.includes('multipart/form-data')) cmd.option('-F, --form <name=value|name=@file...>', 'multipart form fields; @file uploads a file')
cmd.option('-o, --out <file>', 'write a binary response to a file')
cmd.action(async (...actionArgs: unknown[]) => {
const values = actionArgs.slice(0, op.pathParams.length) as (string | undefined)[]
const options = actionArgs[op.pathParams.length] as OperationOptions
const pathValues: Record<string, string> = {}
op.pathParams.forEach((p, i) => {
if (values[i] !== undefined) pathValues[p] = values[i]!
})
await runOperation(ctx(), op, options, pathValues)
})
}
}
/** Requires the extension on the target server (exit 5 with the installed list when it is absent). */
export async function requireExtension(c: Context, name: string): Promise<void> {
;(await c.extensions()).require(name)
}
function counters(destination: Json | undefined): Json {
const c = (destination?.counters ?? {}) as Json
return { published: c.published ?? '', consumed: c.consumed ?? '', failed: c.failed ?? '', deadLettered: c.deadLettered ?? '', inFlight: c.inFlight ?? '', lastActivity: String(c.lastActivity ?? '').slice(0, 19) }
}
function registerEsb(noun: Command, ctx: () => Context): void {
const run = async (id: string, path: Record<string, string> = {}, query: Record<string, string | undefined> = {}) => {
const c = ctx()
await requireExtension(c, 'esb')
return { c, data: (await c.call(id, { path, query })).data as Json }
}
const columns = ['role', 'destination', 'type', 'detail', 'state', 'published', 'consumed', 'failed', 'deadLettered', 'inFlight', 'lastActivity']
const linkRows = (data: Json) => [
...((data.publications ?? []) as Json[]).map((p) => {
const d = p.destination as Json | undefined
return { role: 'publishes', destination: d?.name ?? '', type: d?.type ?? '', detail: ((p.events ?? []) as string[]).join(' '), state: '', ...counters(d) }
}),
...((data.subscribers ?? data.triggers ?? []) as Json[]).map((s) => {
const d = s.destination as Json | undefined
return { role: 'trigger', destination: d?.name ?? '', type: d?.type ?? '', detail: s.processLabel ?? s.processName ?? s.name ?? '', state: s.state ?? '', ...counters(d) }
}),
]
noun.command('overview')
.description('Broker connections and every destination with its publishers and triggers (needs ESB permission).')
.action(async () => {
const { c, data } = await run('getEsbOverview')
const rows = ((data.destinations ?? []) as Json[]).map((d) => ({ destination: d.name, type: d.type, provider: d.provider, publishers: ((d.publishers ?? []) as unknown[]).length, triggers: ((d.triggers ?? []) as unknown[]).length, ...counters(d) }))
c.print({ data, rows, columns: ['destination', 'type', 'provider', 'publishers', 'triggers', 'published', 'consumed', 'failed', 'deadLettered', 'lastActivity'], footer: `(${((data.providers ?? []) as unknown[]).length} providers, ${rows.length} destinations)` })
})
noun.command('table <table>')
.description('What a table publishes and which processes it triggers.')
.action(async (table: string) => {
const { c, data } = await run('getEsbTableByTable', { table })
c.print({ data, rows: linkRows(data), columns })
})
noun.command('process <process>')
.description('What a process publishes and what triggers it.')
.action(async (process: string) => {
const { c, data } = await run('getEsbProcessByProcess', { process })
c.print({ data, rows: linkRows(data), columns })
})
const messageRows = (data: Json) => ((data.messages ?? []) as Json[]).map((m) => ({ messageId: m.messageId, timestamp: String(m.timestamp ?? '').slice(0, 19), deliveries: m.deliveryCount ?? '', event: m.event ?? '', body: String(m.rawBody ?? '').slice(0, 80) }))
noun.command('dead-letters <trigger>')
.description("A trigger's dead letters.")
.option('-l, --limit <n>', 'most messages', '20')
.option('--offset <n>', 'skip this many')
.action(async (trigger: string, options: { limit: string; offset?: string }) => {
const { c, data } = await run('getEsbDeadLettersByTrigger', { trigger }, { limit: options.limit, offset: options.offset })
const rows = messageRows(data)
c.print({ data, rows, columns: ['messageId', 'timestamp', 'deliveries', 'event', 'body'], footer: `(${rows.length} dead letters${data.hasMore ? ', more with --offset' : ''})` })
})
noun.command('messages <destination>')
.description('Messages waiting on a destination (a topic needs --trigger: its messages are kept per trigger).')
.option('-t, --trigger <name>', 'the trigger (subscription) of a topic')
.option('-l, --limit <n>', 'most messages', '20')
.option('--offset <n>', 'skip this many')
.action(async (destination: string, options: { trigger?: string; limit: string; offset?: string }) => {
const { c, data } = await run('getEsbMessagesByDestination', { destination }, { trigger: options.trigger, limit: options.limit, offset: options.offset })
const rows = messageRows(data)
c.print({ data, rows, columns: ['messageId', 'timestamp', 'deliveries', 'event', 'body'], footer: `(${rows.length} messages${data.hasMore ? ', more with --offset' : ''})` })
})
}
export function registerExtensions(program: Command, ctx: () => Context): void {
program.command('extensions')
.description('Extensions (qbits) this client knows and which ones the server has installed.')
.action(async () => {
const c = ctx()
const registry = await c.extensions()
const rows: Json[] = KNOWN_EXTENSIONS.map((e) => {
const installed = registry.installed.find((i) => i.name === e.name)
return {
extension: e.name,
noun: `qqq ${nounOf(e.name)}`,
installed: installed ? installed.version ?? 'yes' : '',
status: !installed ? 'not installed' : installed.meetsMinimum ? 'ok' : `needs ${e.minVersion}+`,
client: e.documentVersion ?? '',
minimum: e.minVersion ?? '',
operations: operationsOf(e.name).length,
channels: e.channels.join(' '),
}
})
for (const u of registry.unknown()) rows.push({ extension: u.name, noun: '', installed: u.version ?? 'yes', status: 'unknown to this client', client: '', minimum: '', operations: 0, channels: '' })
c.print({ data: { installed: registry.installed, known: KNOWN_EXTENSIONS }, rows, columns: ['extension', 'noun', 'installed', 'status', 'client', 'minimum', 'operations', 'channels'], footer: `(installed: ${registry.describe()}; ${OPERATIONS.filter((o) => o.extension).length} extension operations in this client)` })
})
for (const e of KNOWN_EXTENSIONS) {
const name = nounOf(e.name)
const noun = program.command(name).description(`${e.label} (${e.name} extension${e.minVersion ? ` ${e.minVersion}+` : ''}, ${e.repository}): ${e.description || 'operations'}`)
if (name !== e.name) noun.alias(e.name)
if (e.name === 'esb') registerEsb(noun, ctx)
if (e.name === 'collaboration') registerCollab(noun, ctx)
addOperationCommands(noun, e.name, e.pathPrefix, ctx)
noun.addHelpText('after', `\nEvery ${e.name} operation is a subcommand; path parameters go as arguments or --path name=value.\nNot installed on the server: exit 5 with the installed list (see \`qqq extensions\`).`)
}
}
+12 -18
View File
@@ -3,15 +3,16 @@
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync } from 'node:fs'
import { basename, join } from 'node:path'
import type { Command } from 'commander'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { BACK, displayDateTime, formatValue, alternativeNames, safeDownloadName, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpText, isMissingValue, processHelpText, runProcess, screenToText, summaryLines, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core'
import { BACK, displayDateTime, formatValue, FormInputError, FormSession, ProcessCancelledError, ProcessError, blocksToText, checkInputRecords, downloadProcessFile, formatTable, helpText, isMissingValue, processHelpText, runProcess, safeFileName, saveDownload, screenToText, summaryLines, variantParam, type FieldMeta, type ProcessMeta, type RunOutcome, type ScreenBlock } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { ask, promptForm } from '../prompt.js'
import { authenticatedFetch } from '../session.js'
import { parsePairs, readJson, type Json } from './shared.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
async function processMeta(c: Context, name: string): Promise<Json> {
return await c.unwrap(c.client.GET('/qqq/v1/metaData/process/{processName}', { params: { path: { processName: name } } })) as Json
@@ -69,9 +70,15 @@ export function registerProcess(program: Command, ctx: () => Context): void {
.option('--out-dir <dir>', 'where files a step offers for download are saved', '.')
.option('--record-limit <n>', 'most records a record list shows', '100')
.option('--quiet', 'no progress or intermediate screens on stderr')
.option(...VARIANT_OPTION)
.action(async (name: string, options: RunOptions) => {
const c = ctx()
const meta = await processMeta(c, name) as unknown as ProcessMeta
// a process on a variant table runs with the variant on init and every step (as the web UI does)
const tableVariant = meta.tableName && (options.variant !== undefined || ((await c.metaData() as unknown as Json).tables as Record<string, Json> | undefined)?.[meta.tableName])
? variantParam(await chooseVariant(c, await c.tableMeta<Json>(meta.tableName), options.variant))
: undefined
if (options.variant !== undefined && !meta.tableName) throw new UsageError(`${meta.label ?? name} has no table, so it takes no --variant`)
const given: Record<string, unknown> = { ...(options.valuesJson ? readJson(options.valuesJson, '--values-json') as Json : {}), ...parsePairs(options.values, '--values') }
if (options.validate && options.skipValidation) throw new UsageError('--validate and --skip-validation exclude each other.')
if (options.validate) given.doFullValidation = 'true'
@@ -95,7 +102,7 @@ export function registerProcess(program: Command, ctx: () => Context): void {
const file = await downloadProcessFile(c.server, fetchImpl, block)
mkdirSync(options.outDir, { recursive: true })
// the name comes from the server: last segment only, and never overwrite an existing file
const path = writeNew(options.outDir, safeDownloadName(block.fileName), file.bytes)
const path = saveDownload(join(options.outDir, safeFileName(block.fileName)), file.bytes, { explicit: false })
saved.push(path)
c.note(`saved ${path} (${file.bytes.length} bytes${file.contentType ? `, ${file.contentType}` : ''})`)
}
@@ -120,6 +127,7 @@ export function registerProcess(program: Command, ctx: () => Context): void {
records,
files: Object.fromEntries(Object.entries(parsePairs(options.file, '--file')).map(([field, path]) => [field, { name: basename(path), bytes: new Uint8Array(readFileSync(path)) }])),
stepTimeoutMillis: Number(options.timeout),
tableVariant,
always: Boolean(options.interactive),
signal: abort.signal,
screen: { recordLimit: Number(options.recordLimit), format: (field, value, shown) => (field.type === 'DATE_TIME' ? displayDateTime(value, shown, c.dateTimeDisplay) : formatValue(field, value)) },
@@ -199,21 +207,7 @@ export function registerProcess(program: Command, ctx: () => Context): void {
})
}
interface RunOptions { values?: string[]; valuesJson?: string; records?: string; filterJson?: string; file?: string[]; interactive?: boolean; prompt: boolean; timeout: string; skipValidation?: boolean; validate?: boolean; outDir: string; recordLimit: string; quiet?: boolean }
/** Writes bytes to a new file in `dir` (flag wx), taking `name-1.ext`, `name-2.ext`, … when the name is taken. */
function writeNew(dir: string, name: string, bytes: Uint8Array): string {
for (const candidate of alternativeNames(name)) {
const path = join(dir, candidate)
try {
writeFileSync(path, bytes, { flag: 'wx' })
return path
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
}
throw new UsageError(`no free file name for ${name} in ${dir}`)
}
interface RunOptions { values?: string[]; valuesJson?: string; records?: string; filterJson?: string; file?: string[]; interactive?: boolean; prompt: boolean; timeout: string; skipValidation?: boolean; validate?: boolean; outDir: string; recordLimit: string; quiet?: boolean; variant?: string }
function outcomeStepLabel(meta: ProcessMeta, stepName: string): string {
return meta.frontendSteps?.find((s) => s.name === stepName)?.label ?? stepName
+220
View File
@@ -0,0 +1,220 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The record view and related records: `table get` (sections, adornments, help, associations),
* `table children` (a page of one child list, with its count) and `table download` (a stored
* file). The models are the kernel's (records/associations, records/record-view).
*/
import type { Command } from 'commander'
import {
associationGroups, associationLabel, associationsOf, childListFields, fetchChildren, fileDownload, safeFileName, saveDownload, findAssociation, recordView, relationshipValues, tableViewHelp,
defaultJoinColumns, withJoinFields, displayDateTime, fieldLabel, type DisplayOptions, type ExposedJoin, type RecordLike, type TableMetaLike, type WidgetMetaLike,
} from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch } from '../session.js'
import { valueText, type Json } from './shared.js'
/** Reads one record (`{ record }` or a bare record). */
export async function getRecord(c: Context, table: string, primaryKey: string, associations: boolean, query: Record<string, string | undefined> = {}): Promise<Json> {
const result = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: table, primaryKey }, query: { includeAssociations: associations ? true : undefined, ...query } as never } })) as Json
return (result.record ?? result) as Json
}
/** Table metadata for the record's table and every table its associations reach (missing ones stay undefined: no access). */
async function associationTables(c: Context, root: TableMetaLike): Promise<Record<string, TableMetaLike | undefined>> {
const tables: Record<string, TableMetaLike | undefined> = { [root.name]: root }
const known = new Set(Object.keys((await c.metaData()).tables ?? {}))
const queue = [root]
while (queue.length) {
const table = queue.shift()!
for (const association of associationsOf(table)) {
const name = association.associatedTableName
if (name in tables) continue
tables[name] = undefined
if (!known.has(name)) continue
try {
const meta = await c.tableMeta<TableMetaLike>(name)
tables[name] = meta
queue.push(meta)
} catch {
// no access to the child table: shown as unavailable
}
}
}
return tables
}
/** Prints a record: its sections, then links, help and child lists as asked. */
export async function showRecord(c: Context, table: string, primaryKey: string, options: { associations?: boolean; links?: boolean; reveal?: boolean; helpContent?: boolean; query?: Record<string, string | undefined>; joined?: ExposedJoin[] }): Promise<void> {
const meta = await c.tableMeta<TableMetaLike>(table)
const metaData = await c.metaData()
const record = await getRecord(c, table, primaryKey, Boolean(options.associations), options.query) as unknown as RecordLike
const widgets = (metaData.widgets ?? {}) as Record<string, WidgetMetaLike>
const tableNames = Object.keys(metaData.tables ?? {})
const sections = recordView(meta, record, { reveal: options.reveal, tableNames, widgets, helpContent: options.helpContent, tableName: table, primaryKey, empty: c.format === 'table' ? '--' : '', dateTime: c.dateTimeDisplay })
const rows: Json[] = []
for (const section of sections) {
if (options.helpContent && section.help) rows.push({ section: section.label, field: '(help)', value: section.help })
for (const { field, value, help } of section.fields) {
rows.push({ section: section.label, field: field.label, value: value.text })
if (help) rows.push({ section: section.label, field: '', value: `help: ${help}` })
}
}
// fields of joined tables (--join), one section per join
for (const join of options.joined ?? []) {
for (const name of defaultJoinColumns(join, 1000)) rows.push({ section: join.label, field: fieldLabel(meta as unknown as Json, options.joined!, name), value: valueText(record as unknown as Json, name, withJoinFields(meta as unknown as Json, options.joined!), c.dateTimeDisplay) })
}
const links = sections.flatMap((s) => s.fields).filter((x) => x.value.link && 'tableName' in x.value.link)
const structured = c.format === 'json' || c.format === 'yaml'
const footer = [String(record.recordLabel ?? `${meta.label} ${primaryKey}`)]
if (links.length && !options.links) footer.push(`links: ${links.map((x) => x.field.name).join(', ')} (--links lists them, --follow <field> opens one)`)
const masked = sections.flatMap((s) => s.fields).filter((x) => x.value.masked)
if (masked.length) footer.push(`masked: ${masked.map((x) => x.field.name).join(', ')} (--reveal shows them)`)
if (!options.associations && associationsOf(meta).length) footer.push(`associations: ${associationsOf(meta).map((a) => a.name).join(', ')} (--associations lists the child records)`)
c.print({ data: record, rows, columns: ['section', 'field', 'value'], headers: ['Section', 'Field', 'Value'], footer: footer.join('\n') })
if (structured) return
if (options.helpContent) {
const help = tableViewHelp(meta)
if (help.length) c.print({ data: help, rows: help.map((h) => ({ slot: h.slot, help: h.text })), columns: ['slot', 'help'], headers: ['Table help', ''] })
}
if (options.links && links.length) {
c.print({
data: links,
rows: links.map(({ field, value }) => {
const link = value.link as { tableName: string; primaryKey: string }
return { field: field.name, label: field.label, record: String(record.displayValues?.[field.name] ?? record.values[field.name] ?? ''), table: link.tableName, key: link.primaryKey, open: `qqq table get ${table} ${primaryKey} --follow ${field.name}` }
}),
columns: ['field', 'label', 'record', 'table', 'key', 'open'],
headers: ['Field', 'Label', 'Record', 'Table', 'Key', 'Open with'],
})
}
if (options.associations) {
const tables = await associationTables(c, meta)
for (const group of associationGroups(record, meta, tables, { widgets })) {
const indent = ' '.repeat(group.depth - 1)
const title = group.depth === 1 ? `${group.title} [${group.association.name}]` : group.title
if (group.unavailable) {
process.stdout.write(`\n${indent}${title}: unavailable (${group.unavailable})\n`)
continue
}
// nested lists only when they have records; the record's own lists always (an empty one says none)
if (group.depth > 1 && !group.records.length) continue
const childTable = tables[group.table]!
const parentTable = tables[group.parentTable]!
const relationship = relationshipValues(group.association, parentTable, group.parent, childTable)
const binding = 'error' in relationship ? [] : Object.keys(relationship.values)
const fields = childListFields(childTable, binding)
const childRows = group.records.map((child) => Object.fromEntries(fields.map((f) => [f, valueOf(child, f, childTable, c.dateTimeDisplay)])))
process.stdout.write(`\n${indent}${title} (${group.records.length})\n`)
if (childRows.length) c.print({ data: group.records, rows: childRows, columns: fields, headers: fields.map((f) => childTable.fields[f]?.label ?? f) })
else process.stdout.write(`${indent} none\n`)
}
}
}
function valueOf(record: RecordLike, field: string, table?: TableMetaLike, display?: DisplayOptions): string {
if (table?.fields[field]?.type === 'DATE_TIME') return displayDateTime(record.values[field], record.displayValues?.[field], display)
const value = record.displayValues?.[field] ?? record.values[field]
return value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
}
export function registerRelated(program: Command, ctx: () => Context): void {
const table = program.commands.find((x) => x.name() === 'table')!
table.command('children <table> <primaryKey> [association]')
.description("Page through one child list of a record (an association: name or child table), with the total count; without an association, lists the record's associations and their counts.")
.option('-l, --limit <n>', 'children per page', '25')
.option('--skip <n>', 'children to skip', '0')
.option('--all', 'every child (pages of --limit)')
.action(async (name: string, primaryKey: string, associationName: string | undefined, options: { limit: string; skip: string; all?: boolean }) => {
const c = ctx()
const meta = await c.tableMeta<TableMetaLike>(name)
const all = associationsOf(meta)
if (!all.length) throw new UsageError(`${meta.label ?? name} has no associations (child lists).`)
const parent = await getRecord(c, name, primaryKey, false) as unknown as RecordLike
const widgets = ((await c.metaData()).widgets ?? {}) as Record<string, WidgetMetaLike>
const fetchImpl = authenticatedFetch(c.target)
if (!associationName) {
const rows: Json[] = []
for (const association of all) {
let childTable: TableMetaLike | undefined
let count: number | string = ''
try {
childTable = await c.tableMeta<TableMetaLike>(association.associatedTableName)
count = (await fetchChildren({ baseUrl: c.server, fetch: fetchImpl, parentTable: meta, parent, childTable, association, limit: 0 })).count ?? ''
} catch (error) {
count = `unavailable: ${error instanceof Error ? error.message : String(error)}`
}
rows.push({ association: association.name, label: associationLabel(meta, association, { widgets, childTable }), table: association.associatedTableName, count })
}
c.print({ data: rows, columns: ['association', 'label', 'table', 'count'], footer: `qqq table children ${name} ${primaryKey} <association> pages one list` })
return
}
const association = findAssociation(meta, associationName)
if (!association) throw new UsageError(`No association "${associationName}" on ${meta.label ?? name}; associations: ${all.map((a) => `${a.name} (${a.associatedTableName})`).join(', ')}`)
const childTable = await c.tableMeta<TableMetaLike>(association.associatedTableName)
const limit = Number(options.limit)
let skip = Number(options.skip)
if (!Number.isInteger(limit) || limit < 1 || !Number.isInteger(skip) || skip < 0) throw new UsageError('--limit must be a positive integer and --skip a non-negative one.')
const records: RecordLike[] = []
let count: number | undefined
for (;;) {
const page = await fetchChildren({ baseUrl: c.server, fetch: fetchImpl, parentTable: meta, parent, childTable, association, limit, skip, count: count === undefined })
count ??= page.count
records.push(...page.records)
if (!options.all || page.records.length < limit) break
skip += limit
}
const relationship = relationshipValues(association, meta, parent, childTable)
const fields = childListFields(childTable, 'error' in relationship ? [] : Object.keys(relationship.values), 8)
const first = Number(options.skip)
const label = associationLabel(meta, association, { widgets, childTable })
c.print({
data: { association: association.name, table: childTable.name, count, skip: first, records },
rows: records.map((r) => Object.fromEntries(fields.map((f) => [f, valueOf(r, f, childTable, c.dateTimeDisplay)]))),
columns: fields,
headers: fields.map((f) => childTable.fields[f]?.label ?? f),
footer: `${label} of ${parent.recordLabel ?? `${meta.label} ${primaryKey}`}: ${records.length ? `${first + 1}-${first + records.length}` : 'none'} of ${count ?? '?'}${!options.all && count !== undefined && first + records.length < count ? `; next: --skip ${first + records.length}` : ''}`,
})
})
table.command('download <table> <primaryKey> <field>')
.description('Save a file stored in a record field (FILE_DOWNLOAD through GET /table/{t}/{pk}/{field}/{file}, or a BLOB value).')
.option('-o, --out <file>', 'where to write, replacing an existing file (default: the file name, name-1.ext when taken; - for stdout)')
.action(async (name: string, primaryKey: string, fieldName: string, options: { out?: string }) => {
const c = ctx()
const meta = await c.tableMeta<TableMetaLike>(name)
const field = meta.fields[fieldName]
if (!field) throw new UsageError(`No field "${fieldName}" in ${name}`)
const record = await getRecord(c, name, primaryKey, false) as unknown as RecordLike
const file = fileDownload(field, record)
let bytes: Uint8Array
let fileName: string
if (file?.route) {
const result = await c.call('downloadRecordFieldFile', { path: file.route })
bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
fileName = safeFileName(file.fileName)
} else if (file?.url) {
c.print({ data: file, rows: [{ file: file.fileName, url: file.url }], columns: ['file', 'url'], footer: 'The file is outside the QQQ server; download it from the URL.' })
return
} else {
const raw = record.values[fieldName]
if (raw === null || raw === undefined || raw === '') throw new UsageError(`${meta.label} ${primaryKey} has no file in ${field.label}.`)
if (typeof raw !== 'string' || field.type !== 'BLOB') throw new UsageError(`${field.label} is not a file field (no FILE_DOWNLOAD adornment, not a BLOB).`)
bytes = new Uint8Array(Buffer.from(raw, 'base64'))
fileName = safeFileName(`${name}-${primaryKey}-${fieldName}`)
}
const out = options.out ?? fileName
if (out === '-') {
process.stdout.write(bytes)
return
}
const written = saveDownload(out, bytes, { explicit: options.out !== undefined })
c.note(`wrote ${written} (${bytes.length} bytes)`)
})
}
+149
View File
@@ -0,0 +1,149 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import type { Command } from 'commander'
import { decodeJwtClaims } from '@qrun-io/qqq-ui-api-client'
import { SavedViewError, buildFilter, buildViewJson, callOperation, deleteSavedView, exposedJoins, findSavedView, joinFieldNames, listSavedViews, printFilter, resolveField, savedViewProcesses, storeSavedView, variantBody, viewToQuery, type QueryFilter, type SavedView, type SavedViewProcesses } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { authenticatedFetch, currentToken, getLogin } from '../session.js'
import { defaultFields, readJson, type Json } from './shared.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
/** The signed-in user's id as saved views record owners (email, else username or subject). */
async function currentUserId(c: Context): Promise<string | undefined> {
const t = c.target
const token = await currentToken(t)
const claims = token && token.includes('.') ? decodeJwtClaims(token) as Json : {}
const login = await getLogin(t)
const user = login?.kind === 'session' ? login.user ?? {} : {}
const id = claims.email ?? user.email ?? user.idReference ?? user.username ?? claims.preferred_username ?? claims.sub
return id === undefined || id === null || id === '' ? undefined : String(id)
}
async function context(c: Context, table: string): Promise<{ meta: Json; processes: SavedViewProcesses; fetchImpl: typeof fetch }> {
const meta = await c.tableMeta<Json>(table)
const processes = savedViewProcesses(meta, await c.metaData() as unknown as Json)
if (!processes.query) throw new UsageError(`${meta.label} has no saved views on this server (clientCapabilities.savedViews is unavailable).`)
return { meta, processes, fetchImpl: authenticatedFetch(c.target) }
}
async function guard<T>(work: () => Promise<T>): Promise<T> {
try {
return await work()
} catch (error) {
if (error instanceof SavedViewError) throw new UsageError(error.message)
throw error
}
}
function describeFilter(view: SavedView): string {
try {
const filter = view.view.queryFilter as QueryFilter
return filter.criteria?.length || filter.subFilters?.length ? printFilter({ criteria: filter.criteria ?? [], booleanOperator: filter.booleanOperator ?? 'AND', subFilters: filter.subFilters }) : ''
} catch {
return '(unreadable filter)'
}
}
export function registerSavedView(program: Command, ctx: () => Context): void {
const sv = program.command('saved-view').description('Saved views (named query screens kept by the server): list, open, save, rename, delete.')
sv.command('list <table>')
.description("A table's saved views: yours and those shared with you, with quick views and their counts.")
.option('--counts', 'count the records of every view (quick views with doCount are always counted)')
.option(...VARIANT_OPTION)
.action(async (table: string, options: { counts?: boolean; variant?: string }) => {
const c = ctx()
const { meta, processes, fetchImpl } = await context(c, table)
const me = await currentUserId(c)
const views = await guard(() => listSavedViews(c.server, fetchImpl, processes, table))
const joins = exposedJoins(meta)
const counting = options.counts || views.some((v) => v.type === 'quickView' && v.doCount)
const variant = counting ? await chooseVariant(c, meta, options.variant) : undefined
const rows = await Promise.all(views.map(async (v) => {
let count: unknown = ''
if (options.counts || (v.type === 'quickView' && v.doCount)) {
const { filter } = viewToQuery(meta, joins, v.view)
const { orderBys: _o, ...criteria } = filter
void _o
try {
count = ((await callOperation(c.server, 'countTable', { path: { tableName: table }, json: { filter: criteria, ...variantBody(variant) } }, fetchImpl)).data as Json).count ?? ''
} catch {
count = '?'
}
}
return { id: v.id, label: v.label, owner: me && v.userId === me ? 'you' : v.userId ? `shared by ${v.userId}` : '', type: v.type ?? '', filter: describeFilter(v), count }
}))
const mine = rows.filter((r) => r.owner === 'you')
const ordered = [...mine, ...rows.filter((r) => r.owner !== 'you')]
c.print({ data: views, rows: ordered, columns: ['id', 'label', 'owner', 'type', 'filter', 'count'], footer: `(${views.length} view${views.length === 1 ? '' : 's'}${processes.store ? '' : '; saving is unavailable'})` })
})
sv.command('open <table> <view>')
.description('Run a saved view: its filter, sort, columns and page size (the same as `table query <table> --view <view>`; query options are passed on).')
.allowUnknownOption()
.allowExcessArguments()
.action(async (table: string, view: string, _options: unknown, command: Command) => {
const query = program.commands.find((x) => x.name() === 'table')!.commands.find((x) => x.name() === 'query')!
await query.parseAsync([table, '--view', view, ...command.args.slice(2)], { from: 'user' })
})
sv.command('save <table>')
.description('Save a view from criteria, sort, columns and page size. A name you already use is refused unless --replace.')
.requiredOption('-n, --name <name>', 'the view name')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('-o, --order <field...>', 'sort; prefix - for descending')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON instead')
.option('-f, --fields <names>', 'comma-separated columns to show (default: the table\'s default columns)')
.option('-l, --page-size <n>', 'rows per page', '25')
.option('--replace', 'update your view of that name instead of refusing')
.action(async (table: string, options: { name: string; where?: string[]; group?: string[]; or?: boolean; order?: string[]; filterJson?: string; fields?: string; pageSize: string; replace?: boolean }) => {
const c = ctx()
const { meta, processes, fetchImpl } = await context(c, table)
const filter = options.filterJson ? readJson(options.filterJson, '--filter-json') as QueryFilter
: buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: [...Object.keys((meta.fields ?? {}) as Json), ...joinFieldNames(exposedJoins(meta))] })
const columns = c.fields ?? (options.fields ? options.fields.split(',').map((f) => f.trim()).filter(Boolean) : defaultFields(meta))
const unknown = columns.filter((f) => !resolveField(meta, exposedJoins(meta), f))
if (unknown.length) throw new UsageError(`No field ${unknown.join(', ')} in ${table}`)
const views = await guard(() => listSavedViews(c.server, fetchImpl, processes, table))
const me = await currentUserId(c)
const same = views.filter((v) => v.label.toLowerCase() === options.name.toLowerCase())
if (same.length && !options.replace) throw new UsageError(`A view named "${options.name}" exists (id ${same.map((v) => v.id).join(', ')}); nothing was saved. --replace updates it.`)
const target = same.find((v) => !me || !v.userId || v.userId === me) ?? same[0]
const allColumns = Object.keys((meta.fields ?? {}) as Json).filter((n) => !((meta.fields as Record<string, Json>)[n]!.isHidden))
const view = buildViewJson({ filter, columns, allColumns, rowsPerPage: Number(options.pageSize), base: target?.view })
const stored = await guard(() => storeSavedView(c.server, fetchImpl, processes, { table, label: options.name, view, ...(target ? { id: target.id } : {}) }))
c.print({ data: stored, rows: [{ [target ? 'updated' : 'saved']: stored.label, id: stored.id }], columns: [target ? 'updated' : 'saved', 'id'] })
})
sv.command('rename <table> <view> <newName>')
.description('Rename a view (yours; the server refuses others\').')
.action(async (table: string, idOrName: string, newName: string) => {
const c = ctx()
const { processes, fetchImpl } = await context(c, table)
const views = await guard(() => listSavedViews(c.server, fetchImpl, processes, table))
const view = await guard(async () => findSavedView(views, idOrName))
const clash = views.find((v) => v.id !== view.id && v.label.toLowerCase() === newName.toLowerCase())
if (clash) throw new UsageError(`A view named "${newName}" exists (id ${clash.id}); nothing was renamed.`)
const stored = await guard(() => storeSavedView(c.server, fetchImpl, processes, { table, label: newName, view: view.view, id: view.id }))
c.print({ data: stored, rows: [{ renamed: `${view.label} -> ${stored.label}`, id: stored.id }], columns: ['renamed', 'id'] })
})
sv.command('delete <table> <view>')
.description('Delete a view (asks first; --yes skips the question).')
.action(async (table: string, idOrName: string) => {
const c = ctx()
const { processes, fetchImpl } = await context(c, table)
const view = await guard(async () => findSavedView(await listSavedViews(c.server, fetchImpl, processes, table), idOrName))
if (!await c.confirm(`Delete the view "${view.label}" (${view.id})?`)) {
c.note('Not deleted.')
return
}
await guard(() => deleteSavedView(c.server, fetchImpl, processes, view.id))
c.print({ data: { deleted: view.id }, rows: [{ deleted: view.label, id: view.id }], columns: ['deleted', 'id'] })
})
}
+285 -60
View File
@@ -3,37 +3,51 @@
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { writeFileSync } from 'node:fs'
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import type { Command } from 'commander'
import { FormInputError, FormSession, buildFilter, combineWithQuickSearch, copyRecordValues, filterDateTimesToUtc, findRecordByKey, gotoKeys, isGotoKey, printFilter, quickSearchFields, quickSearchFilter, type FieldMeta, type QueryFilter } from '@qrun-io/qqq-core'
import { withJoinFields, AssociationError, associationJoin, buildFilter, columnStatsAvailability, combineWithQuickSearch, computeColumnStats, copyRecordValues, defaultJoinColumns, exposedJoins, fieldLabel, filterDateTimesToUtc, findRecordByKey, findSavedView, FormInputError, FormSession, gotoKeys, isGotoKey, JoinError, joinFieldNames, listSavedViews, listTableVariants, parentLinkValues, planJoins, printFilter, quickSearchFields, quickSearchFilter, relatedRecordTarget, resolveField, runColumnStats, safeFileName, saveDownload, SavedViewError, savedViewProcesses, variantBody, variantParam, viewToQuery, type ExposedJoin, type FieldMeta, type QueryFilter, type QueryJoin, type RecordLike, type TableMetaLike } from '@qrun-io/qqq-core'
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
import { promptForm } from '../prompt.js'
import { authenticatedFetch } from '../session.js'
import { UsageError, type Context } from '../context.js'
import { defaultFields, labelsFor, parsePairs, readJson, valueText, type Json } from './shared.js'
import { defaultFields, parsePairs, readJson, valueText, type Json } from './shared.js'
import { getRecord, showRecord } from './related.js'
import { chooseVariant, VARIANT_OPTION } from './variants.js'
interface QueryOptions { where?: string[]; group?: string[]; or?: boolean; order?: string[]; limit: string; skip?: string; all?: boolean; pageSize: string; fields?: string; filterJson?: string; printFilter?: boolean; count?: boolean; search?: string }
interface QueryOptions { where?: string[]; group?: string[]; or?: boolean; order?: string[]; limit?: string; skip?: string; all?: boolean; pageSize: string; fields?: string; filterJson?: string; printFilter?: boolean; count?: boolean; search?: string; variant?: string; join?: string[]; select?: boolean; view?: string }
/** The formats the export route writes. */
const EXPORT_FORMATS = ['csv', 'xlsx', 'json', 'tsv']
/** A field of the table, or `joinTable.field` of one of its exposed joins. */
function isKnownField(meta: Json, name: string): boolean {
if ((meta.fields as Json | undefined)?.[name]) return true
const at = name.indexOf('.')
if (at < 1) return false
const joinTable = name.slice(0, at)
return ((meta.exposedJoins ?? []) as Json[]).some((j) => (j.joinTable as Json | undefined)?.name === joinTable && Boolean(((j.joinTable as Json).fields as Json | undefined)?.[name.slice(at + 1)]))
/**
* The joins a command may use: the table's exposed joins, plus, when a join is asked for that is not
* exposed, the table's associations (each joined table's metadata fetched).
*/
async function availableJoins(c: Context, meta: Json, options: { join?: string[]; where?: string[]; group?: string[]; order?: string[]; filterJson?: string }, columns?: string[]): Promise<ExposedJoin[]> {
const exposed = exposedJoins(meta, (await c.metaData() as unknown as Json).tables as Record<string, Json> | undefined)
const text = [...(options.join ?? []), ...(columns ?? []), ...(options.where ?? []), ...(options.group ?? []), ...(options.order ?? []), options.filterJson ?? ''].join(' ')
const wanted = (meta.associations ?? []) as Json[]
const extra: ExposedJoin[] = []
for (const association of wanted) {
const table = String(association.associatedTableName ?? '')
if (!table || exposed.some((j) => j.table === table) || extra.some((j) => j.table === table)) continue
if (!(options.join ?? []).includes(table) && !text.includes(`${table}.`)) continue
const join = associationJoin(association, await c.tableMeta<Json>(table))
if (join) extra.push(join)
}
return [...exposed, ...extra]
}
async function tableMeta(c: Context, table: string): Promise<Json> {
return c.tableMeta<Json>(table)
}
function filterFrom(options: QueryOptions, meta: Json, columns?: string[]): QueryFilter {
function filterFrom(options: QueryOptions, meta: Json, columns?: string[], joins: ExposedJoin[] = []): QueryFilter {
const knownFields = [...Object.keys((meta.fields ?? {}) as Json), ...joinFieldNames(joins)]
// --where / --group values on DATE_TIME fields are local times (or dates: local midnight), sent as UTC instants; --filter-json is sent as given
const filter = options.filterJson ? readJson(options.filterJson, '--filter-json') as QueryFilter
: filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields: Object.keys((meta.fields ?? {}) as Json) }), (meta.fields ?? {}) as Record<string, FieldMeta>)
: filterDateTimesToUtc(buildFilter({ where: options.where, group: options.group, or: options.or, order: options.order, knownFields }), (meta.fields ?? {}) as Record<string, FieldMeta>)
if (options.search === undefined) return filter
// quick search as the web UI does it: CONTAINS on each shown string field, ORed, ANDed onto the filter
const fields = quickSearchFields(meta, columns)
@@ -41,13 +55,58 @@ function filterFrom(options: QueryOptions, meta: Json, columns?: string[]): Quer
return combineWithQuickSearch(filter, quickSearchFilter(options.search, fields))
}
/** The joins a query or count needs (--join, plus joined names in columns and the filter). */
function joinsFrom(options: QueryOptions, available: ExposedJoin[], columns: string[] | undefined, filter: QueryFilter): { joins?: QueryJoin[]; used: ExposedJoin[]; includeDistinct: boolean } {
try {
const plan = planJoins(available, { named: options.join, select: options.select, columns, filter })
return { ...(plan.joins.length ? { joins: plan.joins } : {}), used: plan.used, includeDistinct: plan.includeDistinct }
} catch (error) {
if (error instanceof JoinError) throw new UsageError(error.message)
throw error
}
}
/** Prints a count, with the distinct count when a many-side join can repeat records. */
function printCount(c: Context, result: Json, includeDistinct: boolean): void {
const row: Json = { count: result.count }
if (includeDistinct) row.distinctCount = result.distinctCount ?? ''
c.print({ data: result, rows: [row], columns: Object.keys(row) })
}
/** Opens a saved view for a query: the view's filter, columns and page size, with Material's reconcile warnings on stderr. */
async function openSavedView(c: Context, meta: Json, idOrName: string): Promise<{ label: string; filter: QueryFilter; columns: string[]; rowsPerPage?: number }> {
const metaData = await c.metaData() as unknown as Json
const processes = savedViewProcesses(meta, metaData)
try {
const view = findSavedView(await listSavedViews(c.server, authenticatedFetch(c.target), processes, String(meta.name)), idOrName)
const joins = await availableJoins(c, meta, { filterJson: JSON.stringify(view.view) })
const opened = viewToQuery(meta, joins, view.view)
for (const warning of opened.warnings) c.note(`warning: ${warning}`)
return { label: view.label, ...opened }
} catch (error) {
if (error instanceof SavedViewError) throw new UsageError(error.message)
throw error
}
}
/** ANDs the command's criteria onto a view's filter; the command's sort replaces the view's. */
function withSavedView(view: QueryFilter | undefined, given: QueryFilter): QueryFilter {
if (!view) return given
const { orderBys: viewOrder, ...viewRest } = view
const { orderBys: givenOrder, ...givenRest } = given
const orderBys = givenOrder?.length ? givenOrder : viewOrder
const empty = (f: Partial<QueryFilter>) => !f.criteria?.length && !f.subFilters?.length
const base: QueryFilter = empty(givenRest) ? viewRest as QueryFilter : empty(viewRest) ? givenRest as QueryFilter : { criteria: [], booleanOperator: 'AND', subFilters: [viewRest as QueryFilter, givenRest as QueryFilter] }
return orderBys?.length ? { ...base, orderBys } : base
}
/** --fields of the command (or the global one), as a list. */
function columnsFrom(c: Context, options: { fields?: string }): string[] | undefined {
return c.fields ?? (options.fields ? options.fields.split(',').map((f) => f.trim()).filter(Boolean) : undefined)
}
export function registerTable(program: Command, ctx: () => Context): void {
const table = program.command('table').description('Tables: list, describe, query, count, get, create, copy, update, delete, export.')
const table = program.command('table').description('Tables: list, describe, variants, query, count, stats, get, create, copy, update, delete, export.')
table.command('list')
.description('List the tables you may read (optionally of one app).')
@@ -87,13 +146,22 @@ export function registerTable(program: Command, ctx: () => Context): void {
c.print({ data: meta, rows: fields, columns: ['name', 'label', 'type', 'required', 'editable', 'possibleValues', 'hidden'], footer: `${meta.label} (${meta.name}), primary key ${meta.primaryKeyField}\n${sections}\ncapabilities: ${((meta.capabilities ?? []) as string[]).join(', ')}` })
})
table.command('variants <table>')
.description('The variants a table\'s backend can be scoped to (give one to other commands with --variant).')
.action(async (name: string) => {
const c = ctx()
const meta = await tableMeta(c, name)
const variants = await listTableVariants(c.server, authenticatedFetch(c.target), name)
c.print({ data: variants, rows: variants.map((v) => ({ id: v.id, name: v.name ?? '', type: v.type })), columns: ['id', 'name', 'type'], footer: meta.usesVariants ? `${meta.label} needs a ${String(meta.variantTableLabel ?? 'variant')}: --variant <id|name>` : `${meta.label} does not use variants${variants.length ? '' : ' (none listed)'}` })
})
const query = table.command('query <table>')
.description('Query a table. Criteria: "field OPERATOR value[,value]" (operators as the UI API, or = != < <= > >= ~); values may be now(-7d), thisPeriod(month), startOf(quarter,-1), me(), ${variable.x}.')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses, e.g. "(a = 1 AND b = 2) OR c IS_BLANK"')
.option('--or', 'OR the --where criteria instead of AND')
.option('-o, --order <field...>', 'order by fields; prefix - for descending')
.option('-l, --limit <n>', 'most records to return', '25')
.option('-l, --limit <n>', 'most records to return (default: the view\'s page size, else 25)')
.option('--skip <n>', 'records to skip')
.option('--all', 'fetch every page (with --page-size)')
.option('--page-size <n>', 'page size for --all', '500')
@@ -102,33 +170,99 @@ export function registerTable(program: Command, ctx: () => Context): void {
.option('--print-filter', 'print the QueryFilter JSON the options produced and stop')
.option('--count', 'print only the count of matching records')
.option('-S, --search <text>', 'quick search: records whose text fields contain this, ANDed with the filter (as the web UI search box)')
.option('-V, --view <id|name>', 'start from a saved view: its filter, sort, columns and page size (--where and --search are ANDed on, --order and --fields replace)')
.option('-j, --join <table...>', 'join exposed joins (by joined table name or label); joined fields are named table.field in --fields, --where and --order')
.option('--select', 'select the --join tables\' fields (shown columns from a join select it anyway)')
.option(...VARIANT_OPTION)
.action(async (name: string, options: QueryOptions) => {
const c = ctx()
const meta = await tableMeta(c, name)
const filter = filterFrom(options, meta, columnsFrom(c, options))
const saved = options.view !== undefined ? await openSavedView(c, meta, options.view) : undefined
const given = columnsFrom(c, options) ?? (saved?.columns.length ? saved.columns : undefined)
const available = await availableJoins(c, meta, { ...options, filterJson: `${options.filterJson ?? ''} ${saved ? JSON.stringify(saved.filter) : ''}` }, given)
const filter = withSavedView(saved?.filter, filterFrom(options, meta, given, available))
const { joins, used, includeDistinct } = joinsFrom(options, available, given, filter)
if (options.printFilter) {
c.print({ data: filter })
c.print({ data: joins ? { filter, joins } : filter })
return
}
const variant = await chooseVariant(c, meta, options.variant)
if (options.count) {
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct: false } }, body: { filter: filter as never } })) as Json
c.print({ data: result, rows: [{ count: result.count }], columns: ['count'] })
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct } }, body: { filter: filter as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) } })) as Json
printCount(c, result, includeDistinct)
return
}
const records: Json[] = []
const limit = options.all ? Number(options.pageSize) : Number(options.limit)
const limit = options.all ? Number(options.pageSize) : Number(options.limit ?? saved?.rowsPerPage ?? 25)
let skip = Number(options.skip ?? 0)
for (;;) {
const body = { filter: { ...filter, skip, limit } as never }
const body = { filter: { ...filter, skip, limit } as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) }
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: name } }, body })) as Json
const page = (result.records ?? []) as Json[]
records.push(...page)
if (!options.all || page.length < limit) break
skip += limit
}
const fields = columnsFrom(c, options) ?? defaultFields(meta)
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f, meta, c.dateTimeDisplay)])))
c.print({ data: records, rows, columns: fields, headers: labelsFor(meta, fields), footer: `(${records.length} record${records.length === 1 ? '' : 's'}${filter.criteria?.length || filter.subFilters?.length ? `; filter: ${printFilter(filter)}` : ''}${!options.all && records.length === limit ? '; more may exist: --limit, --skip or --all' : ''})` })
const selected = used.filter((j) => joins?.find((q) => q.joinTable === j.table)?.select || options.select)
const fields = given ?? [...defaultFields(meta), ...selected.flatMap((j) => defaultJoinColumns(j))]
const joinsShown = used.map((j) => j.table)
const shownMeta = withJoinFields(meta, used)
const rows = records.map((r) => Object.fromEntries(fields.map((f) => [f, valueText(r, f, shownMeta, c.dateTimeDisplay)])))
c.print({ data: records, rows, columns: fields, headers: fields.map((f) => fieldLabel(meta, used, f)), footer: `(${records.length} record${records.length === 1 ? '' : 's'}${joinsShown.length ? `; joined: ${joinsShown.join(', ')}${includeDistinct ? ' (many-side: rows may repeat a record; --count gives the distinct count)' : ''}` : ''}${variant ? `; variant ${variant.name ?? `${variant.type}:${variant.id}`}` : ''}${saved ? `; view ${saved.label}` : ''}${filter.criteria?.length || filter.subFilters?.length ? `; filter: ${printFilter(filter)}` : ''}${!options.all && records.length === limit ? '; more may exist: --limit, --skip or --all' : ''})` })
})
table.command('stats <table> <field>')
.description('Column statistics over the matching records: count, distinct, sum, average, min, max, and the distribution of values (the server\'s columnStats process; tables need QUERY_STATS).')
.option('-w, --where <criteria...>', 'criteria, ANDed (or --or)')
.option('-g, --group <expression...>', 'a group with AND/OR and parentheses')
.option('--or', 'OR the --where criteria')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.option('-S, --search <text>', 'quick search over the text fields, ANDed with the filter')
.option('-j, --join <table...>', 'join exposed joins (the field may be table.field)')
.option('--sort <order>', 'distribution order: count (default, most first), count-asc, value, value-desc', 'count')
.option('-l, --limit <n>', 'most distribution rows to show', '25')
.option('--local', 'when the server offers no statistics, compute them here from the matching records (paged)')
.option('--max-records <n>', 'with --local, the most records to read', '100000')
.option(...VARIANT_OPTION)
.action(async (name: string, field: string, options: QueryOptions & { sort: string; local?: boolean; maxRecords: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const available = await availableJoins(c, meta, { ...options, join: [...(options.join ?? []), ...(field.includes('.') ? [field.slice(0, field.indexOf('.'))] : [])] }, [field])
const resolved = resolveField(meta, available, field)
if (!resolved) throw new UsageError(`No field "${field}" in ${name}`)
const filter = filterFrom(options, meta, undefined, available)
const orderField = { count: 'count.desc', 'count-asc': 'count.asc', value: `${field}.asc`, 'value-desc': `${field}.desc` }[options.sort]
if (!orderField) throw new UsageError('--sort takes count, count-asc, value or value-desc')
const variant = await chooseVariant(c, meta, options.variant)
const availability = columnStatsAvailability(meta, await c.metaData() as unknown as Json)
let stats
if (availability.available) {
stats = await runColumnStats(c.server, authenticatedFetch(c.target), { table: name, field, filter, orderBy: orderField, tableVariant: variant })
} else if (options.local) {
const { joins } = joinsFrom(options, available, [field], filter)
const records: Json[] = []
const pageSize = 1000
const max = Number(options.maxRecords)
for (let skip = 0; skip < max; skip += pageSize) {
const body = { filter: { ...filter, skip, limit: Math.min(pageSize, max - skip) } as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) }
const page = ((await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/query', { params: { path: { tableName: name } }, body })) as Json).records ?? []) as Json[]
records.push(...page)
if (page.length < pageSize) break
}
if (records.length >= max) c.note(`note: stopped at --max-records ${max}; the statistics cover those records only`)
stats = computeColumnStats(records, field, resolved.field, orderField)
} else {
throw new UsageError(`No column statistics: ${availability.reason}. --local computes them here from the matching records.`)
}
const label = fieldLabel(meta, available, field)
const limit = Number(options.limit)
const distribution = stats.valueCounts.slice(0, limit).map((r) => ({ value: r.displayValues[field] ?? r.values[field] ?? '', count: r.values.count }))
if (c.format === 'json' || c.format === 'yaml' || c.options.query) {
c.print({ data: { field, label, ...stats } })
return
}
c.print({ data: stats, rows: stats.fields.map((f) => ({ statistic: f.label, value: stats.stats.displayValues[f.name] ?? stats.stats.values[f.name] ?? '' })), columns: ['statistic', 'value'], footer: `${meta.label}: ${label}${resolved.field.type === 'DATE_TIME' ? ' (grouped by hour)' : ''}${availability.available ? '' : ' (computed locally)'}` })
c.print({ data: stats.valueCounts, rows: distribution, columns: ['value', 'count'], headers: [label, 'Count'], footer: stats.valueCounts.length > limit ? `(${limit} of ${stats.valueCounts.length} values; --limit for more)` : `(${stats.valueCounts.length} value${stats.valueCounts.length === 1 ? '' : 's'})` })
})
table.command('count <table>')
@@ -138,38 +272,59 @@ export function registerTable(program: Command, ctx: () => Context): void {
.option('--or', 'OR the --where criteria')
.option('--filter-json <json|file|->', 'a QueryFilter as JSON')
.option('-S, --search <text>', 'quick search over the text fields, ANDed with the filter')
.option('-j, --join <table...>', 'join exposed joins (by joined table name or label); joined fields are named table.field in --fields, --where and --order')
.option('--select', 'select the --join tables\' fields (shown columns from a join select it anyway)')
.option(...VARIANT_OPTION)
.action(async (name: string, options: QueryOptions) => {
const c = ctx()
const meta = await tableMeta(c, name)
const filter = filterFrom(options, meta)
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct: false } }, body: { filter: filter as never } })) as Json
c.print({ data: result, rows: [{ count: result.count }], columns: ['count'] })
const available = await availableJoins(c, meta, options)
const filter = filterFrom(options, meta, undefined, available)
const { joins, includeDistinct } = joinsFrom(options, available, undefined, filter)
const variant = await chooseVariant(c, meta, options.variant)
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}/count', { params: { path: { tableName: name }, query: { includeDistinct } }, body: { filter: filter as never, ...(joins ? { joins: joins as never } : {}), ...variantBody(variant) } })) as Json
printCount(c, result, includeDistinct)
})
table.command('get <table> [primaryKey]')
.description('View one record, with field labels and display values: by primary key, or by a unique key with --key field=value (the web UI\'s Go To).')
.option('--associations', 'include associated records')
.description('View one record by section, values through their adornments (links, chips, masked values, files): by primary key, or by a unique key with --key field=value (the web UI\'s Go To); --associations adds the child records.')
.option('--associations', 'list every association (child records, all levels) as tables')
.option('-k, --key <field=value...>', 'find the record by key fields instead of the primary key (one match required)')
.action(async (name: string, primaryKeyArg: string | undefined, options: { associations?: boolean; key?: string[] }) => {
.option('--follow <field>', 'open the record a link or possible-value field points at, instead of this one')
.option('--links', 'list the related records this record links to (fields, tables, keys)')
.option('--reveal', 'show REVEAL (masked) values')
.option('--help-content', 'show table, section and field help content')
.option('-j, --join <table...>', 'include fields of these exposed joins (table.field)')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKeyArg: string | undefined, options: { associations?: boolean; key?: string[]; follow?: string; links?: boolean; reveal?: boolean; helpContent?: boolean; join?: string[]; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const variant = await chooseVariant(c, meta, options.variant)
const plan = joinsFrom({ join: options.join, select: true } as QueryOptions, await availableJoins(c, meta, { join: options.join }), undefined, { criteria: [], booleanOperator: 'AND' })
let primaryKey = primaryKeyArg
if (options.key?.length) {
if (primaryKey !== undefined) throw new UsageError('Give a primary key or --key, not both.')
const values = parsePairs(options.key, '--key')
for (const field of Object.keys(values)) if (!(meta.fields as Json)[field]) throw new UsageError(`No field "${field}" in ${name}`)
if (!isGotoKey(meta, Object.keys(values))) c.note(`note: ${Object.keys(values).join('+')} is not a declared key of ${meta.label} (keys: ${gotoKeys(meta).map((k) => k.join('+')).join(', ')}); looking it up anyway`)
const found = await findRecordByKey(c.server, authenticatedFetch(c.target), meta, values)
const found = await findRecordByKey(c.server, authenticatedFetch(c.target), meta, values, { tableVariant: variant })
if (found.found === 'none') throw new UiApiError(404, { code: 'NOT_FOUND', message: 'Record not found.' })
if (found.found === 'many') throw new UsageError(`More than 1 record was found for ${Object.entries(values).map(([k, v]) => `${k}=${v}`).join(', ')}; it is not a unique key (use table query).`)
primaryKey = found.primaryKey
}
if (primaryKey === undefined) throw new UsageError('Give a primary key, or --key field=value.')
const result = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { includeAssociations: options.associations ? true : undefined } as never } })) as Json
const record = (result.record ?? result) as Json
const fieldMeta = (meta.fields ?? {}) as Record<string, Json>
const rows = defaultFields(meta, 1000).map((f) => ({ field: String(fieldMeta[f]?.label ?? f), value: valueText(record, f, meta, c.dateTimeDisplay) }))
c.print({ data: record, rows, columns: ['field', 'value'], headers: ['Field', 'Value'], footer: String(record.recordLabel ?? `${meta.label} ${primaryKey}`) })
const show = { associations: options.associations, links: options.links, reveal: options.reveal, helpContent: options.helpContent }
if (options.follow) {
const field = ((meta.fields ?? {}) as Record<string, FieldMeta>)[options.follow]
if (!field) throw new UsageError(`No field "${options.follow}" in ${name}`)
const record = await getRecord(c, name, primaryKey, false, { tableVariant: variantParam(variant) })
const target = relatedRecordTarget(field, record as unknown as RecordLike, Object.keys((await c.metaData()).tables ?? {}))
if (!target) throw new UsageError(`${meta.label} ${primaryKey}: ${field.label} links to no record (empty, or not a link or possible-value field)`)
c.note(`${field.label} → ${target.tableName} ${target.primaryKey}`)
await showRecord(c, target.tableName, target.primaryKey, show)
return
}
await showRecord(c, name, primaryKey, { ...show, query: { queryJoins: plan.joins ? JSON.stringify(plan.joins) : undefined, tableVariant: variantParam(variant) }, joined: plan.used })
})
/** Fills a form session from --set / --input-json, then prompts when asked or when required values are missing on a terminal. */
@@ -194,13 +349,44 @@ export function registerTable(program: Command, ctx: () => Context): void {
throw new UiApiError(400, { code: 'BAD_REQUEST', message: 'Validation failed before sending', fieldErrors: Object.entries(problems).map(([field, message]) => ({ field, message: `${session.fields[field]?.label ?? field} ${message}` })) })
}
/** `--upload field=path` pairs as files, checked against the table's fields. */
function readUploads(meta: Json, pairs: string[] | undefined): Record<string, File> {
const out: Record<string, File> = {}
const fields = (meta.fields ?? {}) as Record<string, Json>
for (const [field, path] of Object.entries(parsePairs(pairs, '--upload'))) {
if (!fields[field]) throw new UsageError(`No field "${field}" in ${String(meta.name)}`)
let bytes: Uint8Array
try {
bytes = new Uint8Array(readFileSync(path))
} catch (error) {
throw new UsageError(`--upload ${field}: cannot read ${path} (${error instanceof Error ? error.message : String(error)})`)
}
out[field] = new File([bytes as BlobPart], basename(path))
}
return out
}
/** The multipart record body (the Next UI's recordFormData): text fields as strings, null as an empty value (a clear), files as parts. */
function multipartRecord(values: Json, files: Record<string, File>): Record<string, string | Blob> {
const form: Record<string, string | Blob> = {}
for (const [key, value] of Object.entries(values)) {
if (value === undefined || files[key]) continue
form[key] = value === null ? '' : Array.isArray(value) || typeof value === 'object' ? JSON.stringify(value) : String(value)
}
return { ...form, ...files }
}
table.command('create <table>')
.description('Insert a record from --set name=value pairs or --input-json; -i prompts for the rest. Possible-value fields take a label or an id.')
.option('-s, --set <name=value...>', 'field values')
.option('--input-json <json|file|->', 'the whole record as a JSON object of field values')
.option('-i, --interactive', 'prompt for fields on the terminal')
.option('--generate-input-skeleton', 'print an empty JSON object with the editable fields and stop')
.action(async (name: string, options: { set?: string[]; inputJson?: string; interactive?: boolean; generateInputSkeleton?: boolean }) => {
.option('--parent <table=primaryKey>', 'create a child record linked to this parent (through the parent table\'s association)')
.option('--association <name>', 'with --parent: the association, when the parent has several to this table')
.option('-u, --upload <field=path...>', 'a file for a file field (sent as multipart/form-data)')
.option(...VARIANT_OPTION)
.action(async (name: string, options: { set?: string[]; inputJson?: string; interactive?: boolean; generateInputSkeleton?: boolean; parent?: string; association?: string; upload?: string[]; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const fields = (meta.fields ?? {}) as Record<string, Json>
@@ -210,15 +396,40 @@ export function registerTable(program: Command, ctx: () => Context): void {
c.note(editable.map((f) => `${String(f.name).padEnd(24)} ${f.type}${f.isRequired ? ' required' : ''}${f.possibleValueSourceName ? ` (possible values: ${f.possibleValueSourceName}${Array.isArray(f.possibleValueSourceDependsOn) ? `, depends on ${(f.possibleValueSourceDependsOn as string[]).join(', ')}` : ''})` : ''}`).join('\n'))
return
}
const given = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
if (!Object.keys(given).length && !options.interactive) throw new UsageError('Give field values with --set name=value or --input-json, or prompt with -i.')
const given: Json = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
const uploads = readUploads(meta, options.upload)
let parentNote = ''
if (options.parent) {
const at = options.parent.indexOf('=')
if (at < 1) throw new UsageError(`--parent takes table=primaryKey: ${options.parent}`)
const parentName = options.parent.slice(0, at)
const parentKey = options.parent.slice(at + 1)
const parentMeta = await c.tableMeta<TableMetaLike>(parentName)
const parent = await getRecord(c, parentName, parentKey, false) as unknown as RecordLike
let link: ReturnType<typeof parentLinkValues>
try {
link = parentLinkValues(parentMeta, parent, meta as unknown as TableMetaLike, options.association)
} catch (error) {
if (error instanceof AssociationError) throw new UsageError(error.message)
throw error
}
for (const [field, value] of Object.entries(link.values)) {
if (given[field] !== undefined && String(given[field]) !== String(value)) throw new UsageError(`--set ${field}=${String(given[field])} contradicts --parent ${options.parent} (${field} is ${String(value)})`)
given[field] = String(value)
}
parentNote = `${parentMeta.label ?? parentName} ${parentKey}${parent.recordLabel ? ` (${parent.recordLabel})` : ''}, association ${link.association.name}`
}
if (!Object.keys(given).length && !Object.keys(uploads).length && !options.interactive) throw new UsageError('Give field values with --set name=value or --input-json, or prompt with -i.')
const variant = await chooseVariant(c, meta, options.variant, { write: true })
const session = await fillForm(c, meta, name, given, options)
const problems = session.validate()
const problems = session.validate(Object.keys(uploads).length ? Object.keys(session.fields).filter((n) => !uploads[n]) : undefined)
if (Object.keys(problems).length) validationError(session, problems)
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name } }, body: session.submission() as never })) as Json
const result = Object.keys(uploads).length
? (await c.call('insertRecord', { path: { tableName: name }, query: { tableVariant: variantParam(variant) }, form: multipartRecord(session.submission(), uploads) })).data as Json
: await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name }, query: { tableVariant: variantParam(variant) } as never }, body: session.submission() as never })) as Json
const record = (result.record ?? result) as Json
const pk = (record.values as Json | undefined)?.[String(meta.primaryKeyField)] ?? record[String(meta.primaryKeyField)] ?? ''
c.print({ data: result, rows: [{ created: `${meta.label} ${pk}`, label: record.recordLabel ?? '' }], columns: ['created', 'label'] })
c.print({ data: result, rows: [{ created: `${meta.label} ${pk}`, label: record.recordLabel ?? '', ...(parentNote ? { parent: parentNote } : {}) }], columns: parentNote ? ['created', 'label', 'parent'] : ['created', 'label'] })
})
table.command('copy <table> <primaryKey>')
@@ -226,11 +437,13 @@ export function registerTable(program: Command, ctx: () => Context): void {
.option('-s, --set <name=value...>', 'values to change in the copy')
.option('-i, --interactive', 'prompt for every editable field, the copied values shown')
.option('--dry-run', 'print the values the copy would insert and stop')
.action(async (name: string, primaryKey: string, options: { set?: string[]; interactive?: boolean; dryRun?: boolean }) => {
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, options: { set?: string[]; interactive?: boolean; dryRun?: boolean; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const variant = await chooseVariant(c, meta, options.variant, { write: !options.dryRun })
if (meta.insertPermission === false) throw new UiApiError(403, { code: 'PERMISSION_DENIED', message: `You may not create ${meta.label} records` })
const source = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: {} as never } })) as Json
const source = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { tableVariant: variantParam(variant) } as never } })) as Json
const sourceRecord = (source.record ?? source) as Json
const copied = copyRecordValues(meta, sourceRecord)
const given = parsePairs(options.set, '--set')
@@ -244,7 +457,7 @@ export function registerTable(program: Command, ctx: () => Context): void {
c.print({ data: values, rows: Object.entries(values).map(([field, value]) => ({ field, value: session.displayValues[field] ?? value ?? '' })), columns: ['field', 'value'] })
return
}
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name } }, body: values as never })) as Json
const result = await c.unwrap(c.client.POST('/qqq/v1/table/{tableName}', { params: { path: { tableName: name }, query: { tableVariant: variantParam(variant) } as never }, body: values as never })) as Json
const record = (result.record ?? result) as Json
const pk = (record.values as Json | undefined)?.[String(meta.primaryKeyField)] ?? ''
c.print({ data: result, rows: [{ created: `${meta.label} ${pk}`, label: record.recordLabel ?? '', from: `${meta.label} ${primaryKey}` }], columns: ['created', 'label', 'from'] })
@@ -256,41 +469,49 @@ export function registerTable(program: Command, ctx: () => Context): void {
.option('--input-json <json|file|->', 'field values as a JSON object')
.option('-i, --interactive', 'prompt for every editable field, current values shown')
.option('--expected-version <v>', 'optimistic locking: fail when the record changed')
.action(async (name: string, primaryKey: string, options: { set?: string[]; inputJson?: string; interactive?: boolean; expectedVersion?: string }) => {
.option('-u, --upload <field=path...>', 'replace a file field with this file (multipart/form-data); --set field= removes the file')
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, options: { set?: string[]; inputJson?: string; interactive?: boolean; expectedVersion?: string; upload?: string[]; variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const variant = await chooseVariant(c, meta, options.variant, { write: true })
const given = { ...(options.inputJson ? readJson(options.inputJson, '--input-json') as Json : {}), ...parsePairs(options.set, '--set') }
if (!Object.keys(given).length && !options.interactive) throw new UsageError('Give field values with --set name=value or --input-json, or prompt with -i.')
const uploads = readUploads(meta, options.upload)
if (!Object.keys(given).length && !Object.keys(uploads).length && !options.interactive) throw new UsageError('Give field values with --set name=value or --input-json, or prompt with -i.')
let initial: Json | undefined
if (options.interactive) {
const existing = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: {} as never } })) as Json
const existing = await c.unwrap(c.client.GET('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { tableVariant: variantParam(variant) } as never } })) as Json
initial = ((existing.record ?? existing) as Json).values as Json
}
const session = await fillForm(c, meta, name, given, { interactive: false }, initial)
if (options.interactive) await promptForm(session, { skipSet: false })
const touched = options.interactive ? session.editable.map((f) => f.name).filter((n) => String(session.values[n] ?? '') !== String(initial?.[n] ?? '')) : Object.keys(given)
if (!touched.length) {
if (!touched.length && !Object.keys(uploads).length) {
c.note('Nothing changed.')
return
}
const problems = session.validate(touched)
if (Object.keys(problems).length) validationError(session, problems)
const values = Object.fromEntries(touched.map((n) => [n, session.values[n] ?? null]))
const result = await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { expectedVersion: options.expectedVersion } as never }, body: values as never })) as Json
const result = Object.keys(uploads).length
? (await c.call('updateRecord', { path: { tableName: name, primaryKey }, query: { expectedVersion: options.expectedVersion, tableVariant: variantParam(variant) }, form: multipartRecord(values, uploads) })).data as Json
: await c.unwrap(c.client.PATCH('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { expectedVersion: options.expectedVersion, tableVariant: variantParam(variant) } as never }, body: values as never })) as Json
const record = (result.record ?? result) as Json
c.print({ data: result, rows: [{ updated: `${meta.label} ${primaryKey}`, label: record.recordLabel ?? '', fields: touched.join(', ') }], columns: ['updated', 'label', 'fields'] })
c.print({ data: result, rows: [{ updated: `${meta.label} ${primaryKey}`, label: record.recordLabel ?? '', fields: [...touched, ...Object.keys(uploads)].join(', ') }], columns: ['updated', 'label', 'fields'] })
})
table.command('delete <table> <primaryKey>')
.description('Delete a record (asks first; --yes skips the question).')
.action(async (name: string, primaryKey: string) => {
.option(...VARIANT_OPTION)
.action(async (name: string, primaryKey: string, options: { variant?: string }) => {
const c = ctx()
const meta = await tableMeta(c, name)
const variant = await chooseVariant(c, meta, options.variant, { write: true })
if (!await c.confirm(`Delete ${meta.label} ${primaryKey}?`)) {
c.note('Not deleted.')
return
}
const result = await c.unwrap(c.client.DELETE('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey } } })) as Json
const result = await c.unwrap(c.client.DELETE('/qqq/v1/table/{tableName}/{primaryKey}', { params: { path: { tableName: name, primaryKey }, query: { tableVariant: variantParam(variant) } as never } })) as Json
c.print({ data: result ?? {}, rows: [{ deleted: `${meta.label} ${primaryKey}` }], columns: ['deleted'] })
})
@@ -304,9 +525,10 @@ export function registerTable(program: Command, ctx: () => Context): void {
.option('--format <fmt>', 'csv, xlsx, json or tsv (default: from the --out extension, else csv)')
.option('-f, --fields <names>', 'comma-separated fields to include, in column order (default: the server\'s)')
.option('-l, --limit <n>', 'most records')
.option('--out <file>', 'output file, or - for stdout (default: <table>.<format>)')
.option('--out <file>', 'output file, or - for stdout (default: <table>.<format>; name-1.<format> when taken)')
.option('--no-header', 'leave out the header row (csv, tsv, xlsx)')
.option('-S, --search <text>', 'quick search over the text fields, ANDed with the filter')
.option(...VARIANT_OPTION)
.action(async (name: string, options: QueryOptions & { format?: string; out?: string; header: boolean }) => {
const c = ctx()
const meta = await tableMeta(c, name)
@@ -314,19 +536,22 @@ export function registerTable(program: Command, ctx: () => Context): void {
const format = (options.format ?? (fromOut && EXPORT_FORMATS.includes(fromOut) ? fromOut : 'csv')).toLowerCase()
if (!EXPORT_FORMATS.includes(format)) throw new UsageError(`--format must be one of ${EXPORT_FORMATS.join(', ')}`)
const columns = columnsFrom(c, options)
const unknown = (columns ?? []).filter((f) => !isKnownField(meta, f))
// the export route takes joined columns of exposed joins by name (it has no joins member)
const available = exposedJoins(meta)
const unknown = (columns ?? []).filter((f) => !resolveField(meta, available, f))
if (unknown.length) throw new UsageError(`No field ${unknown.join(', ')} in ${name}; fields: ${Object.keys((meta.fields ?? {}) as Json).join(', ')}`)
if (options.limit !== undefined && !(Number(options.limit) > 0)) throw new UsageError('--limit must be a positive number')
const filter = filterFrom(options, meta, columns)
const out = options.out ?? `${name}.${format}`
const result = await c.call('exportTable', { path: { tableName: name }, json: { format, filter, fieldNames: columns, limit: options.limit ? Number(options.limit) : undefined, ...(options.header === false ? { includeHeaderRow: false } : {}) } })
const filter = filterFrom(options, meta, columns, available)
const variant = await chooseVariant(c, meta, options.variant)
const out = options.out ?? safeFileName(`${name}.${format}`)
const result = await c.call('exportTable', { path: { tableName: name }, json: { format, filter, fieldNames: columns, limit: options.limit ? Number(options.limit) : undefined, ...(options.header === false ? { includeHeaderRow: false } : {}), ...variantBody(variant) } })
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
if (out === '-') {
process.stdout.write(bytes)
return
}
writeFileSync(out, bytes)
c.note(`wrote ${out} (${bytes.length} bytes, ${result.contentType})`)
const written = saveDownload(out, bytes, { explicit: options.out !== undefined })
c.note(`wrote ${written} (${bytes.length} bytes, ${result.contentType})`)
})
// flat aliases, kept from 0.1
+57
View File
@@ -0,0 +1,57 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { VariantError, listTableVariants, resolveVariant, usesVariants, variantLabel, type TableVariant } from '@qrun-io/qqq-core'
import { authenticatedFetch } from '../session.js'
import { UsageError, type Context } from '../context.js'
import type { Json } from './shared.js'
/** The --variant option every table command and `process run` take. */
export const VARIANT_OPTION = ['--variant <id|name|type:id>', 'the table variant (tenant, store, …) to use; see `qqq table variants <table>`'] as const
/**
* The variant a command runs with: the one --variant names, or for a table that uses variants and
* none was named, the only one, a choice on a terminal, or a usage error listing them (the web UI
* asks and queries nothing until one is chosen).
*/
export async function chooseVariant(c: Context, meta: Json, typed: string | undefined, options: { write?: boolean } = {}): Promise<TableVariant | undefined> {
const table = String(meta.name)
if (typed === undefined && !usesVariants(meta)) return undefined
const variants = await listTableVariants(c.server, authenticatedFetch(c.target), table)
const label = variantLabel(meta)
let chosen: TableVariant | undefined
if (typed !== undefined) {
try {
chosen = resolveVariant(variants, typed)
} catch (error) {
if (error instanceof VariantError) throw new UsageError(`--variant: ${error.message}`)
throw error
}
if (!usesVariants(meta)) c.note(`note: ${meta.label} does not use variants; sending ${label} ${chosen.name ?? chosen.id} anyway`)
} else if (variants.length === 1) {
chosen = variants[0]!
c.note(`${label}: ${chosen.name ?? chosen.id} (the only one)`)
} else if (variants.length && process.stdin.isTTY) {
const { createInterface } = await import('node:readline/promises')
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
variants.forEach((v, i) => process.stderr.write(` ${i + 1}. ${v.name ?? v.id} (${v.id})\n`))
const answer = (await rl.question(`${meta.label} needs a ${label}: `)).trim()
if (!answer) throw new UsageError(`No ${label} chosen; nothing was queried.`)
const index = Number(answer)
chosen = Number.isInteger(index) && index >= 1 && index <= variants.length ? variants[index - 1]! : resolveVariant(variants, answer)
} catch (error) {
if (error instanceof VariantError) throw new UsageError(error.message)
throw error
} finally {
rl.close()
}
} else {
throw new UsageError(`${meta.label} needs a ${label}: --variant <id|name>${variants.length ? ` (${variants.slice(0, 10).map((v) => `${v.name ?? v.id} (${v.id})`).join(', ')}${variants.length > 10 ? ', …' : ''})` : '; the server lists none'}`)
}
// UI API 1.8.0 reads tableVariant on query, count, get, export and processes only (docs/server-findings.md #19)
if (options.write) c.note(`note: the server may ignore the ${label} on inserts, updates and deletes (UI API ${'1.8.0'}); see docs/server-findings.md #19`)
return chosen
}
+5 -5
View File
@@ -5,7 +5,7 @@
import { writeFileSync } from 'node:fs'
import type { Command } from 'commander'
import { blocksToText, formatCsv, listReports, widgetRows, widgetToBlocks } from '@qrun-io/qqq-core'
import { blocksToText, formatCsv, listReports, safeFileName, saveDownload, widgetRows, widgetToBlocks } from '@qrun-io/qqq-core'
import { UsageError, type Context } from '../context.js'
import { parsePairs, type Json } from './shared.js'
@@ -61,14 +61,14 @@ export function registerWidget(program: Command, ctx: () => Context): void {
report.command('run <report>')
.description('Run a report and save its output (csv, xlsx, json, pdf as the report allows).')
.option('--format <fmt>', 'output format', 'csv')
.option('-o, --out <file>', 'output file (default: <report>.<format>)')
.option('-o, --out <file>', 'output file, replaced if it exists (default: <report>.<format>; name-1.<format> when taken)')
.action(async (name: string, options: { format: string; out?: string }) => {
const c = ctx()
const result = await c.call('runReport', { path: { reportName: name }, query: { format: options.format } })
const out = options.out ?? `${name}.${options.format}`
const out = options.out ?? safeFileName(`${name}.${options.format}`)
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
writeFileSync(out, bytes)
c.note(`wrote ${out} (${bytes.length} bytes, ${result.contentType})`)
const written = saveDownload(out, bytes, { explicit: options.out !== undefined })
c.note(`wrote ${written} (${bytes.length} bytes, ${result.contentType})`)
})
const prefs = program.command('prefs').description('Your preferences: list, get, set, action.')
+33 -3
View File
@@ -10,8 +10,9 @@
import type { Command } from 'commander'
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
import { FilterSyntaxError, OUTPUT_FORMATS, ProcessError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { authenticatedFetch, clientFor, selectTarget, target, type Target } from './session.js'
import { Collaboration, ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieFor, target, type Target } from './session.js'
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
export const EXIT = { ok: 0, error: 1, usage: 2, auth: 3, transient: 4, notFound: 5, validation: 6, unexpected: 70 } as const
@@ -32,6 +33,7 @@ export interface GlobalOptions {
export class Context {
readonly options: GlobalOptions
private clientCache?: UiApiClient
private registryCache?: Promise<ExtensionRegistry>
constructor(program: Command) {
this.options = program.opts<GlobalOptions>()
@@ -94,6 +96,33 @@ export class Context {
return getTableMetaData<T>(this.server, table, authenticatedFetch(this.target), options)
}
/** The server's extensions (GET /version, cached with the metadata). */
extensions(options: { refresh?: boolean } = {}): Promise<ExtensionRegistry> {
// QQQ_EXTENSION_CHECK=off treats every extension the client knows as installed, for mock
// servers (Prism) whose GET /version is only the document's example.
if (process.env.QQQ_EXTENSION_CHECK === 'off') return Promise.resolve(new ExtensionRegistry(KNOWN_EXTENSIONS.map((e) => ({ name: e.name, version: e.documentVersion, pathPrefix: e.pathPrefix, known: true, meetsMinimum: true }))))
this.registryCache ??= extensionRegistry(this.server, authenticatedFetch(this.target), options)
return this.registryCache
}
/** The collaboration module for this server, with the realtime stream for presence. */
async collaboration(): Promise<Collaboration> {
const t = this.target
return new Collaboration(await this.extensions(), {
call: (id, input) => this.call(id, input),
openStream: (signal) => (async function* () {
const session = await sessionCookieFor(t)
yield* openRealtimeStream({
baseUrl: t.server,
clientChannel: CLIENT_CHANNEL,
signal,
headers: session ? { Cookie: `sessionUUID=${session}` } : undefined,
token: session ? undefined : () => currentToken(t),
})
})(),
})
}
/** Calls an operation by id (the generic escape hatch). */
call(id: string, input: CallInput): Promise<CallResult> {
return callOperation(this.server, id, input, authenticatedFetch(this.target))
@@ -129,7 +158,8 @@ export class UsageError extends Error {}
/** Maps an error to its exit code and the stderr text. */
export function describeError(error: unknown, trace = false): { code: number; text: string } {
if (error instanceof UsageError || error instanceof FilterSyntaxError) return { code: EXIT.usage, text: `Error: ${error.message}` }
if (error instanceof UsageError || error instanceof FilterSyntaxError || error instanceof UnsafeFileNameError) return { code: EXIT.usage, text: `Error: ${error.message}` }
if (error instanceof ExtensionNotInstalledError) return { code: EXIT.notFound, text: `Error: ${error.message}` }
if (error instanceof UiApiError) {
const lines = [`Error: ${error.message} (${error.code}, HTTP ${error.status}${error.traceId ? `, trace ${error.traceId}` : ''})`]
for (const fieldError of error.fieldErrors) lines.push(` ${fieldError.field}: ${fieldError.message}`)
+10 -1
View File
@@ -7,7 +7,8 @@
/**
* qqq - the command-line client for QQQ applications. Every call is an operation of the QQQ UI API
* (/qqq/v1); nothing is hard-coded about an application: apps, tables, fields and processes all
* come from metadata. Grammar: `qqq <noun> <verb>` (table, process, job, widget, report, prefs, api), plus the flat
* come from metadata. Grammar: `qqq <noun> <verb>` (table, process, job, widget, report, prefs, api, and one noun per
* extension: esb, collab, sql-workbench, ...), plus the flat
* commands of 0.1 (apps, tables, query, get, run, jobs, tail) as aliases.
*/
@@ -16,13 +17,17 @@ import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
import { Context, EXIT, describeError } from './context.js'
import { CLI_VERSION } from './session.js'
import { registerApi } from './commands/api.js'
import { registerAudit } from './commands/audit.js'
import { registerAuth } from './commands/auth.js'
import { registerCompletion } from './commands/completion.js'
import { registerConfig } from './commands/config.js'
import { registerExtensions } from './commands/extensions.js'
import { registerHelp } from './commands/help.js'
import { registerJob } from './commands/job.js'
import { registerMisc } from './commands/misc.js'
import { registerProcess } from './commands/process.js'
import { registerSavedView } from './commands/saved-view.js'
import { registerRelated } from './commands/related.js'
import { registerTable } from './commands/table.js'
import { registerWidget } from './commands/widget.js'
@@ -49,10 +54,14 @@ const ctx = () => (context ??= new Context(program))
registerAuth(program, ctx)
registerMisc(program, ctx)
registerTable(program, ctx)
registerAudit(program, ctx)
registerRelated(program, ctx)
registerProcess(program, ctx)
registerSavedView(program, ctx)
registerJob(program, ctx)
registerWidget(program, ctx)
registerApi(program, ctx)
registerExtensions(program, ctx)
registerCompletion(program, ctx)
registerConfig(program, ctx)
registerHelp(program, ctx)
+44
View File
@@ -0,0 +1,44 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Command } from 'commander'
import { ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, operationsOf } from '@qrun-io/qqq-core'
import { EXIT, describeError } from '../context.js'
import { nounOf, registerExtensions, subcommandNames } from '../commands/extensions.js'
import { presenceLine } from '../commands/collab.js'
test('every operation of every extension is a uniquely named subcommand of its noun', () => {
const program = new Command()
registerExtensions(program, () => { throw new Error('not called') })
for (const e of KNOWN_EXTENSIONS) {
const names = subcommandNames(e.name, e.pathPrefix, operationsOf(e.name))
assert.equal(names.size, operationsOf(e.name).length, `${e.name}: one name per operation`)
const noun = program.commands.find((c) => c.name() === nounOf(e.name))!
assert.ok(noun, `noun for ${e.name}`)
for (const name of names.keys()) assert.ok(noun.commands.some((c) => c.name() === name), `${nounOf(e.name)} ${name}`)
}
const esb = subcommandNames('esb', '/esb', operationsOf('esb'))
assert.deepEqual([...esb.keys()].sort(), ['get-dead-letters', 'get-messages', 'get-overview', 'get-process', 'get-table'])
const collab = subcommandNames('collaboration', '/qbit/collaboration', operationsOf('collaboration'))
assert.equal(collab.get('get-records-comments')?.id, 'getQbitCollaborationRecordsByTableByPkComments')
assert.equal(collab.get('get-people-by-user-id')?.id, 'getQbitCollaborationPeopleByUserId')
const collabNoun = program.commands.find((c) => c.name() === 'collab')!
assert.deepEqual(collabNoun.aliases(), ['collaboration'])
for (const verb of ['presence', 'comments', 'favorites', 'recents', 'follow', 'unfollow', 'my-work', 'people']) assert.ok(collabNoun.commands.some((c) => c.name() === verb), verb)
})
test('a missing extension maps to exit 5 with the installed list', () => {
const error = new ExtensionNotInstalledError('collaboration', ExtensionRegistry.fromVersion({ extensions: [{ name: 'esb', version: '1.1.0' }] }).installed)
assert.deepEqual(describeError(error), { code: EXIT.notFound, text: 'Error: extension collaboration is not installed on this server (installed: esb 1.1.0)' })
})
test('presence lines say who is there and since when', () => {
const since = new Date(2026, 9, 31, 17, 50, 1)
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
})
+4
View File
@@ -15,8 +15,12 @@ what it produces; the TUI draws it.
| `forms/form-session` | one form being filled: values, display values, clearing dependents, adjusters, validation, submission |
| `process/runner` | init, step until complete, poll asynchronous jobs, apply metadata adjustments, ask a provider for each step's input, go back (`BACK`), cancel on an abort signal (`POST …/cancel`), check min/max input records |
| `process/screen` | each step's components as neutral screen blocks: help, HTML as text, view pairs, process records (paged), validation review, summary lines, downloads (`GET /download`), widgets |
| `records/associations` | related records: association labels, the values binding a child to its parent (composite joins too), child-list filters, the association tree of a record read with `includeAssociations`, child pages with counts |
| `records/record-view` | the record view as text: sections without hidden sections or fields, adornments (LINK, CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR, FILE_DOWNLOAD, WIDGET, TOOLTIP), view-screen help by role |
| `widgets/render-model` | any widget payload to neutral blocks (heading, stat, table, series, pairs, steps, alert, link, rule, children) and blocks to text |
| `metadata/cache` | on-disk cache of `GET /metaData` and table definitions per server, with a TTL and offline reads |
| `extensions/registry` | every extension the client document carries (operations, documented minimum version, realtime channels), joined with the server's `GET /version`: `has`, `require` (typed `ExtensionNotInstalledError`), unknown extensions kept; see `src/extensions/README.md` |
| `extensions/collaboration` | the collaboration qbit: live presence (`joinPresence`, a no-op without the extension), comments, favorites, recents, follow, people, my work |
| `session/profiles`, `session/keyring` | named profiles and an OS keyring without native modules (Keychain, Secret Service, DPAPI, file) |
Rules the kernel keeps for both heads: every call is a spec operation named by `operationId`; text comes from the
+164 -6
View File
@@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
// Generated by scripts/gen-operations.mjs from the UI API 1.8.0 document. Do not edit.
// Generated by scripts/gen-operations.mjs from the UI API 1.8.0 document (core and 13 extensions). Do not edit.
export interface OperationInfo {
id: string
@@ -14,31 +14,85 @@ export interface OperationInfo {
pathParams: string[]
queryParams: string[]
bodyTypes: string[]
/** The extension (qbit or module) that serves this operation; absent for core operations. */
extension?: string
}
/** An extension document merged into the client document (info.x-qqq-extensions). */
export interface DocumentExtension {
name: string
version: string
pathPrefix?: string
description?: string
}
export const UI_API_DOCUMENT_VERSION = "1.8.0"
/** The extensions merged into the client document, with the versions it was generated from. */
export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
{"name":"esb","version":"1.1.0","pathPrefix":"/esb","description":"ESB overview, the ESB of tables and processes, and messages and dead letters on destinations."},
{"name":"health","version":"1.1.0","pathPrefix":"/health","description":"Central health summary, components, history and on-demand checks."},
{"name":"alerts","version":"1.2.0","pathPrefix":"/alerts","description":"Alerts inbox and record panel"},
{"name":"assistant","version":"1.6.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
{"name":"collaboration","version":"1.1.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
{"name":"data-analysis","version":"1.5.0","pathPrefix":"/qbit/data-analysis","description":"Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user."},
{"name":"db-performance","version":"1.0.0","pathPrefix":"/qbit/db-performance","description":"Database performance, live activity and audited query plans"},
{"name":"edi","version":"1.1.0","pathPrefix":"/qbit/edi","description":"EDI sandbox and mapper, using the signed-in user's permissions."},
{"name":"qa-capture","version":"1.0.0","pathPrefix":"/qbit/qa-capture","description":"QA bug capture: the user's capture capability and QA report creation"},
{"name":"quick-search","version":"1.1.0","pathPrefix":"/qbit/quick-search","description":"Quick Search administration and suggestions"},
{"name":"release-notes","version":"1.0.0","pathPrefix":"/qbit/release-notes","description":"What's new: published release notes and each user's unread count"},
{"name":"secured-data","version":"1.1.0","pathPrefix":"/qbit/secured-data","description":"Reveal grants and break-glass status"},
{"name":"sql-workbench","version":"1.1.0","pathPrefix":"/sql-workbench","description":"Audited SQL execution and notebooks"},
]
export const OPERATIONS: OperationInfo[] = [
{"id":"addRelatedRecords","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/add","summary":"Add child or join rows for picked records (MULTI_CHILD, LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"alertsInbox","method":"GET","path":"/qqq/v1/alerts/inbox","summary":"Page the notification inbox","tag":"alerts","pathParams":[],"queryParams":["status","category","severity","offset","limit"],"bodyTypes":[],"extension":"alerts"},
{"id":"alertsInboxDismiss","method":"POST","path":"/qqq/v1/alerts/inbox/dismiss","summary":"Dismiss own notifications","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"alerts"},
{"id":"alertsInboxRead","method":"POST","path":"/qqq/v1/alerts/inbox/read","summary":"Mark own notifications read","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"alerts"},
{"id":"alertsInboxSummary","method":"GET","path":"/qqq/v1/alerts/inbox/summary","summary":"Read the bell summary","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"alerts"},
{"id":"alertsInboxUnread","method":"POST","path":"/qqq/v1/alerts/inbox/unread","summary":"Mark own notifications unread","tag":"alerts","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"alerts"},
{"id":"alertsRecordPanel","method":"GET","path":"/qqq/v1/alerts/record-panel","summary":"Read visible alerts for a record","tag":"alerts","pathParams":[],"queryParams":["tableName","recordId"],"bodyTypes":[],"extension":"alerts"},
{"id":"cancelJob","method":"POST","path":"/qqq/v1/jobs/{jobId}/cancel","summary":"Jobs: cancel","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"cancelProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/cancel","summary":"Cancel a running process","tag":"Processes","pathParams":["processName","processUUID"],"queryParams":["tableVariant"],"bodyTypes":[]},
{"id":"chooseDeliveryTargets","method":"POST","path":"/qqq/v1/delivery/destinations/{name}/targets","summary":"Describe the next delivery target","tag":"General","pathParams":["name"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"countTable","method":"POST","path":"/qqq/v1/table/{tableName}/count","summary":"Count records in a table","tag":"Tables","pathParams":["tableName"],"queryParams":["includeDistinct"],"bodyTypes":["application/json"]},
{"id":"dbPerformanceActivity","method":"GET","path":"/qqq/v1/qbit/db-performance/activity","summary":"Database Performance activity","tag":"","pathParams":[],"queryParams":["backend"],"bodyTypes":[],"extension":"db-performance"},
{"id":"dbPerformanceExplain","method":"POST","path":"/qqq/v1/qbit/db-performance/explain","summary":"Explain a PostgreSQL query","tag":"","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"db-performance"},
{"id":"dbPerformanceLookup","method":"GET","path":"/qqq/v1/qbit/db-performance/statements/lookup","summary":"Database Performance lookup","tag":"","pathParams":[],"queryParams":["backend","sql"],"bodyTypes":[],"extension":"db-performance"},
{"id":"dbPerformanceOverview","method":"GET","path":"/qqq/v1/qbit/db-performance/overview","summary":"Database Performance overview","tag":"","pathParams":[],"queryParams":["backend"],"bodyTypes":[],"extension":"db-performance"},
{"id":"deleteAssistantConversationsById","method":"DELETE","path":"/qqq/v1/assistant/conversations/{id}","summary":"Delete a conversation","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"deleteQbitCollaborationAttachmentsById","method":"DELETE","path":"/qqq/v1/qbit/collaboration/attachments/{id}","summary":"Delete an attachment","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"deleteQbitCollaborationCommentsById","method":"DELETE","path":"/qqq/v1/qbit/collaboration/comments/{id}","summary":"Delete a comment thread","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"deleteQbitCollaborationFavorites","method":"DELETE","path":"/qqq/v1/qbit/collaboration/favorites","summary":"Unstar a target","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"deleteQbitCollaborationPresence","method":"DELETE","path":"/qqq/v1/qbit/collaboration/presence","summary":"Leave presence","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"deleteQbitCollaborationRecordsByTableByPkFollow","method":"DELETE","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Unfollow a record","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"deleteQbitDataAnalysisSavedByIdStar","method":"DELETE","path":"/qqq/v1/qbit/data-analysis/saved/{id}/star","summary":"Unstar a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"deleteRecord","method":"DELETE","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Delete a record from a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"download","method":"GET","path":"/qqq/v1/download/{file}","summary":"Download a file","tag":"General","pathParams":["file"],"queryParams":["filePath","storageTableName","storageReference"],"bodyTypes":[]},
{"id":"downloadRecordFieldFile","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/{fieldName}/{filename}","summary":"Download a field value from a record","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","filename"],"queryParams":[],"bodyTypes":[]},
{"id":"exportTable","method":"POST","path":"/qqq/v1/table/{tableName}/export","summary":"Export records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"getAssistantAccess","method":"GET","path":"/qqq/v1/assistant/access","summary":"Explain access: which UI API operations the assistant could call for this user, and why not","tag":"assistant","pathParams":[],"queryParams":["table","prompt","turnId"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantActionsById","method":"GET","path":"/qqq/v1/assistant/actions/{id}","summary":"A write the assistant proposed, and its state","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantConfig","method":"GET","path":"/qqq/v1/assistant/config","summary":"Whether the assistant is available to this user, its persona and limits","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantConversations","method":"GET","path":"/qqq/v1/assistant/conversations","summary":"The user's own conversations","tag":"assistant","pathParams":[],"queryParams":["status"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantConversationsById","method":"GET","path":"/qqq/v1/assistant/conversations/{id}","summary":"A conversation and a page of its messages","tag":"assistant","pathParams":["id"],"queryParams":["before","limit"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantTurnsActive","method":"GET","path":"/qqq/v1/assistant/turns/active","summary":"Find the user's active assistant runs","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssistantTurnsByTurnIdEvents","method":"GET","path":"/qqq/v1/assistant/turns/{turnId}/events","summary":"Resume a turn's events after a sequence number (replay, then the live tail)","tag":"assistant","pathParams":["turnId"],"queryParams":["after"],"bodyTypes":[],"extension":"assistant"},
{"id":"getAssociatedScriptLogs","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}/{scriptRevisionId}/logs","summary":"Get the logs of a record's associated script revision (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","scriptRevisionId"],"queryParams":[],"bodyTypes":[]},
{"id":"getAuthenticationMetaData","method":"GET","path":"/qqq/v1/metaData/authentication","summary":"Get authentication metaData","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getAvatar","method":"GET","path":"/qqq/v1/avatars/{avatarId}","summary":"Get an avatar","tag":"Authentication","pathParams":["avatarId"],"queryParams":["size","v"],"bodyTypes":[]},
{"id":"getBrandingAsset","method":"GET","path":"/qqq/v1/branding/{role}","summary":"Get a branding image","tag":"Authentication","pathParams":["role"],"queryParams":[],"bodyTypes":[]},
{"id":"getBulkLoadUploadProfile","method":"GET","path":"/qqq/v1/bulkLoad/uploads/{uploadRef}/profile","summary":"Profile an uploaded file for a bulk load","tag":"Bulk Load","pathParams":["uploadRef"],"queryParams":["tableName","sampleCount"],"bodyTypes":[]},
{"id":"getDeliveryOutput","method":"GET","path":"/qqq/v1/delivery/output/{logId}","summary":"Download stored output as its owner or delivery administrator","tag":"General","pathParams":["logId"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbDeadLettersByTrigger","method":"GET","path":"/qqq/v1/esb/deadLetters/{trigger}","summary":"Dead letters of a trigger","tag":"ESB","pathParams":["trigger"],"queryParams":["offset","limit"],"bodyTypes":[]},
{"id":"getEsbMessagesByDestination","method":"GET","path":"/qqq/v1/esb/messages/{destination}","summary":"Messages on a destination","tag":"ESB","pathParams":["destination"],"queryParams":["trigger","offset","limit"],"bodyTypes":[]},
{"id":"getEsbOverview","method":"GET","path":"/qqq/v1/esb/overview","summary":"ESB overview","tag":"ESB","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbProcessByProcess","method":"GET","path":"/qqq/v1/esb/process/{process}","summary":"ESB of a process","tag":"ESB","pathParams":["process"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbTableByTable","method":"GET","path":"/qqq/v1/esb/table/{table}","summary":"ESB of a table","tag":"ESB","pathParams":["table"],"queryParams":[],"bodyTypes":[]},
{"id":"getEsbDeadLettersByTrigger","method":"GET","path":"/qqq/v1/esb/deadLetters/{trigger}","summary":"Dead letters of a trigger","tag":"ESB","pathParams":["trigger"],"queryParams":["offset","limit"],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbMessagesByDestination","method":"GET","path":"/qqq/v1/esb/messages/{destination}","summary":"Messages on a destination","tag":"ESB","pathParams":["destination"],"queryParams":["trigger","offset","limit"],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbOverview","method":"GET","path":"/qqq/v1/esb/overview","summary":"ESB overview","tag":"ESB","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbProcessByProcess","method":"GET","path":"/qqq/v1/esb/process/{process}","summary":"ESB of a process","tag":"ESB","pathParams":["process"],"queryParams":[],"bodyTypes":[],"extension":"esb"},
{"id":"getEsbTableByTable","method":"GET","path":"/qqq/v1/esb/table/{table}","summary":"ESB of a table","tag":"ESB","pathParams":["table"],"queryParams":[],"bodyTypes":[],"extension":"esb"},
{"id":"getHealthChecksByNameHistory","method":"GET","path":"/qqq/v1/health/checks/{name}/history","summary":"Health history","tag":"Health","pathParams":["name"],"queryParams":["from","to","limit"],"bodyTypes":[],"extension":"health"},
{"id":"getHealthComponentsByName","method":"GET","path":"/qqq/v1/health/components/{name}","summary":"Health component","tag":"Health","pathParams":["name"],"queryParams":[],"bodyTypes":[],"extension":"health"},
{"id":"getHealthSummary","method":"GET","path":"/qqq/v1/health/summary","summary":"Health summary","tag":"Health","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"health"},
{"id":"getHelpCoverage","method":"GET","path":"/qqq/v1/help/coverage","summary":"Inspect application help coverage","tag":"General","pathParams":[],"queryParams":["kind","missingOnly","parentKey"],"bodyTypes":[]},
{"id":"getJob","method":"GET","path":"/qqq/v1/jobs/{jobId}","summary":"Jobs: detail","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"getJobProgress","method":"GET","path":"/qqq/v1/jobs/{jobId}/progress","summary":"Jobs: progress","tag":"Processes","pathParams":["jobId"],"queryParams":["afterLogId","logLimit"],"bodyTypes":[]},
@@ -47,6 +101,37 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"getProcessMetaData","method":"GET","path":"/qqq/v1/metaData/process/{processName}","summary":"Get process metaData","tag":"Processes","pathParams":["processName"],"queryParams":[],"bodyTypes":[]},
{"id":"getProcessRecords","method":"GET","path":"/qqq/v1/processes/{processName}/{processUUID}/records","summary":"Get records from a process","tag":"Processes","pathParams":["processName","processUUID"],"queryParams":["skip","limit","tableVariant"],"bodyTypes":[]},
{"id":"getProcessStatus","method":"GET","path":"/qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}","summary":"Get job status","tag":"Processes","pathParams":["processName","processUUID","jobUUID"],"queryParams":["tableVariant"],"bodyTypes":[]},
{"id":"getQbitCollaborationAccessRequestsMine","method":"GET","path":"/qqq/v1/qbit/collaboration/access-requests/mine","summary":"List my access requests","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationAttachmentsByIdDownload","method":"GET","path":"/qqq/v1/qbit/collaboration/attachments/{id}/download","summary":"Get a temporary download URL; clients follow url","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationAttachmentsByIdPreview","method":"GET","path":"/qqq/v1/qbit/collaboration/attachments/{id}/preview","summary":"Get a temporary preview URL; clients follow url","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationCommentsByIdHistory","method":"GET","path":"/qqq/v1/qbit/collaboration/comments/{id}/history","summary":"Read comment edit history","tag":"collaboration","pathParams":["id"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationFavorites","method":"GET","path":"/qqq/v1/qbit/collaboration/favorites","summary":"List personal favorites","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationMentionsCandidates","method":"GET","path":"/qqq/v1/qbit/collaboration/mentions/candidates","summary":"Find people to mention on a record","tag":"collaboration","pathParams":[],"queryParams":["table","pk","q","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationMyWork","method":"GET","path":"/qqq/v1/qbit/collaboration/my-work","summary":"Get my work summary","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeople","method":"GET","path":"/qqq/v1/qbit/collaboration/people","summary":"Search the active people directory","tag":"collaboration","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeopleByUserId","method":"GET","path":"/qqq/v1/qbit/collaboration/people/{userId}","summary":"Get an active directory person","tag":"collaboration","pathParams":["userId"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecents","method":"GET","path":"/qqq/v1/qbit/collaboration/recents","summary":"List recent targets","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkAttachments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"List record attachments","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkComments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Page record comment threads","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkFollow","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Get the caller's follow state","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkFollowers","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/followers","summary":"List record followers","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkLink","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/link","summary":"Get a record link","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkTags","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tags","summary":"List applied record tags","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkTasks","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tasks","summary":"List record tasks","tag":"collaboration","pathParams":["table","pk"],"queryParams":["includeDone","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkTimeline","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/timeline","summary":"Page the merged record timeline","tag":"collaboration","pathParams":["table","pk"],"queryParams":["before","types","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationTags","method":"GET","path":"/qqq/v1/qbit/collaboration/tags","summary":"Search table tag vocabulary","tag":"collaboration","pathParams":[],"queryParams":["table","q","limit"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationTasksMine","method":"GET","path":"/qqq/v1/qbit/collaboration/tasks/mine","summary":"List my tasks","tag":"collaboration","pathParams":[],"queryParams":["status","limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitDataAnalysisRunsByRunId","method":"GET","path":"/qqq/v1/qbit/data-analysis/runs/{runId}","summary":"Get an exact profile run","tag":"data-analysis","pathParams":["runId"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitDataAnalysisSaved","method":"GET","path":"/qqq/v1/qbit/data-analysis/saved","summary":"List visible saved analyses","tag":"data-analysis","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitDataAnalysisSavedByIdExport","method":"GET","path":"/qqq/v1/qbit/data-analysis/saved/{id}/export","summary":"Export a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":["format"],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitDataAnalysisTablesByTableCapabilities","method":"GET","path":"/qqq/v1/qbit/data-analysis/tables/{table}/capabilities","summary":"Check analysis availability","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"getQbitEdiGuidesByGuideIdTree","method":"GET","path":"/qqq/v1/qbit/edi/guides/{guideId}/tree","summary":"Read the guide tree","tag":"edi","pathParams":["guideId"],"queryParams":[],"bodyTypes":[],"extension":"edi"},
{"id":"getQbitEdiMapsByMapIdTarget","method":"GET","path":"/qqq/v1/qbit/edi/maps/{mapId}/target","summary":"Read target metadata and map rules","tag":"edi","pathParams":["mapId"],"queryParams":[],"bodyTypes":[],"extension":"edi"},
{"id":"getQbitSecuredDataRevealOptions","method":"GET","path":"/qqq/v1/qbit/secured-data/reveal/options","summary":"RevealOptions","tag":"secured-data","pathParams":[],"queryParams":["tableName","fieldName"],"bodyTypes":[],"extension":"secured-data"},
{"id":"getQbitSecuredDataStatus","method":"GET","path":"/qqq/v1/qbit/secured-data/status","summary":"Status","tag":"secured-data","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"secured-data"},
{"id":"getQuickSearchStatus","method":"GET","path":"/qqq/v1/qbit/quick-search/status","summary":"Get Quick Search status","tag":"quick-search","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"quick-search"},
{"id":"getQuickSearchSuggestions","method":"GET","path":"/qqq/v1/qbit/quick-search/suggest","summary":"Get Quick Search suggestions","tag":"quick-search","pathParams":[],"queryParams":["q","limitPerTable","maxTables","tables"],"bodyTypes":[],"extension":"quick-search"},
{"id":"getRecord","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Get a record from a table by primary key","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["includeAssociations","queryJoins","tableVariant"],"bodyTypes":[]},
{"id":"getRecordDeveloperMode","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer","summary":"Get a record's associated scripts (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
{"id":"getRecordFeatures","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/features","summary":"Get record collaboration features","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
@@ -68,9 +153,61 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"manageSession","method":"POST","path":"/qqq/v1/manageSession","summary":"Create a session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"oidcBackChannelLogout","method":"POST","path":"/qqq/v1/oidc/backchannel-logout","summary":"OIDC Back-Channel Logout","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"openRealtimeStream","method":"GET","path":"/qqq/v1/realtime/stream","summary":"Open the realtime event stream","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
{"id":"patchAssistantConversationsById","method":"PATCH","path":"/qqq/v1/assistant/conversations/{id}","summary":"Rename, pin, archive or mark read a conversation","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"patchQbitCollaborationAttachmentsById","method":"PATCH","path":"/qqq/v1/qbit/collaboration/attachments/{id}","summary":"Rename or describe an attachment","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"patchQbitCollaborationCommentsById","method":"PATCH","path":"/qqq/v1/qbit/collaboration/comments/{id}","summary":"Edit the caller's comment","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"patchQbitCollaborationFavoritesOrder","method":"PATCH","path":"/qqq/v1/qbit/collaboration/favorites/order","summary":"Reorder personal favorites","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"patchQbitCollaborationTasksById","method":"PATCH","path":"/qqq/v1/qbit/collaboration/tasks/{id}","summary":"Update a task","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postAssistantActionsByIdConfirm","method":"POST","path":"/qqq/v1/assistant/actions/{id}/confirm","summary":"Confirm a proposed write: it runs once, through the UI API, with this request's session","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"postAssistantActionsByIdReject","method":"POST","path":"/qqq/v1/assistant/actions/{id}/reject","summary":"Cancel a proposed write; nothing changes","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"postAssistantClientErrors","method":"POST","path":"/qqq/v1/assistant/client-errors","summary":"Report an error the client hit while showing the assistant (stored for administrators, rate limited per user)","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantConversations","method":"POST","path":"/qqq/v1/assistant/conversations","summary":"Start a conversation","tag":"assistant","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantConversationsByIdTurns","method":"POST","path":"/qqq/v1/assistant/conversations/{id}/turns","summary":"Ask: start a turn and stream the reply as server-sent events","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantMessagesByIdFeedback","method":"POST","path":"/qqq/v1/assistant/messages/{id}/feedback","summary":"Thumbs up or down on an assistant message","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"assistant"},
{"id":"postAssistantTurnsByTurnIdCancel","method":"POST","path":"/qqq/v1/assistant/turns/{turnId}/cancel","summary":"Stop a running turn","tag":"assistant","pathParams":["turnId"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
{"id":"postHealthChecksByNameRun","method":"POST","path":"/qqq/v1/health/checks/{name}/run","summary":"Health run","tag":"Health","pathParams":["name"],"queryParams":[],"bodyTypes":[],"extension":"health"},
{"id":"postQbitCollaborationAccessRequests","method":"POST","path":"/qqq/v1/qbit/collaboration/access-requests","summary":"Request access to a record","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationCommentsByIdReopen","method":"POST","path":"/qqq/v1/qbit/collaboration/comments/{id}/reopen","summary":"Reopen a comment thread","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"postQbitCollaborationCommentsByIdResolve","method":"POST","path":"/qqq/v1/qbit/collaboration/comments/{id}/resolve","summary":"Resolve a comment thread","tag":"collaboration","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"postQbitCollaborationPresence","method":"POST","path":"/qqq/v1/qbit/collaboration/presence","summary":"Renew presence","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecents","method":"POST","path":"/qqq/v1/qbit/collaboration/recents","summary":"Record a recent view","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkAttachments","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"Upload an attachment","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["multipart/form-data"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkComments","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Post a comment or reply","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkShares","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/shares","summary":"Share a record","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkSharesPreview","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/shares/preview","summary":"Preview share recipients","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationRecordsByTableByPkTasks","method":"POST","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tasks","summary":"Create a task","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitCollaborationTags","method":"POST","path":"/qqq/v1/qbit/collaboration/tags","summary":"Create or suggest a tag","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"postQbitDataAnalysisExport","method":"POST","path":"/qqq/v1/qbit/data-analysis/export","summary":"Export an analysis result","tag":"data-analysis","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisRuns","method":"POST","path":"/qqq/v1/qbit/data-analysis/runs","summary":"Start an exact profile run","tag":"data-analysis","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisSavedByIdRun","method":"POST","path":"/qqq/v1/qbit/data-analysis/saved/{id}/run","summary":"Run a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableColumnProfile","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/column-profile","summary":"Profile one column","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableCompare","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/compare","summary":"Compare two populations","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableCorrelation","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/correlation","summary":"Find numeric relationships","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableCrosstab","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/crosstab","summary":"Cross-tabulate two fields","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableDistinctValues","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/distinct-values","summary":"List distinct column values","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableDuplicates","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/duplicates","summary":"Find duplicate groups","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTablePivot","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/pivot","summary":"Explore a pivot table","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableScatter","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/scatter","summary":"Inspect a numeric relationship","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableShift","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/shift","summary":"Measure distribution shift over time","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableSql","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/sql","summary":"Read an analysis aggregate SQL statement","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisTablesByTableTableProfile","method":"POST","path":"/qqq/v1/qbit/data-analysis/tables/{table}/table-profile","summary":"Profile table completeness and keys","tag":"data-analysis","pathParams":["table"],"queryParams":[],"bodyTypes":["application/json"],"extension":"data-analysis"},
{"id":"postQbitDataAnalysisWatchesByIdPreview","method":"POST","path":"/qqq/v1/qbit/data-analysis/watches/{id}/preview","summary":"Preview an anomaly watch","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"postQbitEdiMapsByMapIdValidate","method":"POST","path":"/qqq/v1/qbit/edi/maps/{mapId}/validate","summary":"Validate unsaved map rules","tag":"edi","pathParams":["mapId"],"queryParams":[],"bodyTypes":["application/json"],"extension":"edi"},
{"id":"postQbitEdiSandboxPreview","method":"POST","path":"/qqq/v1/qbit/edi/sandbox/preview","summary":"Preview an encrypted sample with optional unsaved rules","tag":"edi","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"edi"},
{"id":"postQbitSecuredDataReveal","method":"POST","path":"/qqq/v1/qbit/secured-data/reveal","summary":"Reveal","tag":"secured-data","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"secured-data"},
{"id":"prepareRelatedRecordPicker","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/prepare","summary":"Prepare child rows for picked records (MULTI_CHILD, LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"putQbitCollaborationFavorites","method":"PUT","path":"/qqq/v1/qbit/collaboration/favorites","summary":"Star a target","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitCollaborationRecordsByTableByPkAssignment","method":"PUT","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/assignment","summary":"Set record owner and assignee","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitCollaborationRecordsByTableByPkFollow","method":"PUT","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Follow a record and configure notifications","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitCollaborationRecordsByTableByPkTags","method":"PUT","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/tags","summary":"Add and remove record tags","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
{"id":"putQbitDataAnalysisSavedByIdStar","method":"PUT","path":"/qqq/v1/qbit/data-analysis/saved/{id}/star","summary":"Star a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
{"id":"qaCaptureCapability","method":"GET","path":"/qqq/v1/qbit/qa-capture/capability","summary":"Read the QA capture capability","tag":"","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"qa-capture"},
{"id":"qaCaptureReportCreate","method":"POST","path":"/qqq/v1/qbit/qa-capture/report","summary":"Create a QA report","tag":"","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"qa-capture"},
{"id":"queryRelatedRecordPicker","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/query","summary":"Query the records a related-record picker offers","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"queryTable","method":"POST","path":"/qqq/v1/table/{tableName}/query","summary":"Query for records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"releaseNotesReleases","method":"GET","path":"/qqq/v1/qbit/release-notes/releases","summary":"Published release notes","tag":"release-notes","pathParams":[],"queryParams":["limit","offset"],"bodyTypes":[],"extension":"release-notes"},
{"id":"releaseNotesSeen","method":"POST","path":"/qqq/v1/qbit/release-notes/seen","summary":"Mark What's new as seen","tag":"release-notes","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"release-notes"},
{"id":"releaseNotesSummary","method":"GET","path":"/qqq/v1/qbit/release-notes/summary","summary":"What's new summary","tag":"release-notes","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"release-notes"},
{"id":"removeRelatedRecords","method":"POST","path":"/qqq/v1/table/{tableName}/relatedRecordPicker/{pickerName}/remove","summary":"Unlink picked records (LINK)","tag":"Tables","pathParams":["tableName","pickerName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"retryJob","method":"POST","path":"/qqq/v1/jobs/{jobId}/retry","summary":"Jobs: retry","tag":"Processes","pathParams":["jobId"],"queryParams":[],"bodyTypes":[]},
{"id":"runFormAdjuster","method":"POST","path":"/qqq/v1/table/{tableName}/formAdjuster/{identifier}/{event}","summary":"Run a form adjuster","tag":"Tables","pathParams":["tableName","identifier","event"],"queryParams":[],"bodyTypes":["application/json"]},
@@ -81,6 +218,27 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"searchProcessPossibleValues","method":"POST","path":"/qqq/v1/processes/{processName}/possibleValues/{fieldName}","summary":"Search possible values for a process field","tag":"Processes","pathParams":["processName","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchRecords","method":"POST","path":"/qqq/v1/search","summary":"Search records across tables","tag":"Tables","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"searchTablePossibleValues","method":"POST","path":"/qqq/v1/table/{tableName}/possibleValues/{fieldName}","summary":"Search possible values for a table field","tag":"Tables","pathParams":["tableName","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"sqlWorkbenchCancelExecution","method":"POST","path":"/qqq/v1/sql-workbench/executions/{uuid}/cancel","summary":"Cancel the caller's running execution","tag":"sql-workbench","pathParams":["uuid"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchCreateNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks","summary":"Create a personal notebook","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchCreateSnapshot","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/snapshots","summary":"Pin the caller's cached result to an owned notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchDeleteNotebook","method":"DELETE","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Delete an owned notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchDeleteSnapshot","method":"DELETE","path":"/qqq/v1/sql-workbench/notebooks/{id}/snapshots/{snapshotId}","summary":"Delete a snapshot from an owned notebook","tag":"sql-workbench","pathParams":["id","snapshotId"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchDuplicateNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/duplicate","summary":"Make a personal copy of a visible notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchExecute","method":"POST","path":"/qqq/v1/sql-workbench/execute","summary":"Execute read-only SQL or preview and confirm a gated write","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchExplain","method":"POST","path":"/qqq/v1/sql-workbench/explain","summary":"Explain SQL; ANALYZE remains subject to write gates","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchExportResult","method":"GET","path":"/qqq/v1/sql-workbench/executions/{uuid}/export","summary":"Export the caller's cached result as CSV, JSON or XLSX","tag":"sql-workbench","pathParams":["uuid"],"queryParams":["format"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetCapabilities","method":"GET","path":"/qqq/v1/sql-workbench/capabilities","summary":"Get caller capabilities and configured limits","tag":"sql-workbench","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetHistory","method":"GET","path":"/qqq/v1/sql-workbench/history","summary":"List the caller's recent audited executions","tag":"sql-workbench","pathParams":[],"queryParams":["limit","backendName"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetNotebook","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Get a visible notebook and its cells","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetRows","method":"GET","path":"/qqq/v1/sql-workbench/executions/{uuid}/rows","summary":"Page or sort the caller's cached result","tag":"sql-workbench","pathParams":["uuid"],"queryParams":["offset","limit","sort","dir"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetSchema","method":"GET","path":"/qqq/v1/sql-workbench/schema/{backendName}","summary":"Inspect a target backend schema","tag":"sql-workbench","pathParams":["backendName"],"queryParams":["refresh"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchGetVersion","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions/{n}","summary":"Get a visible notebook version and saved content","tag":"sql-workbench","pathParams":["id","n"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchListNotebooks","method":"GET","path":"/qqq/v1/sql-workbench/notebooks","summary":"List visible personal or shared notebooks","tag":"sql-workbench","pathParams":[],"queryParams":["scope"],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchListSnapshots","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}/snapshots","summary":"List snapshots of a visible notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchListVersions","method":"GET","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions","summary":"List versions of a visible notebook","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"sql-workbench"},
{"id":"sqlWorkbenchPublishNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/publish","summary":"Publish or withdraw an owned notebook with share permission","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchRestoreVersion","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions/{n}/restore","summary":"Restore an owned notebook version as a new version","tag":"sql-workbench","pathParams":["id","n"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"sqlWorkbenchUpdateNotebook","method":"PUT","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Update an owned notebook and record a version","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
{"id":"stepProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}","summary":"Run a step in a process","tag":"Processes","pathParams":["processName","processUUID","stepName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
{"id":"storeAssociatedScript","method":"POST","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}","summary":"Store a new revision of a record's associated script (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
{"id":"suggestBulkLoadMapping","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/suggestMapping","summary":"Suggest a bulk load mapping for an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
+68
View File
@@ -0,0 +1,68 @@
# Extensions in the kernel
Extensions are the qbits and modules that add UI API operations (`/qqq/v1/qbit/{name}/...`, or an
established prefix such as `/esb`). The client document carries every extension found when it was
generated; a server has only some of them installed and lists them in `GET /version`.
## Registry (`registry.ts`)
```ts
import { extensionRegistry, KNOWN_EXTENSIONS, operationsOf, ExtensionNotInstalledError } from '@qrun-io/qqq-core'
const registry = await extensionRegistry(server, authenticatedFetch) // GET /version, cached 5 min with the metadata
registry.has('collaboration') // installed at a version the client works with
registry.version('esb') // '1.1.0' | undefined
registry.require('collaboration') // throws ExtensionNotInstalledError:
// "extension collaboration is not installed on this server (installed: esb 1.1.0)"
registry.requireOperation(id) // same check for an operation id; core operations pass
registry.installed // every extension the server lists, `known: false` for ones the client lacks
registry.describe() // 'esb 1.1.0, zebra 0.3.0 (unknown to this client)'
```
- `KNOWN_EXTENSIONS`: name, label, path prefix, the version the document was generated from,
`minVersion` where the extension's docs state one (esb, health and collaboration 1.1.0), and
`channels`: the realtime channels it publishes on (collaboration: `record:{table}:{pk}`,
`table:{table}`, `user:me`).
- `operationsOf(name)` and `OperationInfo.extension` map operations to extensions.
- Unknown extensions are never dropped. An installed extension below `minVersion` gives
`has() === false` and `require()` throws with `reason: 'too-old'`.
- `ExtensionNotInstalledError` is the CLI's exit 5. The TUI should hide what `has()` denies
instead of showing the error.
## Collaboration (`collaboration.ts`)
```ts
const collab = new Collaboration(registry, {
call: (id, input) => callOperation(server, id, input, authenticatedFetch),
openStream: (signal) => openRealtimeStream({ baseUrl: server, signal, headers, token }), // optional
})
const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
onChange: (state) => draw(state.count, state.people), // people: [{ userId, name, mode, fieldName, since }]
mode: 'VIEWING', // or 'EDITING' (setMode('EDITING', 'email') while a field has focus)
announce: true, // false: watch without appearing
})
presence.count; presence.people; presence.state.live; presence.state.error
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
```
- Without the extension `joinPresence` returns at once with `available: false`, nobody there,
and no requests; `stop()` is a no-op. A TUI can call it for every screen unconditionally.
- With it: POST `/presence` now and every 20 s (leases last 45 s), GET the snapshot, and, when
`openStream` is given, subscribe to `record:{t}:{pk}` or `table:{t}` on the stream's `hello`
and apply `presence.changed` events. Without a stream, each heartbeat re-reads the snapshot.
The stream reconnects with backoff (2 s doubling to 30 s) and re-reads on `resync`.
- `since` is when this client first saw the person; the qbit does not send join times.
- The list includes the signed-in user when they hold a lease (tabs and clients merge per user;
EDITING wins).
- One stream per joinPresence. A TUI that already holds a stream for other channels can pass an
`openStream` that fans out its own connection's events instead of opening another.
- `state.error` holds a refused heartbeat (404 when presence is off for the table or the record is
not visible). The heartbeat keeps trying.
Other calls (all throw `ExtensionNotInstalledError` before any request when it is absent):
`favorites()`, `addFavorite(target)`, `removeFavorite(target)`, `recents(limit)`,
`recordView(target)`, `comments(table, pk, { cursor, limit })`, `addComment(table, pk, body,
{ parentCommentId, fieldName })`, `followState`, `follow(table, pk, prefs)`, `unfollow`,
`myWork()`, `people(q, limit)`, `presenceSnapshot(scope)` (empty when absent). Everything else the
qbit offers is reachable by operation id through `callOperation`.
@@ -0,0 +1,392 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The collaboration extension (qbit-collaboration, `/qqq/v1/qbit/collaboration`) for both heads:
* live presence on a table or record, comments, favorites, recents, follow, people and my work.
*
* Presence follows the qbit's contract: a lease is renewed with POST /presence every 20 seconds
* (it expires after 45), DELETE /presence leaves, GET /presence is the snapshot, and
* `presence.changed` events carry the same snapshot on the `record:{table}:{pk}` and
* `table:{table}` realtime channels. Connection ids need not be stream connections, so a client
* without the stream still takes part by polling on each heartbeat.
*
* When the server does not have the extension, presence is a no-op (`available: false`, nobody
* there) and the other calls throw ExtensionNotInstalledError.
*/
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import type { ExtensionRegistry } from './registry.js'
export const COLLABORATION = 'collaboration'
/** How the module reaches the server: the kernel's callOperation, and optionally the realtime stream. */
export interface CollaborationTransport {
call(id: string, input: CallInput): Promise<CallResult>
/** Opens GET /realtime/stream; without it presence is polled on each heartbeat. */
openStream?(signal: AbortSignal): AsyncIterable<RealtimeMessage>
}
/** A navigation target (favorites and recents). */
export interface CollaborationTarget {
targetType: 'TABLE' | 'APP' | 'RECORD' | 'PROCESS' | 'REPORT' | 'SAVED_VIEW'
targetName: string
recordId?: string
}
export interface FavoriteEntry {
id?: number
targetType?: string
targetName?: string
recordId?: string | null
label?: string
folder?: string | null
sortOrder?: number | null
available?: boolean
href?: string | null
}
export interface CommentEntry {
id?: number
authorUserId?: string
authorDisplayName?: string
body?: string | null
createdAt?: string
editedAt?: string | null
resolvedAt?: string | null
deleted?: boolean
fieldName?: string | null
replies?: CommentEntry[]
[key: string]: unknown
}
export interface CommentPage {
threads: CommentEntry[]
nextCursor?: string | null
}
export interface FollowState {
following: boolean
follow?: { notifyOnChange?: boolean; notifyOnComment?: boolean; reason?: string; mutedUntil?: string | null } | null
}
export interface PersonEntry {
userId?: string
displayName?: string
email?: string | null
lastSeenAt?: string | null
}
export interface MyWork {
openTaskCount?: number
unreadMentionCount?: number
assignedRecordsByTable?: Record<string, number>
tasks?: Record<string, unknown>[]
}
/** What presence is attached to: a table, or one record of it. */
export interface PresenceScope {
table: string
pk?: string
}
/** One person on the scope, merged across their tabs. */
export interface PresencePerson {
userId: string
name: string
mode: 'VIEWING' | 'EDITING' | string
fieldName?: string | null
/** When this client first saw them there (the qbit does not send join times). */
since: Date
}
export interface PresenceState {
/** False when the server has no collaboration extension: nobody is ever shown. */
available: boolean
scope: PresenceScope
count: number
people: PresencePerson[]
/** True while the realtime stream is connected and subscribed (else presence is polled). */
live: boolean
/** The last error (presence not enabled on the table, record not visible, ...), cleared on success. */
error?: string
}
export interface PresenceOptions {
/** Called after every change of the state. */
onChange?: (state: PresenceState) => void
/** VIEWING (default) or EDITING. */
mode?: 'VIEWING' | 'EDITING'
fieldName?: string
/** Heartbeat period; default 20000 ms as the qbit asks. */
heartbeatMs?: number
/** Connection id for the lease; default a random `cli-...` id. */
connectionId?: string
/** False to only watch (snapshot plus events) without holding a lease. Default true. */
announce?: boolean
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
reconnectMs?: number
}
export interface PresenceHandle {
readonly state: PresenceState
readonly count: number
readonly people: PresencePerson[]
/** Re-reads the snapshot now. */
refresh(): Promise<void>
/** Changes mode or focused field and renews the lease at once. */
setMode(mode: 'VIEWING' | 'EDITING', fieldName?: string): Promise<void>
/** Leaves (DELETE /presence), stops the heartbeat and closes the stream. */
stop(): Promise<void>
}
interface Viewer {
userId?: string
displayName?: string
mode?: string
fieldName?: string | null
}
interface Snapshot {
table?: string
pk?: string | null
viewers?: Viewer[]
}
/** The channel presence events for a scope arrive on. */
export function presenceChannel(scope: PresenceScope): string {
return scope.pk !== undefined ? `record:${scope.table}:${scope.pk}` : `table:${scope.table}`
}
function randomId(): string {
const bytes = new Uint8Array(8)
globalThis.crypto.getRandomValues(bytes)
return `cli-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The collaboration calls for one server; gated by its extension registry. */
export class Collaboration {
constructor(private readonly registry: ExtensionRegistry, private readonly transport: CollaborationTransport) {}
/** Whether the server has the extension (presence works, the other calls do not throw). */
get available(): boolean {
return this.registry.has(COLLABORATION)
}
private async data<T>(id: string, input: CallInput = {}): Promise<T> {
this.registry.require(COLLABORATION)
return (await this.transport.call(id, input)).data as T
}
/** The signed-in user's favorites, in their order. */
async favorites(): Promise<FavoriteEntry[]> {
return (await this.data<{ items?: FavoriteEntry[] }>('getQbitCollaborationFavorites')).items ?? []
}
/** Stars a target (idempotent). */
async addFavorite(target: CollaborationTarget): Promise<void> {
await this.data('putQbitCollaborationFavorites', { json: target })
}
/** Unstars a target (idempotent). */
async removeFavorite(target: CollaborationTarget): Promise<void> {
await this.data('deleteQbitCollaborationFavorites', { json: target })
}
/** Recently viewed targets, newest first (at most 50). */
async recents(limit?: number): Promise<FavoriteEntry[]> {
return (await this.data<{ items?: FavoriteEntry[] }>('getQbitCollaborationRecents', { query: { limit: limit === undefined ? undefined : String(limit) } })).items ?? []
}
/** Records a view of a target (the server ignores repeats within a minute). */
async recordView(target: CollaborationTarget): Promise<void> {
await this.data('postQbitCollaborationRecents', { json: target })
}
/** A page of a record's comment threads, by latest activity, with replies. */
async comments(table: string, pk: string, page: { cursor?: string; limit?: number } = {}): Promise<CommentPage> {
const result = await this.data<{ threads?: CommentEntry[]; nextCursor?: string | null }>('getQbitCollaborationRecordsByTableByPkComments', {
path: { table, pk },
query: { cursor: page.cursor, limit: page.limit === undefined ? undefined : String(page.limit) },
})
return { threads: result.threads ?? [], nextCursor: result.nextCursor ?? null }
}
/** Posts a comment, or a reply with `parentCommentId`. Mentions are `@[Name](user:id)`. */
async addComment(table: string, pk: string, body: string, options: { parentCommentId?: number; fieldName?: string } = {}): Promise<CommentEntry> {
return this.data<CommentEntry>('postQbitCollaborationRecordsByTableByPkComments', { path: { table, pk }, json: { body, ...options } })
}
/** The signed-in user's follow state on a record. */
async followState(table: string, pk: string): Promise<FollowState> {
return this.data<FollowState>('getQbitCollaborationRecordsByTableByPkFollow', { path: { table, pk } })
}
/** Follows a record; omitted preferences keep their current values. */
async follow(table: string, pk: string, preferences: { notifyOnChange?: boolean; notifyOnComment?: boolean } = {}): Promise<FollowState> {
return this.data<FollowState>('putQbitCollaborationRecordsByTableByPkFollow', { path: { table, pk }, json: preferences })
}
/** Stops following a record. */
async unfollow(table: string, pk: string): Promise<FollowState> {
return this.data<FollowState>('deleteQbitCollaborationRecordsByTableByPkFollow', { path: { table, pk } })
}
/** Open tasks, assigned records per table and unread mentions of the signed-in user. */
async myWork(): Promise<MyWork> {
return this.data<MyWork>('getQbitCollaborationMyWork')
}
/** Searches the active people directory by name or email. */
async people(q?: string, limit?: number): Promise<PersonEntry[]> {
return (await this.data<{ items?: PersonEntry[] }>('getQbitCollaborationPeople', { query: { q, limit: limit === undefined ? undefined : String(limit) } })).items ?? []
}
/** Who is on a table or record right now, without joining. Empty when the extension is absent. */
async presenceSnapshot(scope: PresenceScope): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
if (!state.available) return state
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot
const now = new Date()
state.people = (snapshot.viewers ?? []).map((v) => ({ userId: String(v.userId ?? ''), name: String(v.displayName ?? v.userId ?? ''), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: now }))
state.count = state.people.length
return state
}
/**
* Joins presence on a table or record: holds a lease (unless `announce: false`), subscribes to
* the scope's realtime channel when a stream is available, and keeps `state` current. Returns
* at once with a no-op handle when the extension is absent.
*/
async joinPresence(scope: PresenceScope, options: PresenceOptions = {}): Promise<PresenceHandle> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
const notify = () => options.onChange?.({ ...state, people: [...state.people] })
if (!state.available) {
return { state, get count() { return 0 }, get people() { return [] }, refresh: async () => {}, setMode: async () => {}, stop: async () => {} }
}
const announce = options.announce !== false
const connectionId = options.connectionId ?? randomId()
const firstSeen = new Map<string, Date>()
let mode = options.mode ?? 'VIEWING'
let fieldName = options.fieldName
let stopped = false
const abort = new AbortController()
const apply = (snapshot: Snapshot) => {
const now = new Date()
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const ids = new Set(viewers.map((v) => String(v.userId)))
for (const id of [...firstSeen.keys()]) if (!ids.has(id)) firstSeen.delete(id)
state.people = viewers.map((v) => {
const userId = String(v.userId)
if (!firstSeen.has(userId)) firstSeen.set(userId, now)
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen.get(userId)! }
})
state.count = state.people.length
state.error = undefined
notify()
}
const refresh = async () => {
try {
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
} catch (error) {
state.error = messageOf(error)
notify()
}
}
const heartbeat = async () => {
if (stopped) return
if (announce) {
try {
const result = await this.transport.call('postQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk, mode, fieldName } })
const data = result.data as Snapshot | undefined
// The qbit answers the heartbeat with the snapshot; older builds answer {success}.
if (data && typeof data.table === 'string') {
apply(data)
return
}
} catch (error) {
state.error = messageOf(error)
notify()
return
}
}
await refresh()
}
const subscribe = async () => {
if (!this.transport.openStream) return
let delay = options.reconnectMs ?? 2000
while (!stopped) {
try {
for await (const message of this.transport.openStream(abort.signal)) {
if (stopped) break
if (message.event === 'hello') {
const hello = (message.data ?? {}) as { connectionId?: string }
const result = await this.transport.call('updateRealtimeSubscriptions', { json: { connectionId: hello.connectionId, add: [presenceChannel(scope)] } })
const active = ((result.data ?? {}) as { active?: string[] }).active ?? []
state.live = active.includes(presenceChannel(scope))
delay = options.reconnectMs ?? 2000
await refresh()
} else if (message.event === 'presence.changed') {
// An emptied scope arrives as {"table": "person"} with no viewers at all.
const raw = message.data as Snapshot & { data?: Snapshot }
const snapshot = raw && typeof raw.table === 'string' ? raw : raw?.data
if (snapshot && snapshot.table === scope.table && (scope.pk === undefined ? snapshot.pk == null : String(snapshot.pk) === scope.pk)) apply(snapshot)
} else if (message.event === 'resync') {
await refresh()
}
}
} catch (error) {
if (stopped) break
state.error = messageOf(error)
}
if (stopped) break
state.live = false
notify()
await new Promise((resolve) => setTimeout(resolve, delay))
delay = Math.min(delay * 2, 30000)
}
}
await heartbeat()
const timer = setInterval(() => void heartbeat(), options.heartbeatMs ?? 20000)
void subscribe()
return {
state,
get count() { return state.count },
get people() { return state.people },
refresh,
setMode: async (next, field) => {
mode = next
fieldName = field
await heartbeat()
},
stop: async () => {
if (stopped) return
stopped = true
clearInterval(timer)
abort.abort()
state.live = false
if (announce) {
try {
await this.transport.call('deleteQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk } })
} catch {
// the lease expires on its own after 45 seconds
}
}
},
}
}
}
+220
View File
@@ -0,0 +1,220 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Extensions (qbits and modules that add UI API operations): which ones the client knows, which
* operations belong to each, and which ones a server has installed. The server's truth is
* GET /version `extensions`; the client's knowledge is the merged document it was generated from
* (DOCUMENT_EXTENSIONS and OperationInfo.extension) plus the facts below that the documents do not
* carry (documented minimum versions and the realtime channels an extension publishes on).
*
* Rules: an extension the server lists but the client does not know is kept and shown, never
* dropped; an operation of an extension the server does not list is refused before any request,
* with a message naming what is installed.
*/
import { DOCUMENT_EXTENSIONS, OPERATIONS, operation, type OperationInfo } from '../api/operations.js'
import { getServerVersion, type MetaDataCacheOptions, type ServerVersion } from '../metadata/cache.js'
/** What the client knows about one extension. */
export interface KnownExtension {
name: string
/** Short human name, for headings. */
label: string
/** The repository that publishes it. */
repository: string
/** Path prefix under /qqq/v1 (`/qbit/{name}`, or an established one such as `/esb`). */
pathPrefix: string
/** The extension version the client document was generated from. */
documentVersion?: string
/** The lowest server version of this extension the client works with, when its docs state one. */
minVersion?: string
/** The UI API version the extension's docs say it needs, when they say. */
minUiApi?: string
/** Realtime channels (GET /realtime/stream subscriptions) the extension publishes events on. */
channels: string[]
/** One line on what it does. */
description: string
}
/** One extension as the server lists it, joined with what the client knows. */
export interface InstalledExtension {
name: string
version?: string
pathPrefix?: string
description?: string
/** False when the client has no document for it (it is still listed). */
known: boolean
/** False when the client knows a minimum version and the server's is lower. */
meetsMinimum: boolean
}
interface Facts {
label: string
repository: string
minVersion?: string
minUiApi?: string
channels?: string[]
}
/**
* Facts from each extension's own docs (README, UI-API-CHANGELOG.md) that the OpenAPI documents
* do not carry. Minimums are only set where the docs state one.
*/
const FACTS: Record<string, Facts> = {
// UI-API-CHANGELOG.md: esb and health 1.1.0 added operationIds, tags and the standard errors.
esb: { label: 'ESB', repository: 'qqq (qqq-esb)', minVersion: '1.1.0' },
health: { label: 'Health', repository: 'qqq (qqq-middleware-javalin)', minVersion: '1.1.0' },
alerts: { label: 'Alerts', repository: 'qbit-alerts' },
assistant: { label: 'Assistant', repository: 'qbit-assistant' },
// qbit-collaboration README: 1.1.0 carries the UI API 1.6.0 error contract and the published
// presence schemas; it publishes on record:{table}:{pk}, table:{table} and user:{userId}.
collaboration: { label: 'Collaboration', repository: 'qbit-collaboration', minVersion: '1.1.0', minUiApi: '1.6.0', channels: ['record:{table}:{pk}', 'table:{table}', 'user:me'] },
'data-analysis': { label: 'Data analysis', repository: 'qbit-data-analysis' },
'db-performance': { label: 'DB performance', repository: 'qbit-db-performance' },
edi: { label: 'EDI', repository: 'qbit-edi', minUiApi: '1.6.0' },
'qa-capture': { label: 'QA capture', repository: 'qbit-qa-capture' },
'quick-search': { label: 'Quick search', repository: 'qbit-quick-search' },
'release-notes': { label: 'Release notes', repository: 'qbit-release-notes' },
'secured-data': { label: 'Secured data', repository: 'qbit-secured-data', minUiApi: '1.6.0' },
'sql-workbench': { label: 'SQL workbench', repository: 'qbit-sql-workbench' },
}
/** Every extension the client has a document for, in document order. */
export const KNOWN_EXTENSIONS: readonly KnownExtension[] = DOCUMENT_EXTENSIONS.map((e) => {
const facts: Facts = FACTS[e.name] ?? { label: e.name, repository: `qbit-${e.name}` }
return {
name: e.name,
label: facts.label,
repository: facts.repository,
pathPrefix: e.pathPrefix ?? `/qbit/${e.name}`,
documentVersion: e.version,
minVersion: facts.minVersion,
minUiApi: facts.minUiApi,
channels: facts.channels ?? [],
description: e.description ?? '',
}
})
const knownByName = new Map(KNOWN_EXTENSIONS.map((e) => [e.name, e]))
/** What the client knows about an extension, or undefined. */
export function knownExtension(name: string): KnownExtension | undefined {
return knownByName.get(name)
}
/** The operations an extension serves, by id. */
export function operationsOf(name: string): OperationInfo[] {
return OPERATIONS.filter((o) => o.extension === name)
}
/** The extension an operation belongs to; undefined for core operations and unknown ids. */
export function extensionOfOperation(id: string): string | undefined {
return operation(id)?.extension
}
/** Compares two MAJOR.MINOR.PATCH versions (pre-release suffixes ignored). */
export function compareVersions(a: string, b: string): number {
const parts = (v: string) => v.split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0)
const [x, y] = [parts(a), parts(b)]
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0)
return 0
}
/** Thrown when an operation or command needs an extension the server does not have (CLI exit 5). */
export class ExtensionNotInstalledError extends Error {
readonly extension: string
readonly installed: InstalledExtension[]
/** `absent`, or `too-old` when the server has it below the client's minimum. */
readonly reason: 'absent' | 'too-old'
constructor(extension: string, installed: InstalledExtension[], reason: 'absent' | 'too-old' = 'absent', operationId?: string) {
const list = installed.length ? installed.map((e) => `${e.name}${e.version ? ` ${e.version}` : ''}`).join(', ') : 'none'
const subject = operationId ? `${operationId} belongs to extension ${extension}, which` : `extension ${extension}`
const found = installed.find((e) => e.name === extension)
const text = reason === 'too-old'
? `${operationId ? `${operationId} needs` : 'this client needs'} extension ${extension} ${knownExtension(extension)?.minVersion} or later; this server has ${found?.version ?? 'an unknown version'} (installed: ${list})`
: `${subject} is not installed on this server (installed: ${list})`
super(text)
this.name = 'ExtensionNotInstalledError'
this.extension = extension
this.installed = installed
this.reason = reason
}
}
/** Joins the server's GET /version `extensions` with the client's knowledge. Unknown ones are kept. */
export function joinInstalled(version: ServerVersion): InstalledExtension[] {
return (version.extensions ?? []).filter((e) => e && typeof e.name === 'string').map((e) => {
const known = knownByName.get(e.name)
const meetsMinimum = !known?.minVersion || !e.version || compareVersions(e.version, known.minVersion) >= 0
return { name: e.name, version: e.version, pathPrefix: e.pathPrefix, description: e.description, known: Boolean(known), meetsMinimum }
})
}
/** The extensions a server has installed, from its GET /version (cached with the metadata cache). */
export async function installedExtensions(server: string, fetchImpl: typeof fetch, options: MetaDataCacheOptions & { offline?: boolean; refresh?: boolean } = {}): Promise<InstalledExtension[]> {
return joinInstalled(await getServerVersion(server, fetchImpl, options))
}
/** One server's extensions: what is installed, and the checks commands make before calling. */
export class ExtensionRegistry {
readonly installed: InstalledExtension[]
private readonly byName: Map<string, InstalledExtension>
constructor(installed: InstalledExtension[]) {
this.installed = installed
this.byName = new Map(installed.map((e) => [e.name, e]))
}
/** A registry from a GET /version document. */
static fromVersion(version: ServerVersion): ExtensionRegistry {
return new ExtensionRegistry(joinInstalled(version))
}
/** Whether the server has the extension at a version the client works with. */
has(name: string): boolean {
const e = this.byName.get(name)
return Boolean(e && e.meetsMinimum)
}
/** The installed version of an extension, or undefined. */
version(name: string): string | undefined {
return this.byName.get(name)?.version
}
/** The installed extension, or a typed error naming what is installed. */
require(name: string): InstalledExtension {
const e = this.byName.get(name)
if (!e) throw new ExtensionNotInstalledError(name, this.installed, 'absent')
if (!e.meetsMinimum) throw new ExtensionNotInstalledError(name, this.installed, 'too-old')
return e
}
/** Throws when the operation belongs to an extension this server does not have; core operations pass. */
requireOperation(id: string): void {
const name = extensionOfOperation(id)
if (!name) return
const e = this.byName.get(name)
if (!e) throw new ExtensionNotInstalledError(name, this.installed, 'absent', id)
if (!e.meetsMinimum) throw new ExtensionNotInstalledError(name, this.installed, 'too-old', id)
}
/** Installed extensions the client has no document for (tolerated and listed). */
unknown(): InstalledExtension[] {
return this.installed.filter((e) => !e.known)
}
/** `esb 1.1.0, foo 2.0.0 (unknown to this client)`, or `none`. */
describe(): string {
if (!this.installed.length) return 'none'
return this.installed.map((e) => `${e.name}${e.version ? ` ${e.version}` : ''}${e.known ? '' : ' (unknown to this client)'}${e.meetsMinimum ? '' : ` (below ${knownExtension(e.name)?.minVersion})`}`).join(', ')
}
}
/** The registry of a server, from its cached GET /version. */
export async function extensionRegistry(server: string, fetchImpl: typeof fetch, options: MetaDataCacheOptions & { offline?: boolean; refresh?: boolean } = {}): Promise<ExtensionRegistry> {
return new ExtensionRegistry(await installedExtensions(server, fetchImpl, options))
}
+9
View File
@@ -22,5 +22,14 @@ export * from './records/copy.js'
export * from './records/variants.js'
export * from './metadata/navigation.js'
export * from './help/help-content.js'
export * from './query/joins.js'
export * from './query/column-stats.js'
export * from './query/saved-views.js'
export * from './records/audits.js'
export * from './process/screen.js'
export * from './forms/datetime.js'
export * from './records/associations.js'
export * from './records/record-view.js'
export * from './render/files.js'
export * from './extensions/registry.js'
export * from './extensions/collaboration.js'
+53 -2
View File
@@ -70,7 +70,10 @@ export function writeCachedMetaData(server: string, metaData: MetaDataDocument,
/** Removes the cache of one server, or of every server. */
export function clearMetaDataCache(server?: string, options: MetaDataCacheOptions = {}): void {
const dir = options.dir ?? defaultDir()
if (server) rmSync(fileFor(dir, server), { force: true })
if (server) {
rmSync(fileFor(dir, server), { force: true })
rmSync(versionFileFor(dir, server), { force: true })
}
else rmSync(dir, { recursive: true, force: true })
}
@@ -85,7 +88,8 @@ export async function getMetaData(server: string, fetchImpl: typeof fetch, optio
if (options.offline && !cached) throw new Error('No cached metadata for this server; run any qqq command once to fill the cache.')
let version: string | undefined
try {
const v = (await callOperation(server, 'getVersion', {}, fetchImpl)).data as { apiVersion?: string }
const v = (await callOperation(server, 'getVersion', {}, fetchImpl)).data as ServerVersion
writeCachedVersion(server, v, options)
version = v.apiVersion
} catch (error) {
if (cached) return cached.metaData
@@ -133,3 +137,50 @@ export async function getTableMetaData<T = unknown>(server: string, table: strin
writeCachedTableMeta(server, table, metaData, options)
return metaData
}
/** GET /version: the API semver, core features, registered extensions and accepted client channels. */
export interface ServerVersion {
apiVersion?: string
basePath?: string
supportedMajorVersions?: number[]
features?: string[]
extensions?: { name: string; version?: string; pathPrefix?: string; description?: string }[]
clientChannels?: string[]
[key: string]: unknown
}
function versionFileFor(dir: string, server: string): string {
return join(dir, `version-${server.replace(/[^A-Za-z0-9.-]+/g, '_')}.json`)
}
function writeCachedVersion(server: string, version: ServerVersion, options: MetaDataCacheOptions = {}): void {
const dir = options.dir ?? defaultDir()
mkdirSync(dir, { recursive: true, mode: 0o700 })
writeFileSync(versionFileFor(dir, server), JSON.stringify({ fetchedAt: new Date().toISOString(), version }), { mode: 0o600 })
}
/**
* The server's GET /version, cached next to its metadata with the same TTL (it is unsecured and
* small, but listings and completion should not wait for it). `offline` uses a stale copy.
*/
export async function getServerVersion(server: string, fetchImpl: typeof fetch, options: MetaDataCacheOptions & { offline?: boolean; refresh?: boolean } = {}): Promise<ServerVersion> {
const file = versionFileFor(options.dir ?? defaultDir(), server)
let cached: { fetchedAt: string; version: ServerVersion } | undefined
if (!options.refresh && existsSync(file)) {
try {
cached = JSON.parse(readFileSync(file, 'utf8')) as { fetchedAt: string; version: ServerVersion }
} catch {
cached = undefined
}
}
if (cached && (options.offline || Date.now() - Date.parse(cached.fetchedAt) < (options.ttlSeconds ?? 300) * 1000)) return cached.version
if (options.offline && !cached) throw new Error('No cached version for this server; run any qqq command once to fill the cache.')
try {
const version = (await callOperation(server, 'getVersion', {}, fetchImpl)).data as ServerVersion
writeCachedVersion(server, version, options)
return version
} catch (error) {
if (cached) return cached.version
throw error
}
}
-19
View File
@@ -140,25 +140,6 @@ export function downloadReference(values: FieldValues): { fileName: string; quer
return undefined
}
/**
* A server-supplied file name made safe to write in a chosen directory: the last path segment
* only (either separator), without control characters; empty, `.` and `..` are refused (throws).
*/
export function safeDownloadName(name: string): string {
const base = name.split(/[\\/]/).pop()!.replace(/[\u0000-\u001f\u007f]/g, '').trim()
if (!base || base === '.' || base === '..') throw new Error(`unsafe file name from the server: ${JSON.stringify(name)}`)
return base
}
/** Candidate names when one exists already: `a.csv`, `a-1.csv`, `a-2.csv`, … */
export function* alternativeNames(name: string): Generator<string> {
yield name
const dot = name.lastIndexOf('.')
const stem = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
for (let i = 1; i < 1000; i++) yield `${stem}-${i}${ext}`
}
/** Downloads a DOWNLOAD_FORM file (GET /download/{file}); returns its bytes and content type. */
export async function downloadProcessFile(baseUrl: string, fetchImpl: typeof fetch, reference: { fileName: string; query: Record<string, string> }): Promise<{ bytes: Uint8Array; contentType: string }> {
const result = await callOperation(baseUrl, 'download', { path: { file: reference.fileName }, query: reference.query }, fetchImpl)
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Column statistics, as the web UI's column menu shows them: count, distinct count, sum, average,
* min and max of one column over the filtered rows, and the distribution of its values. The
* server computes them with the standard `columnStats` process, offered only for tables with the
* QUERY_STATS capability when the user may run the process. `computeColumnStats` does the same
* over records the client already has, for servers without the process (an explicit choice).
*/
import { callOperation } from '../api/call.js'
import type { QueryFilter } from '../filter/grammar.js'
import type { TableVariant } from '../records/variants.js'
import { variantParam } from '../records/variants.js'
type J = Record<string, unknown>
/** The backend's standard column statistics process. */
export const COLUMN_STATS_PROCESS = 'columnStats'
export interface ColumnStats {
/** The statistics present, in order (`count`, `countDistinct`, `sum`, `average`, `min`, `max`). */
fields: Array<{ name: string; label: string }>
/** Their values (and display values). */
stats: { values: J; displayValues: J }
/** One row per distinct value: `values[field]`, `values.count`, display values. */
valueCounts: Array<{ values: J; displayValues: J }>
}
/** Whether the server offers column statistics for a table, and why not. */
export function columnStatsAvailability(tableMeta: J, metaData: J): { available: boolean; reason?: string } {
if (!((tableMeta.capabilities ?? []) as string[]).includes('QUERY_STATS')) return { available: false, reason: `${String(tableMeta.label ?? tableMeta.name)} does not offer column statistics (no QUERY_STATS capability)` }
const process = ((metaData.processes ?? {}) as Record<string, J>)[COLUMN_STATS_PROCESS]
if (!process) return { available: false, reason: `the server has no ${COLUMN_STATS_PROCESS} process` }
if (process.hasPermission === false) return { available: false, reason: `you may not run ${COLUMN_STATS_PROCESS}` }
return { available: true }
}
/** The filter the statistics run over: criteria and groups only (paging and sort dropped). */
export function statsFilter(filter: Partial<QueryFilter>): Partial<QueryFilter> {
const { skip: _skip, limit: _limit, orderBys: _orderBys, ...criteria } = filter
void _skip
void _limit
void _orderBys
return criteria
}
/** Runs the `columnStats` process. `orderBy` orders the distribution: `count.desc`, `count.asc`, `{field}.asc`, `{field}.desc`. */
export async function runColumnStats(baseUrl: string, fetchImpl: typeof fetch, options: { table: string; field: string; filter?: Partial<QueryFilter>; orderBy?: string; tableVariant?: TableVariant }): Promise<ColumnStats> {
const values = { tableName: options.table, fieldName: options.field, filterJSON: JSON.stringify(statsFilter(options.filter ?? {})), orderBy: options.orderBy ?? 'count.desc' }
const result = await callOperation(baseUrl, 'initProcess', {
path: { processName: COLUMN_STATS_PROCESS },
query: { tableVariant: variantParam(options.tableVariant) },
form: { values: JSON.stringify(values), stepTimeoutMillis: '60000' },
}, fetchImpl)
const response = (result.data ?? {}) as J
if (response.type === 'ERROR' || response.error) throw new Error(String(response.userFacingError ?? response.error ?? 'Column statistics failed'))
const out = (response.values ?? {}) as J
if (!response.values) throw new Error('Column statistics did not complete.')
const record = (out.statsRecord ?? {}) as J
return {
fields: ((out.statsFields ?? []) as J[]).filter((f) => f && f.name).map((f) => ({ name: String(f.name), label: String(f.label ?? f.name) })),
stats: { values: (record.values ?? {}) as J, displayValues: (record.displayValues ?? {}) as J },
valueCounts: ((out.valueCounts ?? []) as J[]).map((r) => ({ values: (r.values ?? {}) as J, displayValues: (r.displayValues ?? {}) as J })),
}
}
const NUMERIC = new Set(['INTEGER', 'LONG', 'DECIMAL'])
/**
* Computes the statistics from records (query results) for one field: what the process returns,
* with DATE_TIME values grouped by hour as the web UI shows them, and no sum or average for
* possible-value ids.
*/
export function computeColumnStats(records: J[], field: string, fieldMeta: { type?: unknown; possibleValueSourceName?: unknown }, orderBy = 'count.desc'): ColumnStats {
const fieldType = String(fieldMeta.type ?? 'STRING')
// ids of a possible-value source are not quantities: no sum or average, ordered as numbers still
const numeric = NUMERIC.has(fieldType)
const quantity = numeric && !fieldMeta.possibleValueSourceName
const counts = new Map<string, { value: unknown; display: unknown; count: number }>()
let count = 0
let sum = 0
let min: unknown
let max: unknown
const less = (a: unknown, b: unknown) => (numeric ? Number(a) < Number(b) : String(a) < String(b))
for (const record of records) {
const values = (record.values ?? {}) as J
let value = values[field]
let display = ((record.displayValues ?? {}) as J)[field] ?? value
if (fieldType === 'DATE_TIME' && typeof value === 'string' && value.length >= 13) {
value = `${value.slice(0, 13)}:00:00Z`
display = value
}
const key = value === null || value === undefined ? '\u0000' : String(value)
const entry = counts.get(key) ?? { value: value ?? null, display: value === null || value === undefined ? '' : display, count: 0 }
entry.count++
counts.set(key, entry)
if (value === null || value === undefined || value === '') continue
count++
if (quantity) sum += Number(value)
if (min === undefined || less(value, min)) min = value
if (max === undefined || less(max, value)) max = value
}
const fields: ColumnStats['fields'] = [{ name: 'count', label: 'Count' }, { name: 'countDistinct', label: 'Count Distinct' }]
const values: J = { count, countDistinct: [...counts.keys()].filter((k) => k !== '\u0000').length }
if (quantity) {
fields.push({ name: 'sum', label: 'Sum' }, { name: 'average', label: 'Average' })
values.sum = Math.round(sum * 1e6) / 1e6
values.average = count ? Math.round((sum / count) * 1e6) / 1e6 : null
}
if (fieldType !== 'BOOLEAN') {
fields.push({ name: 'min', label: 'Min' }, { name: 'max', label: 'Max' })
values.min = min ?? null
values.max = max ?? null
}
const [by, direction] = orderBy.split('.')
const sign = direction === 'asc' ? 1 : -1
const rows = [...counts.values()].sort((a, b) => (by === 'count'
? sign * (a.count - b.count) || String(a.display).localeCompare(String(b.display))
: sign * (numeric ? Number(a.value) - Number(b.value) : String(a.value ?? '').localeCompare(String(b.value ?? '')))))
return {
fields,
stats: { values, displayValues: {} },
valueCounts: rows.map((r) => ({ values: { [field]: r.value, count: r.count }, displayValues: { [field]: r.display === null ? '' : String(r.display) } })),
}
}
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Exposed joins: the joins a table's metadata exposes (`exposedJoins`) let a query show, filter
* and sort on fields of another table, named `joinTable.field`. As in the web UI, a query joins
* only the exposed joins it uses (named explicitly, or referenced by a shown column, a criterion
* or a sort), each as a LEFT join with its join name when the path is one hop; a many-side join
* can repeat base records, so counts then ask for the distinct count too.
*/
import type { QueryFilter } from '../filter/grammar.js'
type J = Record<string, unknown>
/** One exposed join of a table. */
export interface ExposedJoin {
/** The joined table's name (the prefix of its qualified field names). */
table: string
label: string
isMany: boolean
/** The join name, when the path is a single hop. */
joinName?: string
/** The joined table's metadata, as the exposed join carries it. */
tableMeta: J
}
/** The UI API's `QueryJoin`. */
export interface QueryJoin {
joinTable: string
select?: boolean
type?: 'INNER' | 'LEFT' | 'RIGHT' | 'FULL'
joinName?: string
alias?: string
baseTableOrAlias?: string
}
/**
* The exposed joins the user may read: the joined table and every table on the path readable
* (`readPermission` in `tables`, the instance metadata's table list, when given).
*/
export function exposedJoins(tableMeta: J, tables?: Record<string, J>): ExposedJoin[] {
const out: ExposedJoin[] = []
for (const raw of (tableMeta.exposedJoins ?? []) as J[]) {
const joinTable = raw.joinTable as J | undefined
if (!joinTable?.name || joinTable.readPermission === false) continue
const path = (raw.joinPath ?? []) as J[]
if (tables) {
const names = [String(joinTable.name), ...path.flatMap((p) => [String(p.leftTable), String(p.rightTable)])]
if (!names.every((n) => n === tableMeta.name || tables[n]?.readPermission !== false && tables[n] !== undefined)) continue
}
out.push({
table: String(joinTable.name),
label: String(raw.label ?? joinTable.label ?? joinTable.name),
isMany: raw.isMany === true,
...(path.length === 1 && path[0]!.name ? { joinName: String(path[0]!.name) } : {}),
tableMeta: joinTable,
})
}
return out
}
/** Finds an exposed join by its table name or label (any case). */
export function findExposedJoin(joins: ExposedJoin[], name: string): ExposedJoin | undefined {
const lower = name.toLowerCase()
return joins.find((j) => j.table === name) ?? joins.find((j) => j.table.toLowerCase() === lower || j.label.toLowerCase() === lower)
}
/** Every qualified `joinTable.field` name the joins offer (hidden fields left out). */
export function joinFieldNames(joins: ExposedJoin[]): string[] {
return joins.flatMap((j) => Object.entries((j.tableMeta.fields ?? {}) as Record<string, J>).filter(([, f]) => !f.isHidden).map(([n]) => `${j.table}.${n}`))
}
/** A field of the table, or `joinTable.field` of an exposed join, with its metadata and the join. */
export function resolveField(tableMeta: J, joins: ExposedJoin[], name: string): { field: J; join?: ExposedJoin } | undefined {
const own = ((tableMeta.fields ?? {}) as Record<string, J>)[name]
if (own) return { field: own }
const at = name.indexOf('.')
if (at < 1) return undefined
const join = joins.find((j) => j.table === name.slice(0, at))
const field = join && ((join.tableMeta.fields ?? {}) as Record<string, J>)[name.slice(at + 1)]
return field ? { field, join } : undefined
}
/** A column header: the field label, prefixed with the join label for joined fields. */
export function fieldLabel(tableMeta: J, joins: ExposedJoin[], name: string): string {
const found = resolveField(tableMeta, joins, name)
if (!found) return name
const label = String(found.field.label ?? found.field.name ?? name)
return found.join ? `${found.join.label}: ${label}` : label
}
/** Every field name a filter refers to (criteria, sub-filters and sorts). */
export function filterFieldNames(filter: Partial<QueryFilter> | undefined): string[] {
if (!filter) return []
return [
...(filter.criteria ?? []).map((c) => c.fieldName),
...(filter.orderBys ?? []).map((o) => o.fieldName),
...(filter.subFilters ?? []).flatMap((s) => filterFieldNames(s)),
]
}
/** The default joined columns for a selected join: its sections' fields by tier, then the rest, without the primary key; capped. */
export function defaultJoinColumns(join: ExposedJoin, cap = 4): string[] {
const fields = (join.tableMeta.fields ?? {}) as Record<string, J>
const pk = String(join.tableMeta.primaryKeyField ?? '')
const order: string[] = []
const add = (n: string) => { if (fields[n] && !fields[n]!.isHidden && !fields[n]!.isHeavy && n !== pk && !order.includes(n)) order.push(n) }
const sections = (join.tableMeta.sections ?? []) as J[]
for (const tier of ['T1', 'T2', 'T3']) for (const section of sections.filter((x) => x.tier === tier)) for (const n of (section.fieldNames ?? []) as string[]) add(n)
for (const n of Object.keys(fields)) add(n)
return order.slice(0, cap).map((n) => `${join.table}.${n}`)
}
/**
* The joins a query needs: the named ones plus those its columns and filter refer to. A join is
* selected when it was named with `select`, or when a shown column comes from it.
*/
export function planJoins(joins: ExposedJoin[], options: { named?: string[]; select?: boolean; columns?: string[]; filter?: Partial<QueryFilter> }): { joins: QueryJoin[]; used: ExposedJoin[]; includeDistinct: boolean } {
const prefix = (n: string) => (n.indexOf('.') > 0 ? n.slice(0, n.indexOf('.')) : undefined)
const shown = new Set((options.columns ?? []).map(prefix).filter((p): p is string => Boolean(p)))
const used = new Map<string, { join: ExposedJoin; select: boolean }>()
for (const name of options.named ?? []) {
const join = findExposedJoin(joins, name)
if (!join) throw new JoinError(`No exposed join "${name}"${joins.length ? `; joins: ${joins.map((j) => `${j.table} (${j.label})`).join(', ')}` : ' (the table exposes none)'}`)
used.set(join.table, { join, select: Boolean(options.select) || shown.has(join.table) })
}
for (const name of [...(options.columns ?? []), ...filterFieldNames(options.filter)]) {
const table = prefix(name)
if (!table || used.has(table)) continue
const join = joins.find((j) => j.table === table)
if (!join) throw new JoinError(`"${name}" names no exposed join${joins.length ? `; joins: ${joins.map((j) => j.table).join(', ')}` : ' (the table exposes none)'}`)
used.set(table, { join, select: shown.has(table) })
}
const list = [...used.values()]
return {
joins: list.map(({ join, select }) => ({ joinTable: join.table, select, type: 'LEFT' as const, ...(join.joinName ? { joinName: join.joinName } : {}) })),
used: list.map((u) => u.join),
includeDistinct: list.some((u) => u.join.isMany),
}
}
/** A join cannot be resolved. */
export class JoinError extends Error {}
/**
* A join from one of the table's associations (`associations[].join`), for tables that expose no
* join to it. The web UI joins only exposed joins; the CLI also offers these, marked as such.
*/
export function associationJoin(association: J, joinedTableMeta: J): ExposedJoin | undefined {
const join = association.join as J | undefined
if (!joinedTableMeta?.name || joinedTableMeta.readPermission === false) return undefined
return {
table: String(joinedTableMeta.name),
label: String(joinedTableMeta.label ?? association.name ?? joinedTableMeta.name),
isMany: join?.type === 'ONE_TO_MANY' || join?.type === 'MANY_TO_MANY',
...(join?.name ? { joinName: String(join.name) } : {}),
tableMeta: joinedTableMeta,
}
}
/** The table metadata with each join's fields added under their qualified names (for value formatting). */
export function withJoinFields(tableMeta: J, joins: ExposedJoin[]): J {
if (!joins.length) return tableMeta
const fields: J = { ...((tableMeta.fields ?? {}) as J) }
for (const join of joins) for (const [name, field] of Object.entries((join.tableMeta.fields ?? {}) as J)) fields[`${join.table}.${name}`] = { ...(field as J), name: `${join.table}.${name}` }
return { ...tableMeta, fields }
}
+226
View File
@@ -0,0 +1,226 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Saved views: named query screens (filter, sort, columns, page size) a backend keeps through its
* saved-view processes (`querySavedView`, `storeSavedView`, `deleteSavedView`), named per table by
* `clientCapabilities.savedViews`. The view document is the Material dashboard's `RecordQueryView`,
* so views saved in the web UIs open here and the other way round.
*/
import { callOperation } from '../api/call.js'
import type { OrderBy, QueryFilter } from '../filter/grammar.js'
import type { ExposedJoin } from './joins.js'
import { resolveField } from './joins.js'
type J = Record<string, unknown>
/** The process names when a server predates `clientCapabilities` (UI API before 1.6). */
export const LEGACY_SAVED_VIEW_PROCESSES = { query: 'querySavedView', store: 'storeSavedView', delete: 'deleteSavedView' } as const
/** One column of a view document. */
export interface SavedViewColumn {
name: string
isVisible: boolean
width?: number
pinned?: 'left' | 'right'
}
/** The view document (Material `RecordQueryView`). */
export interface RecordQueryView {
queryFilter: Partial<QueryFilter>
queryColumns?: { columns: SavedViewColumn[] }
rowsPerPage?: number
quickFilterFieldNames?: string[]
mode?: string
}
/** A stored view. */
export interface SavedView {
id: number
label: string
userId?: string
tableName: string
view: RecordQueryView
type?: string
sortOrder?: number
doCount?: boolean
}
export interface SavedViewProcesses {
query?: string
store?: string
delete?: string
}
/**
* The saved-view processes of a table: those `clientCapabilities.savedViews` names when it is
* available; none when it says unavailable; the legacy names, when the instance has them and the
* table carries no capabilities at all.
*/
export function savedViewProcesses(tableMeta: J, metaData: J): SavedViewProcesses {
const capabilities = tableMeta.clientCapabilities as J | undefined
if (capabilities) {
const declared = capabilities.savedViews as J | undefined
if (!declared?.available) return {}
return {
...(declared.queryProcessName ? { query: String(declared.queryProcessName) } : {}),
...(declared.storeProcessName ? { store: String(declared.storeProcessName) } : {}),
...(declared.deleteProcessName ? { delete: String(declared.deleteProcessName) } : {}),
}
}
const processes = (metaData.processes ?? {}) as Record<string, J>
const permitted = (name: string) => Boolean(processes[name]) && processes[name]!.hasPermission !== false
const out: SavedViewProcesses = {}
for (const key of ['query', 'store', 'delete'] as const) if (permitted(LEGACY_SAVED_VIEW_PROCESSES[key])) out[key] = LEGACY_SAVED_VIEW_PROCESSES[key]
return out
}
/** Parses a stored `viewJson` (text or object), tolerating partial documents. */
export function parseViewJson(viewJson: unknown): RecordQueryView {
let parsed: unknown = viewJson
if (typeof viewJson === 'string') {
try {
parsed = JSON.parse(viewJson)
} catch {
parsed = {}
}
}
const source = (parsed && typeof parsed === 'object' ? parsed : {}) as J
const columns = (source.queryColumns as { columns?: unknown } | undefined)?.columns
return {
queryFilter: source.queryFilter && typeof source.queryFilter === 'object' ? source.queryFilter as Partial<QueryFilter> : {},
...(Array.isArray(columns) ? { queryColumns: { columns: (columns as SavedViewColumn[]).filter((c) => c && typeof c === 'object' && typeof c.name === 'string') } } : {}),
...(typeof source.rowsPerPage === 'number' ? { rowsPerPage: source.rowsPerPage } : {}),
quickFilterFieldNames: Array.isArray(source.quickFilterFieldNames) ? (source.quickFilterFieldNames as unknown[]).filter((n): n is string => typeof n === 'string') : [],
...(typeof source.mode === 'string' ? { mode: source.mode } : {}),
}
}
/** Runs a saved-view process and returns its `savedViewList`. */
export async function runSavedViewProcess(baseUrl: string, fetchImpl: typeof fetch, processName: string, values: J): Promise<SavedView[]> {
const result = await callOperation(baseUrl, 'initProcess', { path: { processName }, form: { values: JSON.stringify(values), stepTimeoutMillis: '60000' } }, fetchImpl)
const response = (result.data ?? {}) as J
if (response.type === 'ERROR' || response.error) throw new SavedViewError(String(response.userFacingError ?? response.error ?? 'The saved view request failed'))
if (response.type !== undefined && response.type !== 'COMPLETE') throw new SavedViewError('The saved view request did not complete.')
const list = (((response.values ?? {}) as J).savedViewList ?? []) as Array<{ values?: J }>
return list.map((record) => {
const v = record.values ?? {}
return {
id: Number(v.id),
label: String(v.label ?? ''),
...(typeof v.userId === 'string' ? { userId: v.userId } : {}),
tableName: String(v.tableName ?? ''),
view: parseViewJson(v.viewJson),
...(typeof v.type === 'string' ? { type: v.type } : {}),
...(typeof v.sortOrder === 'number' ? { sortOrder: v.sortOrder } : {}),
...(typeof v.doCount === 'boolean' ? { doCount: v.doCount } : {}),
}
})
}
/** Saved views of a table, sorted by label. */
export async function listSavedViews(baseUrl: string, fetchImpl: typeof fetch, processes: SavedViewProcesses, table: string): Promise<SavedView[]> {
if (!processes.query) throw new SavedViewError('Saved views are unavailable for this table.')
return (await runSavedViewProcess(baseUrl, fetchImpl, processes.query, { tableName: table })).sort((a, b) => a.label.localeCompare(b.label))
}
/** Finds a view by id or by name (exact, then any case); throws when none or several match. */
export function findSavedView(views: SavedView[], idOrName: string): SavedView {
const byId = /^\d+$/.test(idOrName) ? views.find((v) => v.id === Number(idOrName)) : undefined
if (byId) return byId
const exact = views.filter((v) => v.label === idOrName)
const named = exact.length ? exact : views.filter((v) => v.label.toLowerCase() === idOrName.toLowerCase())
if (named.length === 1) return named[0]!
if (named.length > 1) throw new SavedViewError(`${named.length} views are named "${idOrName}" (ids ${named.map((v) => v.id).join(', ')}); give the id`)
throw new SavedViewError(`No saved view "${idOrName}"${views.length ? `; views: ${views.map((v) => `${v.label} (${v.id})`).join(', ')}` : ' (the table has none)'}`)
}
/** Stores a view (insert, or update when `id` is given) and returns the stored record. */
export async function storeSavedView(baseUrl: string, fetchImpl: typeof fetch, processes: SavedViewProcesses, input: { table: string; label: string; view: RecordQueryView; id?: number }): Promise<SavedView> {
if (!processes.store) throw new SavedViewError('Saving views is unavailable for this table.')
const [stored] = await runSavedViewProcess(baseUrl, fetchImpl, processes.store, { tableName: input.table, label: input.label, viewJson: JSON.stringify(input.view), ...(input.id !== undefined ? { id: input.id } : {}) })
if (!stored) throw new SavedViewError('No saved view was stored.')
return stored
}
/** Deletes a view. */
export async function deleteSavedView(baseUrl: string, fetchImpl: typeof fetch, processes: SavedViewProcesses, id: number): Promise<void> {
if (!processes.delete) throw new SavedViewError('Deleting views is unavailable for this table.')
await runSavedViewProcess(baseUrl, fetchImpl, processes.delete, { id })
}
/** The view document for a filter, sort, shown columns and page size (what the web UIs store). */
export function buildViewJson(input: { filter: Partial<QueryFilter>; columns: string[]; allColumns?: string[]; rowsPerPage?: number; base?: RecordQueryView }): RecordQueryView {
const { skip: _skip, limit: _limit, orderBys, ...rest } = input.filter
void _skip
void _limit
const shown = new Set(input.columns)
const hidden = (input.allColumns ?? []).filter((c) => !shown.has(c))
return {
queryFilter: { criteria: rest.criteria ?? [], booleanOperator: rest.booleanOperator ?? 'AND', ...(rest.subFilters?.length ? { subFilters: rest.subFilters } : {}), orderBys: orderBys ?? [] },
queryColumns: { columns: [{ name: '__check__', isVisible: true, width: 100, pinned: 'left' }, ...input.columns.map((name) => ({ name, isVisible: true })), ...hidden.map((name) => ({ name, isVisible: false }))] },
...(input.rowsPerPage ? { rowsPerPage: input.rowsPerPage } : {}),
quickFilterFieldNames: input.base?.quickFilterFieldNames ?? [],
mode: input.base?.mode ?? 'advanced',
}
}
/**
* What a view runs: its filter and sort, its visible columns in order, its page size, with what the
* table no longer has removed and the warnings the web UI shows (Material's reconcile).
*/
export function viewToQuery(tableMeta: J, joins: ExposedJoin[], view: RecordQueryView): { filter: QueryFilter; columns: string[]; rowsPerPage?: number; warnings: string[] } {
const removed = new Set<string>()
const reasons = new Set<string>()
const exists = (name: string) => Boolean(resolveField(tableMeta, joins, name))
const clean = (filter: Partial<QueryFilter>): QueryFilter => {
const criteria = []
for (const criterion of Array.isArray(filter.criteria) ? filter.criteria : []) {
if (!criterion || typeof criterion.fieldName !== 'string') continue
const found = resolveField(tableMeta, joins, criterion.fieldName)
if (!found) {
removed.add(criterion.fieldName)
continue
}
if (found.field.type === 'BOOLEAN' && criterion.operator && !['EQUALS', 'IS_BLANK', 'IS_NOT_BLANK', 'TRUE', 'FALSE'].includes(criterion.operator)) {
reasons.add(`${String(found.field.label ?? criterion.fieldName)} has an unsupported operator: ${criterion.operator}`)
continue
}
criteria.push(criterion)
}
return { criteria, booleanOperator: filter.booleanOperator === 'OR' ? 'OR' : 'AND', ...(Array.isArray(filter.subFilters) && filter.subFilters.length ? { subFilters: filter.subFilters.map(clean) } : {}) }
}
const filter = clean(view.queryFilter ?? {})
const saved = (Array.isArray(view.queryFilter?.orderBys) ? view.queryFilter.orderBys : []) as OrderBy[]
const orderBys = saved.filter((o) => {
if (!o || typeof o.fieldName !== 'string') return false
if (exists(o.fieldName)) return true
removed.add(o.fieldName)
return false
})
if (saved.length && !orderBys.length) orderBys.push({ fieldName: String(tableMeta.primaryKeyField ?? 'id'), isAscending: false })
if (orderBys.length) filter.orderBys = orderBys
const columns: string[] = []
for (const column of view.queryColumns?.columns ?? []) {
if (column.name === '__check__' || column.isVisible === false) continue
if (exists(column.name)) columns.push(column.name)
else removed.add(column.name)
}
for (const name of view.quickFilterFieldNames ?? []) if (!exists(name)) removed.add(name)
const warnings: string[] = []
if (removed.size) {
const plural = removed.size > 1
warnings.push(`${removed.size} field${plural ? 's' : ''} that ${plural ? 'were' : 'was'} part of this view ${plural ? 'are' : 'is'} no longer in this table, and ${plural ? 'were' : 'was'} removed from this view (${[...removed].join(', ')}).`)
}
if (reasons.size) {
const plural = reasons.size > 1
warnings.push(`${reasons.size} filter${plural ? 's' : ''} ${plural ? 'are' : 'is'} misconfigured for this screen and ${plural ? 'were' : 'was'} removed from this view: (Details: ${[...reasons].join(', ')}).`)
}
return { filter, columns, ...(view.rowsPerPage ? { rowsPerPage: view.rowsPerPage } : {}), warnings }
}
/** A saved-view request cannot be done (unavailable, unknown view, server refusal text). */
export class SavedViewError extends Error {}
+279
View File
@@ -0,0 +1,279 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Related records: a table's associations (child lists), the values that bind a child to its
* parent, the filter that lists a parent's children, and the association tree a record read with
* `includeAssociations=true` carries. Shared by the CLI (`table get --associations`,
* `table children`, `table create --parent`) and the TUI record view. Follows the Next UI
* (`association-utils.ts`, `AssociatedRecords.tsx`).
*/
import type { FieldMeta } from '../forms/fields.js'
import type { QueryFilter } from '../filter/grammar.js'
import { callOperation } from '../api/call.js'
export interface JoinOn {
leftField: string
rightField: string
}
export interface AssociationMeta {
name: string
associatedTableName: string
join: {
name?: string
type?: string
leftTable: string
rightTable: string
joinOns?: JoinOn[]
orderBys?: Array<{ fieldName: string; isAscending?: boolean }>
}
}
export interface ExposedJoinMeta {
label?: string
joinTable?: string
joinPath?: Array<{ name?: string }>
[key: string]: unknown
}
export interface SectionMeta {
name: string
label?: string
tier?: string
fieldNames?: string[] | null
widgetName?: string | null
isHidden?: boolean | null
helpContents?: unknown
[key: string]: unknown
}
/** The table metadata the association model reads (a structural subset of TableMetaData). */
export interface TableMetaLike {
name: string
label?: string
primaryKeyField?: string
fields: Record<string, FieldMeta>
associations?: AssociationMeta[] | null
exposedJoins?: ExposedJoinMeta[] | null
sections?: SectionMeta[] | null
readPermission?: boolean
insertPermission?: boolean
[key: string]: unknown
}
/** A record as the UI API returns it. */
export interface RecordLike {
tableName?: string
values: Record<string, unknown>
displayValues?: Record<string, unknown>
recordLabel?: string
associatedRecords?: Record<string, RecordLike[]>
[key: string]: unknown
}
/** Widget metadata, as far as child-list widgets go (`childRecordList` with a `joinName`). */
export interface WidgetMetaLike {
name?: string
label?: string
type?: string
defaultValues?: Record<string, unknown>
[key: string]: unknown
}
/** The associations a table declares (empty when none). */
export function associationsOf(table: TableMetaLike): AssociationMeta[] {
return Array.isArray(table.associations) ? table.associations : []
}
/** An association by its name, or by its child table when only one association reaches that table. */
export function findAssociation(table: TableMetaLike, nameOrTable: string): AssociationMeta | undefined {
const all = associationsOf(table)
const byName = all.find((a) => a.name === nameOrTable)
if (byName) return byName
const byTable = all.filter((a) => a.associatedTableName === nameOrTable)
return byTable.length === 1 ? byTable[0] : undefined
}
/**
* The label to show for an association: the record-view section whose child-list widget binds the
* association's join (Next's widget sections), else an exposed join to the same table, else the
* child table's label, else the association name.
*/
export function associationLabel(table: TableMetaLike, association: AssociationMeta, options: { widgets?: Record<string, WidgetMetaLike>; childTable?: TableMetaLike } = {}): string {
const joinName = association.join?.name
if (joinName) {
for (const section of table.sections ?? []) {
if (!section.widgetName || section.isHidden) continue
const widget = options.widgets?.[section.widgetName]
if (widget?.defaultValues?.joinName === joinName) return String(section.label ?? widget.label ?? association.name)
}
for (const widget of Object.values(options.widgets ?? {})) {
if (widget.type === 'childRecordList' && widget.defaultValues?.joinName === joinName && widget.label) return String(widget.label)
}
}
const exposed = (table.exposedJoins ?? []).find((j) => j.joinTable === association.associatedTableName && j.label)
if (exposed?.label) return exposed.label
return options.childTable?.label ?? association.name
}
export type RelationshipValues = { values: Record<string, string | number | boolean>; reverse: boolean } | { error: string }
/**
* The child-field values that bind a child record to `parent` through the association (one per
* join-on pair, so composite joins give several). A reverse association (the child is the join's
* left table) is reported, because its children cannot be listed from the parent.
*/
export function relationshipValues(association: AssociationMeta, parentTable: TableMetaLike, parent: RecordLike, childTable: TableMetaLike): RelationshipValues {
const join = association.join
const forward = join.leftTable === parentTable.name && join.rightTable === association.associatedTableName
const reverse = !forward && join.rightTable === parentTable.name && join.leftTable === association.associatedTableName
if ((!forward && !reverse) || childTable.name !== association.associatedTableName || !join.joinOns?.length) return { error: 'Association join is unavailable.' }
const values: Record<string, string | number | boolean> = {}
for (const pair of join.joinOns) {
const parentField = forward ? pair.leftField : pair.rightField
const childField = forward ? pair.rightField : pair.leftField
const value = parent.values[parentField]
if (!parentTable.fields[parentField] || !childTable.fields[childField] || !['string', 'number', 'boolean'].includes(typeof value) || value === ''
|| (typeof value === 'number' && !Number.isFinite(value))) {
return { error: 'Relationship values are unavailable; this record cannot be linked.' }
}
const scalar = value as string | number | boolean
if (Object.prototype.hasOwnProperty.call(values, childField) && values[childField] !== scalar) return { error: 'Association join has conflicting relationship values.' }
values[childField] = scalar
}
return { values, reverse }
}
/** The filter that lists a parent's children: EQUALS on every binding field, ordered as the join says (else by the child's key). */
export function childFilter(association: AssociationMeta, parentTable: TableMetaLike, parent: RecordLike, childTable: TableMetaLike, page: { limit?: number; skip?: number } = {}): QueryFilter {
const relationship = relationshipValues(association, parentTable, parent, childTable)
if ('error' in relationship) throw new AssociationError(relationship.error)
if (relationship.reverse) throw new AssociationError(`${association.name} is a reverse association; its records cannot be listed from ${parentTable.label ?? parentTable.name}.`)
const orderBys = association.join.orderBys?.length
? association.join.orderBys.map((o) => ({ fieldName: o.fieldName, isAscending: o.isAscending !== false }))
: [{ fieldName: childTable.primaryKeyField ?? 'id', isAscending: true }]
return {
criteria: Object.entries(relationship.values).map(([fieldName, value]) => ({ fieldName, operator: 'EQUALS' as const, values: [String(value)] })),
booleanOperator: 'AND',
orderBys,
...(page.skip !== undefined ? { skip: page.skip } : {}),
...(page.limit !== undefined ? { limit: page.limit } : {}),
}
}
export class AssociationError extends Error {}
/**
* The fields a child list shows: visible, not heavy, without the fields that point back at the
* parent; the key first, then in section order by tier (as the record view), then the rest; capped.
*/
export function childListFields(childTable: TableMetaLike, bindingFields: string[] = [], cap = 6): string[] {
const pk = childTable.primaryKeyField ?? 'id'
const usable = (name: string) => {
const field = childTable.fields[name]
return Boolean(field) && !field!.isHidden && !field!.isHeavy && !bindingFields.includes(name)
}
const order: string[] = usable(pk) ? [pk] : []
const sections = (childTable.sections ?? []).filter((s) => !s.isHidden)
for (const tier of ['T1', 'T2', 'T3', undefined]) {
for (const section of sections.filter((s) => (tier ? s.tier === tier : !['T1', 'T2', 'T3'].includes(String(s.tier))))) {
for (const name of section.fieldNames ?? []) if (!order.includes(name) && usable(name)) order.push(name)
}
}
const hiddenOnly = new Set((childTable.sections ?? []).filter((s) => s.isHidden).flatMap((s) => s.fieldNames ?? []))
for (const name of Object.keys(childTable.fields)) if (!order.includes(name) && usable(name) && !hiddenOnly.has(name)) order.push(name)
return order.slice(0, cap)
}
/** One child list of a record, with its place in the tree (`pets`, then `pets[Charlie].notes`). */
export interface AssociationGroup {
association: AssociationMeta
label: string
/** Table of the children. */
table: string
/** The parent these children belong to (the root record, or a child at a deeper level). */
parent: RecordLike
parentTable: string
/** Depth: 1 for the record's own children, 2 for grandchildren, and so on. */
depth: number
/** Human-readable path, e.g. `Pets`, `Pets: Charlie: Notes`. */
title: string
records: RecordLike[]
/** Set when the children could not be read (no metadata, no read permission). */
unavailable?: string
}
/**
* Walks a record read with `includeAssociations=true` into child lists, depth first, honouring the
* table metadata: every declared association appears (an empty list when the server sent none),
* and an association whose child table the user may not read is marked unavailable.
*/
export function associationGroups(record: RecordLike, table: TableMetaLike, tables: Record<string, TableMetaLike | undefined>, options: { widgets?: Record<string, WidgetMetaLike>; maxDepth?: number } = {}): AssociationGroup[] {
const groups: AssociationGroup[] = []
const maxDepth = options.maxDepth ?? 5
const walk = (parent: RecordLike, parentTable: TableMetaLike, depth: number, prefix: string): void => {
if (depth > maxDepth) return
for (const association of associationsOf(parentTable)) {
const childTable = tables[association.associatedTableName]
const label = associationLabel(parentTable, association, { widgets: options.widgets, childTable })
const title = prefix ? `${prefix}: ${label}` : label
const records = parent.associatedRecords?.[association.name] ?? []
const unavailable = !childTable ? `No metadata for table ${association.associatedTableName} (no access, or hidden).`
: childTable.readPermission === false ? `You may not read ${childTable.label ?? childTable.name}.` : undefined
groups.push({ association, label, table: association.associatedTableName, parent, parentTable: parentTable.name, depth, title, records: unavailable ? [] : records, ...(unavailable ? { unavailable } : {}) })
if (unavailable || !childTable) continue
for (const child of records) {
const childLabel = child.recordLabel ?? String(child.values[childTable.primaryKeyField ?? 'id'] ?? '')
walk(child, childTable, depth + 1, `${title}: ${childLabel}`)
}
}
}
walk(record, table, 1, '')
return groups
}
/**
* The values a new child record needs to belong to `parent` (`table create <child> --parent
* <table>=<pk>`): finds the parent table's association to the child table (or the one named) and
* returns the binding values. Throws when there is none, or more than one and none was named.
*/
export function parentLinkValues(parentTable: TableMetaLike, parent: RecordLike, childTable: TableMetaLike, associationName?: string): { association: AssociationMeta; values: Record<string, string | number | boolean> } {
const candidates = associationsOf(parentTable).filter((a) => a.associatedTableName === childTable.name && (!associationName || a.name === associationName))
if (!candidates.length) throw new AssociationError(`${parentTable.label ?? parentTable.name} has no association to ${childTable.label ?? childTable.name}${associationName ? ` named ${associationName}` : ''}; associations: ${associationsOf(parentTable).map((a) => `${a.name} (${a.associatedTableName})`).join(', ') || 'none'}`)
if (candidates.length > 1) throw new AssociationError(`${parentTable.label ?? parentTable.name} has several associations to ${childTable.label ?? childTable.name} (${candidates.map((a) => a.name).join(', ')}); name one with --association`)
const association = candidates[0]!
const relationship = relationshipValues(association, parentTable, parent, childTable)
if ('error' in relationship) throw new AssociationError(relationship.error)
if (relationship.reverse) throw new AssociationError(`${association.name} is a reverse association; create the ${parentTable.label ?? parentTable.name} from the other side.`)
return { association, values: relationship.values }
}
/** A page of a parent's children, with the total count (`table children`). */
export interface ChildPage {
association: AssociationMeta
filter: QueryFilter
records: RecordLike[]
count?: number
}
/** Reads one page of children through `queryTable` (none with `limit: 0`) and their total through `countTable`. */
export async function fetchChildren(options: { baseUrl: string; fetch: typeof fetch; parentTable: TableMetaLike; parent: RecordLike; childTable: TableMetaLike; association: AssociationMeta; limit?: number; skip?: number; count?: boolean }): Promise<ChildPage> {
const filter = childFilter(options.association, options.parentTable, options.parent, options.childTable, { limit: options.limit, skip: options.skip })
let records: RecordLike[] = []
if (options.limit !== 0) {
const query = await callOperation(options.baseUrl, 'queryTable', { path: { tableName: options.childTable.name }, json: { filter } }, options.fetch)
const data = query.data as { records?: RecordLike[] } | RecordLike[]
records = Array.isArray(data) ? data : data.records ?? []
}
let count: number | undefined
if (options.count !== false) {
const { skip: _skip, limit: _limit, orderBys: _orderBys, ...countFilter } = filter
const result = await callOperation(options.baseUrl, 'countTable', { path: { tableName: options.childTable.name }, query: { includeDistinct: 'false' }, json: { filter: countFilter } }, options.fetch)
count = (result.data as { count?: number }).count
}
return { association: options.association, filter, records, ...(count !== undefined ? { count } : {}) }
}
+118
View File
@@ -0,0 +1,118 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* A record's audit history, read as the web UIs read it: through the audit process the table's
* `clientCapabilities.audits` names (the standard `GetAuditsForRecord`) when the user may run it,
* otherwise by querying the audit table joined to its detail table. Rows (one per changed field)
* are grouped into entries, newest first unless asked otherwise.
*/
import { callOperation } from '../api/call.js'
type J = Record<string, unknown>
/** The standard names, for servers without `clientCapabilities` (UI API before 1.6). */
export const AUDIT_DEFAULTS = { processName: 'GetAuditsForRecord', tableName: 'audit', detailTableName: 'auditDetail' } as const
/** Most audit rows read for one record, as in the web UIs. */
export const AUDIT_LIMIT = 1000
export interface AuditSource {
kind: 'process' | 'table'
processName?: string
tableName?: string
detailTableName?: string
}
export interface AuditEntry {
id: number
timestamp: string
user: string
action: 'INSERT' | 'UPDATE' | 'DELETE'
message: string
changes: Array<{ fieldName: string; oldValue: unknown; newValue: unknown; message?: string }>
}
/**
* How the user can read a table's audits, or undefined. An available capability names the process
* or table; when it is absent or unavailable, the instance metadata (permission-filtered) decides,
* since records of unaudited tables can still carry audits written explicitly.
*/
export function auditSource(tableMeta: J, metaData: J): AuditSource | undefined {
const audits = (tableMeta.clientCapabilities as J | undefined)?.audits as J | undefined
const detail = audits?.available ? (audits.detailTableName ? String(audits.detailTableName) : undefined) : AUDIT_DEFAULTS.detailTableName
if (audits?.available) {
if (audits.processName) return { kind: 'process', processName: String(audits.processName), ...(audits.tableName ? { tableName: String(audits.tableName) } : {}), ...(detail ? { detailTableName: detail } : {}) }
if (audits.tableName) return { kind: 'table', tableName: String(audits.tableName), ...(detail ? { detailTableName: detail } : {}) }
return undefined
}
const process = ((metaData.processes ?? {}) as Record<string, J>)[AUDIT_DEFAULTS.processName]
if (process && process.hasPermission !== false) return { kind: 'process', processName: AUDIT_DEFAULTS.processName, tableName: AUDIT_DEFAULTS.tableName, detailTableName: AUDIT_DEFAULTS.detailTableName }
const table = ((metaData.tables ?? {}) as Record<string, J>)[AUDIT_DEFAULTS.tableName]
if (table && table.readPermission !== false) return { kind: 'table', tableName: AUDIT_DEFAULTS.tableName, detailTableName: AUDIT_DEFAULTS.detailTableName }
return undefined
}
/** Groups audit rows (one per detail, from the detail join) into entries, in the rows' order. */
export function groupAuditRows(rows: J[], detailTableName = AUDIT_DEFAULTS.detailTableName as string): AuditEntry[] {
const entries = new Map<string, AuditEntry>()
for (const row of rows) {
const values = (row.values ?? {}) as J
const display = (row.displayValues ?? {}) as J
const id = String(values.id)
let entry = entries.get(id)
if (!entry) {
const message = typeof values.message === 'string' ? values.message : ''
entry = {
id: Number(values.id),
timestamp: String(values.timestamp ?? ''),
user: String(display.auditUserId ?? values.auditUserId ?? ''),
action: /\bInserted\b/.test(message) ? 'INSERT' : /\bDeleted\b/.test(message) ? 'DELETE' : 'UPDATE',
message,
changes: [],
}
entries.set(id, entry)
}
const detailId = values[`${detailTableName}.id`]
if (detailId !== null && detailId !== undefined) {
const detailMessage = values[`${detailTableName}.message`]
entry.changes.push({
fieldName: String(values[`${detailTableName}.fieldName`] ?? ''),
oldValue: values[`${detailTableName}.oldValue`] ?? null,
newValue: values[`${detailTableName}.newValue`] ?? null,
...(typeof detailMessage === 'string' ? { message: detailMessage } : {}),
})
}
}
return [...entries.values()]
}
/** Reads a record's audits; `total` is the distinct count when the server gives one (or the cap was reached). */
export async function getAudits(baseUrl: string, fetchImpl: typeof fetch, source: AuditSource, table: string, primaryKey: string, options: { ascending?: boolean } = {}): Promise<{ entries: AuditEntry[]; total: number | null }> {
const ascending = Boolean(options.ascending)
if (source.kind === 'process') {
const result = await callOperation(baseUrl, 'initProcess', { path: { processName: source.processName! }, form: { values: JSON.stringify({ tableName: table, recordId: primaryKey, isSortAscending: ascending, limit: AUDIT_LIMIT }), stepTimeoutMillis: '60000' } }, fetchImpl)
const response = (result.data ?? {}) as J
if (response.type === 'ERROR' || response.error) throw new Error(String(response.userFacingError ?? response.error ?? 'Error loading audits'))
const out = (response.values ?? {}) as J
if (!Array.isArray(out.audits)) throw new Error('Invalid audit response')
return { entries: groupAuditRows(out.audits as J[], source.detailTableName), total: typeof out.distinctCount === 'number' ? out.distinctCount : null }
}
const auditTable = source.tableName!
const detail = source.detailTableName
const criteria = [{ fieldName: 'auditTable.name', operator: 'EQUALS', values: [table] }, { fieldName: 'recordId', operator: 'EQUALS', values: [primaryKey] }]
const body = {
filter: { criteria, booleanOperator: 'AND', orderBys: [{ fieldName: 'timestamp', isAscending: ascending }, { fieldName: 'id', isAscending: ascending }, ...(detail ? [{ fieldName: `${detail}.id`, isAscending: true }] : [])], limit: AUDIT_LIMIT },
joins: [{ joinTable: 'auditTable', select: false, type: 'INNER' }, ...(detail ? [{ joinTable: detail, select: true, type: 'LEFT' }] : [])],
}
const records = ((await callOperation(baseUrl, 'queryTable', { path: { tableName: auditTable }, json: body }, fetchImpl)).data as J | undefined)?.records
if (!Array.isArray(records)) throw new Error('Invalid audit response')
let total: number | null = null
if (records.length === AUDIT_LIMIT) {
const count = (await callOperation(baseUrl, 'countTable', { path: { tableName: auditTable }, query: { includeDistinct: 'true' }, json: { filter: { criteria, booleanOperator: 'AND' } } }, fetchImpl)).data as J
total = Number(count.distinctCount ?? count.count)
}
return { entries: groupAuditRows(records as J[], detail), total }
}
+304
View File
@@ -0,0 +1,304 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The record view as text: sections in metadata order (hidden sections and hidden fields never
* shown), each value through its adornments (LINK, CHIP, REVEAL, RENDER_HTML, CODE_EDITOR, ERROR,
* FILE_DOWNLOAD, WIDGET, TOOLTIP, FILE_UPLOAD), and the help content that applies to a view
* screen. Mirrors the Next UI's FieldValue and adornment-utils, minus what only a browser can do.
*/
import type { FieldMeta } from '../forms/fields.js'
import { blocksToText, htmlToText, widgetToBlocks } from '../widgets/render-model.js'
import { displayDateTime, type DisplayOptions } from '../forms/datetime.js'
import type { RecordLike, SectionMeta, TableMetaLike } from './associations.js'
export interface HelpContent {
summary?: string
title?: string
content?: string
format?: 'TEXT' | 'HTML' | 'MARKDOWN' | string
contentAsText?: string
contentAsHtml?: string
roles?: string[]
links?: Array<{ label?: string; url?: string }>
}
/** The roles that apply to a record view, most specific first (Next's VIEW_SCREEN_HELP_ROLES). */
export const VIEW_SCREEN_HELP_ROLES = ['VIEW_SCREEN', 'READ_SCREENS', 'ALL_SCREENS'] as const
/** The help entry for a screen: the first entry naming one of the roles (in role order), else one with no roles. */
export function selectHelpContent(entries: unknown, roles: readonly string[] = VIEW_SCREEN_HELP_ROLES): HelpContent | undefined {
const list = (Array.isArray(entries) ? entries : entries && typeof entries === 'object' ? [entries] : []) as HelpContent[]
const usable = list.filter((e) => Boolean(e && (e.content?.trim() || e.contentAsHtml?.trim() || e.summary?.trim() || e.contentAsText?.trim())))
for (const role of roles) {
const match = usable.find((e) => e.roles?.includes(role))
if (match) return match
}
return usable.find((e) => !e.roles || e.roles.length === 0)
}
/** Help content as plain text: TEXT as written, HTML and MARKDOWN through the server's rendered HTML. */
export function helpEntryText(entry: HelpContent | undefined): string {
if (!entry) return ''
const format = entry.format ?? 'TEXT'
let text = entry.contentAsText?.trim() ? entry.contentAsText
: format === 'TEXT' ? entry.content ?? entry.summary ?? ''
: htmlToText(entry.contentAsHtml || entry.content || entry.summary || '')
text = text.trim()
if (entry.title && !text.startsWith(entry.title)) text = `${entry.title}: ${text}`
const links = (entry.links ?? []).filter((l) => l?.url).map((l) => `${l.label ?? l.url} <${l.url}>`)
return links.length ? `${text}${text ? ' ' : ''}(${links.join(', ')})` : text
}
/** Table help for a view: the table's `helpContent`, or its `helpContents` slots (every slot that has view help, by slot name). */
export function tableViewHelp(table: TableMetaLike, roles: readonly string[] = VIEW_SCREEN_HELP_ROLES): Array<{ slot: string; text: string }> {
const out: Array<{ slot: string; text: string }> = []
const single = selectHelpContent(table.helpContent, roles)
if (single) out.push({ slot: 'table', text: helpEntryText(single) })
const slots = table.helpContents
if (slots && typeof slots === 'object' && !Array.isArray(slots)) {
for (const [slot, entries] of Object.entries(slots as Record<string, unknown>)) {
const entry = selectHelpContent(entries, roles)
if (entry) out.push({ slot, text: helpEntryText(entry) })
}
}
return out
}
export type Adornment = { type: string; values?: Record<string, unknown> }
export function findAdornment(field: FieldMeta, type: string): Adornment | undefined {
return field.adornments?.find((a) => a.type === type)
}
function adornmentString(field: FieldMeta, type: string, key: string): string | undefined {
const value = findAdornment(field, type)?.values?.[key]
return typeof value === 'string' && value !== '' ? value : undefined
}
export type LinkTarget = { kind: 'record'; tableName: string; primaryKey: string } | { kind: 'url'; href: string; external: boolean }
/** Where a LINK adornment points: a record (static or dynamic table) or a URL. */
export function linkTarget(field: FieldMeta, record: RecordLike): LinkTarget | undefined {
const adornment = findAdornment(field, 'LINK')
const raw = record.values[field.name]
if (!adornment || raw === null || raw === undefined || raw === '') return undefined
const staticTable = adornmentString(field, 'LINK', 'toRecordFromTable')
const dynamicTable = adornment.values?.toRecordFromTableDynamic ? record.displayValues?.[`${field.name}:toRecordFromTableDynamic`] : undefined
const tableName = staticTable ?? (typeof dynamicTable === 'string' && dynamicTable ? dynamicTable : undefined)
if (tableName) return { kind: 'record', tableName, primaryKey: String(raw) }
if (adornment.values?.toRecordFromTableDynamic) return undefined
const href = String(raw).trim()
if (/^https?:\/\//i.test(href)) return { kind: 'url', href, external: true }
if (href.startsWith('/') && !href.startsWith('//')) return { kind: 'url', href, external: false }
return undefined
}
/**
* The related record a field points at: its LINK adornment's record, or, for a possible-value
* field whose source is a table, that table's record (Next shows both as record links).
*/
export function relatedRecordTarget(field: FieldMeta, record: RecordLike, tableNames?: Iterable<string>): { tableName: string; primaryKey: string } | undefined {
const link = linkTarget(field, record)
if (link?.kind === 'record') return { tableName: link.tableName, primaryKey: link.primaryKey }
const raw = record.values[field.name]
const source = field.possibleValueSourceName
if (!source || raw === null || raw === undefined || raw === '') return undefined
const known = tableNames ? new Set(tableNames) : undefined
if (known && !known.has(source)) return undefined
return { tableName: source, primaryKey: String(raw) }
}
const FIELD_DOWNLOAD_PATH = /^\/data\/([^/?#]+)\/([^/?#]+)\/([^/?#]+)\/([^?#]+)(\?[^#]*)?$/
/**
* A stored file a FILE_DOWNLOAD field offers: its name, and either the UI API route
* (`downloadRecordFieldFile`: table, key, field, file name) or an outside URL.
*/
export function fileDownload(field: FieldMeta, record: RecordLike): { fileName: string; route?: { tableName: string; primaryKey: string; fieldName: string; filename: string }; url?: string } | undefined {
const adornment = findAdornment(field, 'FILE_DOWNLOAD')
if (!adornment) return undefined
const dynamic = adornment.values?.downloadUrlDynamic ? record.displayValues?.[`${field.name}:downloadUrlDynamic`] : undefined
const url = typeof dynamic === 'string' && dynamic ? dynamic : record.values[field.name]
if (typeof url !== 'string' || !url || (!url.startsWith('/') && !/^https?:\/\//i.test(url)) || url.startsWith('//')) return undefined
const display = record.displayValues?.[field.name]
const last = url.split('?')[0]!.split('/').pop() || field.label
const fileName = typeof display === 'string' && display && display !== url ? display : safeDecode(last)
const match = FIELD_DOWNLOAD_PATH.exec(url)
if (match && !dynamic) return { fileName, route: { tableName: safeDecode(match[1]!), primaryKey: safeDecode(match[2]!), fieldName: safeDecode(match[3]!), filename: safeDecode(match[4]!) } }
return { fileName, url }
}
function safeDecode(segment: string): string {
try {
return decodeURIComponent(segment)
} catch {
return segment
}
}
/** TOOLTIP text (dynamic per record, else static). */
export function tooltipText(field: FieldMeta, record: RecordLike): string | undefined {
const adornment = findAdornment(field, 'TOOLTIP')
if (!adornment) return undefined
if (adornment.values?.tooltipDynamic) {
const dynamic = record.displayValues?.[`${field.name}:tooltipDynamic`]
if (typeof dynamic === 'string' && dynamic) return dynamic
}
return adornmentString(field, 'TOOLTIP', 'staticText')
}
export const MASK = '••••••••'
export interface ValueOptions {
/** Show REVEAL values instead of the mask. */
reveal?: boolean
/** Table names the user can open (possible-value sources that are tables become record links). */
tableNames?: Iterable<string>
/** The record's table and key, for the download command hint. */
tableName?: string
primaryKey?: string
/** Widget metadata, for WIDGET adornments (a missing widget is reported, as Next does). */
widgets?: Record<string, unknown>
/** Text placeholder for empty values (default empty). */
empty?: string
/** DATE_TIME display: the viewer's zone (default), another zone, or the raw UTC instant (`--utc`). */
dateTime?: DisplayOptions
}
/** A field value rendered for a terminal. */
export interface ValueText {
text: string
/** The record or URL the value links to. */
link?: { tableName: string; primaryKey: string } | { url: string }
/** A downloadable file. */
file?: ReturnType<typeof fileDownload>
/** The adornment that decided the rendering. */
adornment?: string
masked?: boolean
error?: boolean
}
function str(value: unknown): string {
if (value === null || value === undefined) return ''
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
/** Renders one value through its adornments, in Next's order; TOOLTIP text is appended in parentheses. */
export function valueText(field: FieldMeta, record: RecordLike, options: ValueOptions = {}): ValueText {
const out = valueTextWithoutTooltip(field, record, options)
const tip = tooltipText(field, record)
if (tip && out.text) out.text = `${out.text} (${tip})`
return out
}
function valueTextWithoutTooltip(field: FieldMeta, record: RecordLike, options: ValueOptions): ValueText {
const raw = record.values[field.name]
const display = record.displayValues?.[field.name]
const value = display ?? raw
const empty = options.empty ?? ''
const widget = findAdornment(field, 'WIDGET')
if (widget) {
const widgetName = typeof widget.values?.widgetName === 'string' ? widget.values.widgetName : ''
if (options.widgets && !options.widgets[widgetName]) return { text: `Error: Could not load widget [${widgetName}]`, adornment: 'WIDGET', error: true }
if (raw === null || raw === undefined) return { text: empty, adornment: 'WIDGET' }
const text = blocksToText(widgetToBlocks(raw), { width: 80 })
return { text: text || str(raw), adornment: 'WIDGET' }
}
if (findAdornment(field, 'LINK')) {
if (value === null || value === undefined || value === '') return { text: empty, adornment: 'LINK' }
const target = linkTarget(field, record)
if (target?.kind === 'record') return { text: `${str(value)} → ${target.tableName} ${target.primaryKey}`, link: { tableName: target.tableName, primaryKey: target.primaryKey }, adornment: 'LINK' }
if (target?.kind === 'url') return { text: str(value) === target.href ? target.href : `${str(value)} <${target.href}>`, link: { url: target.href }, adornment: 'LINK' }
return { text: str(value), adornment: 'LINK' }
}
if (findAdornment(field, 'REVEAL')) {
if (value === null || value === undefined || value === '') return { text: empty, adornment: 'REVEAL' }
return options.reveal ? { text: str(raw ?? value), adornment: 'REVEAL' } : { text: MASK, adornment: 'REVEAL', masked: true }
}
if (findAdornment(field, 'RENDER_HTML')) return { text: htmlToText(str(raw)), adornment: 'RENDER_HTML' }
if (findAdornment(field, 'CHIP')) {
if (value === null || value === undefined || value === '') return { text: empty, adornment: 'CHIP' }
return { text: `[${str(value)}]`, adornment: 'CHIP' }
}
if (findAdornment(field, 'CODE_EDITOR')) return { text: str(raw ?? value), adornment: 'CODE_EDITOR' }
if (findAdornment(field, 'ERROR')) {
const text = str(raw ?? value)
return { text: text ? `! ${text}` : empty, adornment: 'ERROR', error: Boolean(text) }
}
if (findAdornment(field, 'FILE_DOWNLOAD')) {
const file = fileDownload(field, record)
if (!file) return { text: empty, adornment: 'FILE_DOWNLOAD' }
const hint = file.route && options.tableName ? ` (qqq table download ${options.tableName} ${options.primaryKey ?? file.route.primaryKey} ${field.name})` : file.url ? ` <${file.url}>` : ''
return { text: `${file.fileName}${hint}`, file, adornment: 'FILE_DOWNLOAD' }
}
if (value === null || value === undefined || value === '') return { text: empty }
if (field.type === 'DATE_TIME') return { text: displayDateTime(raw, display, options.dateTime) }
if (field.type === 'BLOB' && typeof raw === 'string') {
const bytes = Math.floor(raw.replace(/=+$/, '').length * 3 / 4)
const hint = options.tableName ? ` (qqq table download ${options.tableName} ${options.primaryKey ?? ''} ${field.name})`.replace(' ', ' ') : ''
return { text: `<file, ${bytes} bytes>${hint}`, ...(findAdornment(field, 'FILE_UPLOAD') ? { adornment: 'FILE_UPLOAD' } : {}) }
}
const related = relatedRecordTarget(field, record, options.tableNames)
if (related) return { text: `${str(value)} → ${related.tableName} ${related.primaryKey}`, link: related }
return { text: str(value), ...(findAdornment(field, 'FILE_UPLOAD') ? { adornment: 'FILE_UPLOAD' } : {}) }
}
/** One section of the record view. */
export interface ViewSection {
name: string
label: string
tier?: string
help?: string
fields: Array<{ field: FieldMeta; value: ValueText; help?: string }>
/** A widget section (child list or other widget): rendered by the caller. */
widgetName?: string
}
/**
* The record view: sections in metadata order without hidden sections or hidden fields; fields
* that no section lists come last under "Other Fields" unless they appear only in hidden sections.
* Without sections, every visible non-heavy field.
*/
export function recordView(table: TableMetaLike, record: RecordLike, options: ValueOptions & { helpContent?: boolean } = {}): ViewSection[] {
const fields = table.fields
const sections = (table.sections ?? []) as SectionMeta[]
const placed = new Set<string>()
const out: ViewSection[] = []
const pk = table.primaryKeyField ?? 'id'
const entry = (name: string) => {
const field = fields[name]!
const help = options.helpContent ? helpEntryText(selectHelpContent(field.helpContents)) : ''
return { field, value: valueText(field, record, { ...options, tableName: options.tableName ?? table.name, primaryKey: options.primaryKey ?? str(record.values[pk]) }), ...(help ? { help } : {}) }
}
for (const section of sections) {
for (const name of section.fieldNames ?? []) placed.add(name)
if (section.isHidden) continue
const help = options.helpContent ? helpEntryText(selectHelpContent(section.helpContents)) : ''
const names = (section.fieldNames ?? []).filter((n) => fields[n] && !fields[n]!.isHidden)
if (!names.length && !section.widgetName) continue
out.push({ name: section.name, label: section.label ?? section.name, ...(section.tier ? { tier: section.tier } : {}), ...(help ? { help } : {}), fields: names.map(entry), ...(section.widgetName ? { widgetName: section.widgetName } : {}) })
}
const rest = Object.keys(fields).filter((n) => !placed.has(n) && !fields[n]!.isHidden && (!sections.length ? !fields[n]!.isHeavy || record.values[n] !== undefined : true))
if (rest.length) {
const ordered = rest.includes(pk) ? [pk, ...rest.filter((n) => n !== pk)] : rest
out.push({ name: sections.length ? 'otherFields' : 'fields', label: sections.length ? 'Other Fields' : 'Fields', fields: ordered.map(entry) })
}
return out
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Saving downloaded files. A file name that comes from the server (a record's file name, a
* Content-Disposition, a process DOWNLOAD_FORM) is untrusted: only its final path component is
* kept, and names that could escape the directory or hide characters are refused. A default
* name never overwrites an existing file: the next free `name-1.ext`, `name-2.ext` is taken;
* only a path the user named (`--out`) is overwritten.
*/
import { writeFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
export class UnsafeFileNameError extends Error {}
/** The final path component of a server-supplied name (either separator); throws on empty, `.`, `..` or control characters. */
export function safeFileName(name: unknown): string {
const base = String(name ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
if (!base.trim() || base === '.' || base === '..' || /[\u0000-\u001f\u007f]/.test(base)) {
throw new UnsafeFileNameError(`unsafe file name from server: ${JSON.stringify(String(name ?? ''))}`)
}
return base
}
/** Candidate names when one exists already: `a.csv`, `a-1.csv`, `a-2.csv`, … */
export function* alternativeNames(name: string): Generator<string> {
yield name
const dot = name.lastIndexOf('.')
const stem = dot > 0 ? name.slice(0, dot) : name
const ext = dot > 0 ? name.slice(dot) : ''
for (let i = 1; i < 1000; i++) yield `${stem}-${i}${ext}`
}
/**
* Writes a download and returns the path written. `explicit` is true when the user named the
* path: it is written as given (an existing file is replaced). Otherwise the file is created
* exclusively (flag wx), taking `name-1.ext`, `name-2.ext`, … when the name is taken.
*/
export function saveDownload(path: string, bytes: Uint8Array, options: { explicit: boolean }): string {
if (options.explicit) {
writeFileSync(path, bytes)
return path
}
const dir = dirname(path)
for (const candidate of alternativeNames(basename(path))) {
const target = dir === '.' ? candidate : join(dir, candidate)
try {
writeFileSync(target, bytes, { flag: 'wx' })
return target
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
}
throw new UnsafeFileNameError(`no free file name for ${basename(path)} in ${dir}`)
}
+107
View File
@@ -0,0 +1,107 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { AssociationError, associationGroups, associationLabel, childFilter, childListFields, fetchChildren, findAssociation, parentLinkValues, relationshipValues, type RecordLike, type TableMetaLike } from '../records/associations.js'
const f = (name: string, extra: Record<string, unknown> = {}) => ({ name, label: name[0]!.toUpperCase() + name.slice(1), type: 'STRING', ...extra })
const person: TableMetaLike = {
name: 'person', label: 'Person', primaryKeyField: 'id',
fields: { id: f('id', { type: 'INTEGER' }), firstName: f('firstName') },
associations: [{ name: 'pets', associatedTableName: 'pet', join: { name: 'personJoinPet', type: 'ONE_TO_MANY', leftTable: 'person', rightTable: 'pet', joinOns: [{ leftField: 'id', rightField: 'personId' }] } }],
}
const pet: TableMetaLike = {
name: 'pet', label: 'Pet', primaryKeyField: 'id', readPermission: true,
fields: { id: f('id', { type: 'INTEGER' }), name: f('name'), personId: f('personId', { type: 'INTEGER', possibleValueSourceName: 'person' }), secret: f('secret', { isHidden: true }), blob: f('blob', { isHeavy: true }) },
associations: [{ name: 'notes', associatedTableName: 'petNote', join: { name: 'petJoinNote', leftTable: 'pet', rightTable: 'petNote', joinOns: [{ leftField: 'id', rightField: 'petId' }], orderBys: [{ fieldName: 'id', isAscending: false }] } }],
}
const petNote: TableMetaLike = { name: 'petNote', label: 'Pet Note', primaryKeyField: 'id', fields: { id: f('id'), petId: f('petId'), note: f('note') } }
const avery: RecordLike = {
tableName: 'person', values: { id: 1, firstName: 'Avery' }, recordLabel: 'Avery Sample',
associatedRecords: {
pets: [
{ tableName: 'pet', values: { id: 1, name: 'Charlie', personId: 1 }, recordLabel: 'Charlie', associatedRecords: { notes: [{ tableName: 'petNote', values: { id: 1, petId: 1, note: 'Target note' }, recordLabel: 'Target note' }] } },
{ tableName: 'pet', values: { id: 2, name: 'Coco', personId: 1 }, recordLabel: 'Coco' },
],
},
}
test('relationship values bind a child to its parent; composite joins give one value per pair', () => {
assert.deepEqual(relationshipValues(person.associations![0]!, person, avery, pet), { values: { personId: 1 }, reverse: false })
const order: TableMetaLike = { name: 'order', fields: { id: f('id'), clientId: f('clientId') }, associations: [{ name: 'lines', associatedTableName: 'line', join: { leftTable: 'order', rightTable: 'line', joinOns: [{ leftField: 'id', rightField: 'orderId' }, { leftField: 'clientId', rightField: 'clientId' }] } }] }
const line: TableMetaLike = { name: 'line', fields: { id: f('id'), orderId: f('orderId'), clientId: f('clientId') } }
assert.deepEqual(relationshipValues(order.associations![0]!, order, { values: { id: 7, clientId: 'acme' } }, line), { values: { orderId: 7, clientId: 'acme' }, reverse: false })
assert.ok('error' in relationshipValues(order.associations![0]!, order, { values: { id: 7 } }, line), 'a missing join value is an error, not a wrong link')
assert.ok('error' in relationshipValues(person.associations![0]!, person, avery, petNote), 'the wrong child table is refused')
})
test('the child filter is EQUALS on every binding field, ordered by the join, then by the child key', () => {
assert.deepEqual(childFilter(person.associations![0]!, person, avery, pet, { limit: 10, skip: 20 }), {
criteria: [{ fieldName: 'personId', operator: 'EQUALS', values: ['1'] }], booleanOperator: 'AND', orderBys: [{ fieldName: 'id', isAscending: true }], skip: 20, limit: 10,
})
assert.deepEqual(childFilter(pet.associations![0]!, pet, avery.associatedRecords!.pets![0]!, petNote).orderBys, [{ fieldName: 'id', isAscending: false }])
const reverse = { name: 'owner', associatedTableName: 'person', join: { leftTable: 'person', rightTable: 'pet', joinOns: [{ leftField: 'id', rightField: 'personId' }] } }
assert.throws(() => childFilter(reverse, pet, { values: { id: 1, personId: 1 } }, person), AssociationError)
})
test('association groups walk three levels and keep every declared association, empty or not', () => {
const groups = associationGroups(avery, person, { person, pet, petNote })
assert.deepEqual(groups.map((g) => [g.title, g.depth, g.records.length]), [
['Pet', 1, 2],
['Pet: Charlie: Pet Note', 2, 1],
['Pet: Coco: Pet Note', 2, 0],
])
assert.equal(groups[1]!.records[0]!.values.note, 'Target note', 'each pet shows only its own notes')
})
test('a child table the user may not read, or without metadata, is unavailable without data', () => {
const denied = associationGroups(avery, person, { person, pet: { ...pet, readPermission: false }, petNote })
assert.equal(denied.length, 1)
assert.match(denied[0]!.unavailable!, /may not read Pet/)
assert.deepEqual(denied[0]!.records, [])
assert.match(associationGroups(avery, person, { person })[0]!.unavailable!, /No metadata/)
})
test('labels come from the child-list widget section, then an exposed join, then the child table', () => {
const salesOrder: TableMetaLike = { ...person, sections: [{ name: 'lines', label: 'Order Lines', widgetName: 'w' }] }
assert.equal(associationLabel(salesOrder, person.associations![0]!, { widgets: { w: { type: 'childRecordList', defaultValues: { joinName: 'personJoinPet' } } }, childTable: pet }), 'Order Lines')
assert.equal(associationLabel({ ...person, exposedJoins: [{ joinTable: 'pet', label: 'Animals' }] }, person.associations![0]!, { childTable: pet }), 'Animals')
assert.equal(associationLabel(person, person.associations![0]!, { childTable: pet }), 'Pet')
assert.equal(associationLabel(person, person.associations![0]!), 'pets')
})
test('find by association name or by child table; parent link values for a new child', () => {
assert.equal(findAssociation(person, 'pets')?.name, 'pets')
assert.equal(findAssociation(person, 'pet')?.name, 'pets')
assert.equal(findAssociation(person, 'nope'), undefined)
assert.deepEqual(parentLinkValues(person, avery, pet).values, { personId: 1 })
assert.throws(() => parentLinkValues(person, avery, petNote), /no association to Pet Note/)
})
test('child list fields skip hidden, heavy and back-pointing fields', () => {
assert.deepEqual(childListFields(pet, ['personId']), ['id', 'name'])
const sectioned: TableMetaLike = { ...pet, fields: { ...pet.fields, a: f('a'), b: f('b'), c: f('c') }, sections: [{ name: 'x', tier: 'T2', fieldNames: ['b', 'a'] }, { name: 'y', tier: 'T1', fieldNames: ['id', 'name'] }, { name: 'z', isHidden: true, fieldNames: ['c'] }] }
assert.deepEqual(childListFields(sectioned, ['personId']), ['id', 'name', 'b', 'a'], 'section order by tier; fields only in hidden sections left out')
})
test('fetchChildren queries one page and counts all children with the same criteria', async () => {
const calls: Array<{ url: string; body: unknown }> = []
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input instanceof Request ? input.url : input)
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined })
const body = url.includes('/count') ? { count: 4 } : { records: [{ values: { id: 3, name: 'Louie', personId: 1 } }] }
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
}
const page = await fetchChildren({ baseUrl: 'http://h', fetch: fetchImpl, parentTable: person, parent: avery, childTable: pet, association: person.associations![0]!, limit: 1, skip: 2 })
assert.equal(page.count, 4)
assert.equal(page.records[0]!.values.name, 'Louie')
assert.match(calls[0]!.url, /\/qqq\/v1\/table\/pet\/query$/)
assert.deepEqual((calls[0]!.body as { filter: { limit: number; skip: number } }).filter.limit, 1)
assert.match(calls[1]!.url, /\/qqq\/v1\/table\/pet\/count\?includeDistinct=false$/)
assert.deepEqual(calls[1]!.body, { filter: { criteria: [{ fieldName: 'personId', operator: 'EQUALS', values: ['1'] }], booleanOperator: 'AND' } })
})
+46
View File
@@ -0,0 +1,46 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { auditSource, getAudits, groupAuditRows } from '../records/audits.js'
test('the audit source: an available capability, else the instance process, else the audit table', () => {
assert.deepEqual(auditSource({ clientCapabilities: { audits: { available: true, processName: 'P', detailTableName: 'D' } } }, {}), { kind: 'process', processName: 'P', detailTableName: 'D' })
assert.deepEqual(auditSource({ clientCapabilities: { audits: { available: true, tableName: 'A' } } }, {}), { kind: 'table', tableName: 'A' })
assert.equal(auditSource({ clientCapabilities: { audits: { available: false } } }, { processes: {}, tables: {} }), undefined)
assert.equal(auditSource({}, { processes: { GetAuditsForRecord: {} } })!.kind, 'process')
assert.deepEqual(auditSource({}, { processes: { GetAuditsForRecord: { hasPermission: false } }, tables: { audit: {} } }), { kind: 'table', tableName: 'audit', detailTableName: 'auditDetail' })
})
test('rows group into entries with their field changes', () => {
const entries = groupAuditRows([
{ values: { id: 2, message: 'Record was Edited', timestamp: 't2', auditUserId: 1, 'auditDetail.id': 5, 'auditDetail.fieldName': 'a', 'auditDetail.oldValue': 1, 'auditDetail.newValue': 2 }, displayValues: { auditUserId: 'Ann' } },
{ values: { id: 2, message: 'Record was Edited', 'auditDetail.id': 6, 'auditDetail.fieldName': 'b', 'auditDetail.message': 'Set b' } },
{ values: { id: 1, message: 'Record was Inserted', 'auditDetail.id': null } },
])
assert.deepEqual(entries.map((e) => [e.id, e.action, e.user, e.changes.length]), [[2, 'UPDATE', 'Ann', 2], [1, 'INSERT', '', 0]])
assert.deepEqual(entries[0]!.changes[1], { fieldName: 'b', oldValue: null, newValue: null, message: 'Set b' })
})
test('audits read through the process, or the audit table with its joins', async () => {
const seen: Array<{ url: string; body: unknown }> = []
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input)
const body = init?.body instanceof FormData ? JSON.parse(String(init.body.get('values'))) : JSON.parse(String(init?.body))
seen.push({ url, body })
const data = url.includes('/processes/') ? { type: 'COMPLETE', values: { audits: [{ values: { id: 1, message: 'Record was Deleted' } }], distinctCount: 1 } } : { records: [{ values: { id: 3, message: 'x' } }] }
return new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } })
}
const viaProcess = await getAudits('http://h', fetchImpl, { kind: 'process', processName: 'GetAuditsForRecord', detailTableName: 'auditDetail' }, 'person', '7')
assert.deepEqual([viaProcess.entries[0]!.action, viaProcess.total], ['DELETE', 1])
assert.deepEqual(seen[0]!.body, { tableName: 'person', recordId: '7', isSortAscending: false, limit: 1000 })
const viaTable = await getAudits('http://h', fetchImpl, { kind: 'table', tableName: 'audit', detailTableName: 'auditDetail' }, 'person', '7', { ascending: true })
assert.equal(viaTable.entries[0]!.id, 3)
assert.match(seen[1]!.url, /\/table\/audit\/query$/)
const body = seen[1]!.body as { joins: unknown[]; filter: { orderBys: Array<{ isAscending: boolean }> } }
assert.deepEqual(body.joins, [{ joinTable: 'auditTable', select: false, type: 'INNER' }, { joinTable: 'auditDetail', select: true, type: 'LEFT' }])
assert.equal(body.filter.orderBys[0]!.isAscending, true)
})
@@ -0,0 +1,130 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import { Collaboration, presenceChannel, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
import { ExtensionNotInstalledError, ExtensionRegistry } from '../extensions/registry.js'
const withCollab = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.1.0' }] })
const without = ExtensionRegistry.fromVersion({ extensions: [{ name: 'esb', version: '1.1.0' }] })
const result = (data: unknown): CallResult => ({ status: 200, headers: new Headers(), data, contentType: 'application/json' })
const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms))
/** A fake server: records calls, answers from a handler. */
function fake(handler: (id: string, input: CallInput) => unknown, stream?: (signal: AbortSignal) => AsyncIterable<RealtimeMessage>) {
const calls: { id: string; input: CallInput }[] = []
const transport: CollaborationTransport = {
call: async (id, input) => {
calls.push({ id, input })
return result(handler(id, input))
},
openStream: stream,
}
return { calls, transport }
}
test('without the extension, presence is a no-op and other calls throw the typed error before any request', async () => {
const { calls, transport } = fake(() => ({}))
const collab = new Collaboration(without, transport)
assert.equal(collab.available, false)
const handle = await collab.joinPresence({ table: 'person', pk: '1' })
assert.deepEqual([handle.count, handle.people, handle.state.available], [0, [], false])
await handle.stop()
assert.equal((await collab.presenceSnapshot({ table: 'person' })).count, 0)
await assert.rejects(collab.favorites(), (e: unknown) => e instanceof ExtensionNotInstalledError && /installed: esb 1\.1\.0/.test(e.message))
assert.equal(calls.length, 0)
})
test('favorites, recents, comments, follow, people and my work call the qbit operations', async () => {
const { calls, transport } = fake((id) => {
if (id === 'getQbitCollaborationFavorites') return { items: [{ id: 1, targetType: 'RECORD', targetName: 'person', recordId: '1', label: 'Avery' }], nextCursor: null }
if (id === 'getQbitCollaborationRecordsByTableByPkComments') return { threads: [{ id: 5, body: 'hi' }], nextCursor: 'c2' }
if (id === 'postQbitCollaborationRecordsByTableByPkComments') return { id: 6, body: 'new' }
if (id.includes('Follow')) return { following: id !== 'deleteQbitCollaborationRecordsByTableByPkFollow' }
if (id === 'getQbitCollaborationPeople') return { items: [{ userId: 'bob', displayName: 'Bob Chen' }] }
if (id === 'getQbitCollaborationMyWork') return { openTaskCount: 2, unreadMentionCount: 1 }
return { items: [], success: true }
})
const collab = new Collaboration(withCollab, transport)
assert.equal((await collab.favorites())[0]?.label, 'Avery')
await collab.addFavorite({ targetType: 'RECORD', targetName: 'person', recordId: '1' })
await collab.removeFavorite({ targetType: 'TABLE', targetName: 'person' })
await collab.recents(10)
assert.deepEqual(await collab.comments('person', '1', { limit: 5 }), { threads: [{ id: 5, body: 'hi' }], nextCursor: 'c2' })
assert.equal((await collab.addComment('person', '1', 'new', { parentCommentId: 5 })).id, 6)
assert.equal((await collab.follow('person', '1', { notifyOnComment: true })).following, true)
assert.equal((await collab.unfollow('person', '1')).following, false)
assert.equal((await collab.people('bo'))[0]?.displayName, 'Bob Chen')
assert.equal((await collab.myWork()).openTaskCount, 2)
const byId = Object.fromEntries(calls.map((c) => [c.id, c.input]))
assert.deepEqual(byId.putQbitCollaborationFavorites, { json: { targetType: 'RECORD', targetName: 'person', recordId: '1' } })
assert.deepEqual(byId.getQbitCollaborationRecents, { query: { limit: '10' } })
assert.deepEqual(byId.postQbitCollaborationRecordsByTableByPkComments, { path: { table: 'person', pk: '1' }, json: { body: 'new', parentCommentId: 5 } })
assert.deepEqual(byId.getQbitCollaborationPeople, { query: { q: 'bo', limit: undefined } })
})
test('joinPresence holds a lease, polls on each heartbeat without a stream, tracks since, and leaves on stop', async () => {
let viewers = [{ userId: 'bob', displayName: 'Bob Chen', mode: 'VIEWING' }]
const { calls, transport } = fake((id) => (id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '1', viewers }))
const states: PresenceState[] = []
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { heartbeatMs: 20, connectionId: 'cli-test', onChange: (s) => states.push(s) })
assert.equal(handle.count, 1)
const bobSince = handle.people[0]!.since
viewers = [...viewers, { userId: 'carol', displayName: 'Carol', mode: 'EDITING' }]
await tick(35)
assert.deepEqual(handle.people.map((p) => [p.name, p.mode]), [['Bob Chen', 'VIEWING'], ['Carol', 'EDITING']])
assert.equal(handle.people[0]!.since, bobSince)
await handle.stop()
const posts = calls.filter((c) => c.id === 'postQbitCollaborationPresence')
assert.ok(posts.length >= 2)
assert.deepEqual(posts[0]!.input.json, { connectionId: 'cli-test', table: 'person', pk: '1', mode: 'VIEWING', fieldName: undefined })
assert.deepEqual(calls.at(-1), { id: 'deleteQbitCollaborationPresence', input: { json: { connectionId: 'cli-test', table: 'person', pk: '1' } } })
const after = calls.length
await tick(30)
assert.equal(calls.length, after, 'no heartbeat after stop')
assert.ok(states.length >= 2)
})
test('joinPresence subscribes the scope channel on hello and applies presence.changed for its scope only', async () => {
const events: RealtimeMessage[] = [
{ event: 'hello', data: { connectionId: 'sse-1', heartbeatSeconds: 25 } },
{ event: 'presence.changed', data: { table: 'person', viewers: [{ userId: 'zed', displayName: 'Zed' }] } },
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'bob', displayName: 'Bob Chen', mode: 'EDITING', fieldName: 'email' }] } },
]
const emptied: RealtimeMessage = { event: 'presence.changed', data: { table: 'person', pk: '1' } }
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
let next = 0
while (!signal.aborted) {
await tick(2)
if (next < events.length) yield events[next++]!
}
}
const { calls, transport } = fake((id, input) => {
if (id === 'updateRealtimeSubscriptions') return { active: (input.json as { add: string[] }).add, refused: [] }
if (id === 'deleteQbitCollaborationPresence') return { success: true }
return { table: 'person', pk: '1', viewers: [] }
}, stream)
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
await tick(30)
assert.deepEqual(calls.find((c) => c.id === 'updateRealtimeSubscriptions')?.input.json, { connectionId: 'sse-1', add: ['record:person:1'] })
assert.equal(handle.state.live, true)
assert.deepEqual(handle.people.map((p) => [p.userId, p.mode, p.fieldName]), [['bob', 'EDITING', 'email']])
events.push(emptied)
await tick(10)
assert.equal(handle.count, 0, 'a snapshot without viewers empties the scope (the qbit omits the empty list)')
await handle.stop()
assert.equal(calls.some((c) => c.id === 'postQbitCollaborationPresence' || c.id === 'deleteQbitCollaborationPresence'), false, 'watch-only never takes a lease')
assert.equal(presenceChannel({ table: 'person' }), 'table:person')
})
test('a refused heartbeat is reported as the state error', async () => {
const transport: CollaborationTransport = { call: async (id) => { if (id === 'postQbitCollaborationPresence') throw new Error('Not found'); return result({ success: true }) } }
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'secret' }, { heartbeatMs: 10000 })
assert.equal(handle.state.error, 'Not found')
await handle.stop()
})
+70
View File
@@ -0,0 +1,70 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { OPERATIONS, operation } from '../api/operations.js'
import { ExtensionNotInstalledError, ExtensionRegistry, KNOWN_EXTENSIONS, compareVersions, extensionOfOperation, installedExtensions, knownExtension, operationsOf } from '../extensions/registry.js'
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
test('every extension operation in the index names its extension; core operations name none', () => {
assert.equal(operation('getEsbOverview')?.extension, 'esb')
assert.equal(operation('getQbitCollaborationPresence')?.extension, 'collaboration')
assert.equal(operation('queryTable')?.extension, undefined)
for (const e of KNOWN_EXTENSIONS) assert.ok(operationsOf(e.name).length > 0, `${e.name} has operations`)
const names = new Set(KNOWN_EXTENSIONS.map((e) => e.name))
for (const o of OPERATIONS) if (o.extension) assert.ok(names.has(o.extension), `${o.id} -> ${o.extension}`)
for (const name of ['esb', 'health', 'alerts', 'assistant', 'collaboration', 'data-analysis', 'sql-workbench', 'secured-data', 'edi', 'quick-search', 'qa-capture']) assert.ok(knownExtension(name), name)
assert.equal(knownExtension('collaboration')?.pathPrefix, '/qbit/collaboration')
assert.deepEqual(knownExtension('collaboration')?.channels, ['record:{table}:{pk}', 'table:{table}', 'user:me'])
assert.equal(extensionOfOperation('getQbitEdiGuidesByGuideIdTree') ?? extensionOfOperation(operationsOf('edi')[0]!.id), 'edi')
})
test('the registry knows what is installed, keeps unknown extensions, and refuses absent ones with the installed list', () => {
const registry = ExtensionRegistry.fromVersion({ apiVersion: '1.8.0', extensions: [{ name: 'esb', version: '1.1.0' }, { name: 'zebra', version: '0.3.0' }] })
assert.equal(registry.has('esb'), true)
assert.equal(registry.has('collaboration'), false)
assert.equal(registry.version('esb'), '1.1.0')
assert.deepEqual(registry.unknown().map((e) => e.name), ['zebra'])
assert.equal(registry.describe(), 'esb 1.1.0, zebra 0.3.0 (unknown to this client)')
assert.throws(() => registry.require('collaboration'), (e: unknown) => e instanceof ExtensionNotInstalledError && e.message === 'extension collaboration is not installed on this server (installed: esb 1.1.0, zebra 0.3.0)' && e.reason === 'absent')
assert.doesNotThrow(() => registry.requireOperation('queryTable'))
assert.doesNotThrow(() => registry.requireOperation('getEsbOverview'))
assert.throws(() => registry.requireOperation('getQbitCollaborationMyWork'), /getQbitCollaborationMyWork belongs to extension collaboration, which is not installed on this server \(installed: esb 1\.1\.0/)
assert.throws(() => new ExtensionRegistry([]).require('esb'), /\(installed: none\)/)
})
test('an installed extension below the documented minimum is not usable', () => {
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.0.3' }] })
assert.equal(registry.has('collaboration'), false)
assert.throws(() => registry.require('collaboration'), (e: unknown) => e instanceof ExtensionNotInstalledError && e.reason === 'too-old' && /1\.1\.0 or later; this server has 1\.0\.3/.test(e.message))
assert.ok(compareVersions('1.10.0', '1.9.9') > 0)
assert.equal(compareVersions('1.1.0-RC.2', '1.1.0'), 0)
})
test('installedExtensions reads GET /version once and serves it from the cache', async () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-ext-'))
let calls = 0
const fetchImpl: typeof fetch = async () => {
calls++
return json({ apiVersion: '1.8.0', extensions: [{ name: 'esb', version: '1.1.0', pathPrefix: '/esb' }] })
}
try {
const first = await installedExtensions('http://h', fetchImpl, { dir })
assert.deepEqual(first, [{ name: 'esb', version: '1.1.0', pathPrefix: '/esb', description: undefined, known: true, meetsMinimum: true }])
await installedExtensions('http://h', fetchImpl, { dir })
assert.equal(calls, 1)
await installedExtensions('http://h', fetchImpl, { dir, refresh: true })
assert.equal(calls, 2)
const offline = await installedExtensions('http://h', async () => { throw new Error('down') }, { dir, ttlSeconds: 0 })
assert.equal(offline[0]?.name, 'esb')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
+50
View File
@@ -0,0 +1,50 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { UnsafeFileNameError, alternativeNames, safeFileName, saveDownload } from '../render/files.js'
test('server file names keep only their final component', () => {
assert.equal(safeFileName('../../etc/passwd'), 'passwd')
assert.equal(safeFileName('C:\\x\\y.txt'), 'y.txt')
assert.equal(safeFileName('..\\..\\Windows\\win.ini'), 'win.ini')
assert.equal(safeFileName('/abs/path/report q3.pdf'), 'report q3.pdf')
assert.equal(safeFileName('plain.csv'), 'plain.csv')
})
test('empty, dot, dot-dot and control-character names are refused', () => {
for (const bad of ['', ' ', '.', '..', 'a/..', 'dir/', 'evil\u0007.txt', 'line\nbreak', null, undefined]) {
assert.throws(() => safeFileName(bad), UnsafeFileNameError, String(bad))
}
})
test('a default name never overwrites: the next free name-N.ext is taken; a named path is overwritten', () => {
const dir = mkdtempSync(join(tmpdir(), 'qqq-files-'))
try {
const path = join(dir, 'a.txt')
writeFileSync(path, 'old')
assert.equal(saveDownload(path, new TextEncoder().encode('new'), { explicit: false }), join(dir, 'a-1.txt'))
assert.equal(saveDownload(path, new TextEncoder().encode('newer'), { explicit: false }), join(dir, 'a-2.txt'))
assert.equal(readFileSync(path, 'utf8'), 'old')
assert.equal(readFileSync(join(dir, 'a-1.txt'), 'utf8'), 'new')
assert.equal(saveDownload(path, new TextEncoder().encode('replaced'), { explicit: true }), path)
assert.equal(readFileSync(path, 'utf8'), 'replaced')
assert.equal(saveDownload(join(dir, 'b'), new TextEncoder().encode('b'), { explicit: false }), join(dir, 'b'))
assert.equal(saveDownload(join(dir, 'b'), new TextEncoder().encode('b'), { explicit: false }), join(dir, 'b-1'))
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('alternative names keep the extension', () => {
const names = alternativeNames('report.q3.csv')
assert.deepEqual([names.next().value, names.next().value, names.next().value], ['report.q3.csv', 'report.q3-1.csv', 'report.q3-2.csv'])
const bare = alternativeNames('.env')
assert.deepEqual([bare.next().value, bare.next().value], ['.env', '.env-1'])
})
@@ -0,0 +1,94 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { JoinError, withJoinFields, associationJoin, defaultJoinColumns, exposedJoins, fieldLabel, filterFieldNames, joinFieldNames, planJoins, resolveField } from '../query/joins.js'
import { VariantError, listTableVariants, resolveVariant, variantBody, variantParam } from '../records/variants.js'
const order = {
name: 'order', label: 'Order', primaryKeyField: 'id',
fields: { id: { name: 'id', label: 'Id' }, total: { name: 'total', label: 'Total' } },
exposedJoins: [
{ label: 'Customer', isMany: false, joinTable: { name: 'customer', label: 'Customer', primaryKeyField: 'id', fields: { id: { name: 'id' }, name: { name: 'name', label: 'Name' }, secret: { name: 'secret', isHidden: true } }, sections: [{ tier: 'T1', fieldNames: ['name'] }] }, joinPath: [{ name: 'orderJoinCustomer', leftTable: 'order', rightTable: 'customer' }] },
{ label: 'Lines', isMany: true, joinTable: { name: 'orderLine', label: 'Order Line', fields: { sku: { name: 'sku', label: 'SKU' } } }, joinPath: [{ name: 'a', leftTable: 'order', rightTable: 'x' }, { name: 'b', leftTable: 'x', rightTable: 'orderLine' }] },
{ label: 'Hidden', joinTable: { name: 'audit', readPermission: false, fields: {} } },
],
}
test('exposed joins: readable ones, with join names for one-hop paths; path tables must be readable', () => {
const joins = exposedJoins(order)
assert.deepEqual(joins.map((j) => [j.table, j.label, j.isMany, j.joinName]), [['customer', 'Customer', false, 'orderJoinCustomer'], ['orderLine', 'Lines', true, undefined]])
assert.deepEqual(exposedJoins(order, { customer: { readPermission: true }, orderLine: { readPermission: true } }).map((j) => j.table), ['customer'])
assert.deepEqual(joinFieldNames(joins), ['customer.id', 'customer.name', 'orderLine.sku'])
assert.equal(resolveField(order, joins, 'customer.name')!.join!.table, 'customer')
assert.equal(resolveField(order, joins, 'customer.nope'), undefined)
assert.equal(fieldLabel(order, joins, 'orderLine.sku'), 'Lines: SKU')
assert.equal(fieldLabel(order, joins, 'total'), 'Total')
assert.deepEqual(defaultJoinColumns(joins[0]!), ['customer.name'])
assert.equal(((withJoinFields(order, joins).fields as Record<string, { name: string }>)['orderLine.sku'])!.name, 'orderLine.sku')
})
test('planJoins joins named and referenced joins, selects shown ones, and asks for distinct counts on many-side joins', () => {
const joins = exposedJoins(order)
const filter = { criteria: [{ fieldName: 'orderLine.sku', operator: 'EQUALS' as const, values: ['A'] }], booleanOperator: 'AND' as const, orderBys: [{ fieldName: 'total', isAscending: true }] }
assert.deepEqual(filterFieldNames({ ...filter, subFilters: [{ criteria: [{ fieldName: 'x', operator: 'IS_BLANK', values: [] }], booleanOperator: 'AND' }] }), ['orderLine.sku', 'total', 'x'])
const plan = planJoins(joins, { named: ['Customer'], columns: ['id', 'customer.name'], filter })
assert.deepEqual(plan.joins, [{ joinTable: 'customer', select: true, type: 'LEFT', joinName: 'orderJoinCustomer' }, { joinTable: 'orderLine', select: false, type: 'LEFT' }])
assert.equal(plan.includeDistinct, true)
assert.deepEqual(planJoins(joins, { named: ['customer'] }).joins[0]!.select, false)
assert.deepEqual(planJoins(joins, { named: ['customer'], select: true }).joins[0]!.select, true)
assert.throws(() => planJoins(joins, { named: ['zz'] }), JoinError)
assert.throws(() => planJoins(joins, { columns: ['zz.a'] }), /names no exposed join/)
const assoc = associationJoin({ name: 'pets', associatedTableName: 'pet', join: { name: 'personJoinPet', type: 'ONE_TO_MANY' } }, { name: 'pet', label: 'Pet', fields: {} })!
assert.deepEqual([assoc.table, assoc.isMany, assoc.joinName], ['pet', true, 'personJoinPet'])
})
test('variants: listed from the v1 route (wrapped or bare), resolved by id, name or type:id, sent as body or JSON param', async () => {
const fetchImpl: typeof fetch = async (input) => {
assert.match(String(input), /\/qqq\/v1\/table\/store\/variants$/)
return new Response(JSON.stringify({ variants: [{ id: 1, type: 'store', name: 'North' }, { id: 2, type: 'store', name: 'Northwest' }, { bad: true }] }), { status: 200, headers: { 'content-type': 'application/json' } })
}
const variants = await listTableVariants('http://h', fetchImpl, 'store')
assert.deepEqual(variants, [{ id: '1', type: 'store', name: 'North' }, { id: '2', type: 'store', name: 'Northwest' }])
assert.equal(resolveVariant(variants, '2').name, 'Northwest')
assert.equal(resolveVariant(variants, 'north').id, '1')
assert.equal(resolveVariant(variants, 'west').id, '2')
assert.deepEqual(resolveVariant(variants, 'store:9'), { type: 'store', id: '9' })
assert.throws(() => resolveVariant(variants, 'nor'), (e: Error) => e instanceof VariantError && /matches 2 variants/.test(e.message))
assert.throws(() => resolveVariant([], 'x'), /give type:id/)
assert.equal(variantParam({ id: '1', type: 'store', name: 'North' }), '{"type":"store","id":"1"}')
assert.deepEqual(variantBody(undefined), {})
assert.deepEqual(variantBody({ id: '1', type: 'store' }), { tableVariant: { type: 'store', id: '1' } })
})
test('column statistics: availability needs QUERY_STATS and the process; local computation matches the process shape', async () => {
const { columnStatsAvailability, computeColumnStats, runColumnStats, statsFilter } = await import('../query/column-stats.js')
assert.match(columnStatsAvailability({ label: 'T', capabilities: ['TABLE_QUERY'] }, { processes: { columnStats: {} } }).reason!, /QUERY_STATS/)
assert.match(columnStatsAvailability({ capabilities: ['QUERY_STATS'] }, { processes: {} }).reason!, /no columnStats process/)
assert.match(columnStatsAvailability({ capabilities: ['QUERY_STATS'] }, { processes: { columnStats: { hasPermission: false } } }).reason!, /may not run/)
assert.deepEqual(columnStatsAvailability({ capabilities: ['QUERY_STATS'] }, { processes: { columnStats: {} } }), { available: true })
assert.deepEqual(statsFilter({ criteria: [], booleanOperator: 'AND', skip: 5, limit: 2, orderBys: [] }), { criteria: [], booleanOperator: 'AND' })
const records = [{ values: { n: 3 } }, { values: { n: 1 } }, { values: { n: 3 } }, { values: { n: null } }]
const stats = computeColumnStats(records, 'n', { type: 'INTEGER' })
assert.deepEqual(stats.stats.values, { count: 3, countDistinct: 2, sum: 7, average: 2.333333, min: 1, max: 3 })
assert.deepEqual(stats.valueCounts.map((r) => [r.values.n, r.values.count]), [[3, 2], [null, 1], [1, 1]])
assert.deepEqual(computeColumnStats(records, 'n', { type: 'INTEGER' }, 'n.asc').valueCounts.map((r) => r.values.n), [null, 1, 3])
assert.ok(!('sum' in computeColumnStats(records, 'n', { type: 'INTEGER', possibleValueSourceName: 'p' }).stats.values))
const hours = computeColumnStats([{ values: { t: '2026-01-01T10:15:00Z' } }, { values: { t: '2026-01-01T10:45:00Z' } }], 't', { type: 'DATE_TIME' })
assert.deepEqual(hours.valueCounts[0]!.values, { t: '2026-01-01T10:00:00Z', count: 2 })
let sent: FormData | undefined
let url = ''
const fetchImpl: typeof fetch = async (input, init) => {
url = String(input)
sent = init?.body as FormData
return new Response(JSON.stringify({ type: 'COMPLETE', values: { statsFields: [{ name: 'count', label: 'Count' }], statsRecord: { values: { count: 4 } }, valueCounts: [{ values: { n: 3, count: 2 }, displayValues: { n: 'three' } }] } }), { status: 200, headers: { 'content-type': 'application/json' } })
}
const remote = await runColumnStats('http://h', fetchImpl, { table: 't', field: 'n', filter: { criteria: [], booleanOperator: 'AND', limit: 9 }, tableVariant: { type: 's', id: '1' } })
assert.match(url, /\/qqq\/v1\/processes\/columnStats\/init\?tableVariant=/)
assert.deepEqual(JSON.parse(String(sent!.get('values'))), { tableName: 't', fieldName: 'n', filterJSON: '{"criteria":[],"booleanOperator":"AND"}', orderBy: 'count.desc' })
assert.deepEqual(remote.fields, [{ name: 'count', label: 'Count' }])
assert.equal(remote.valueCounts[0]!.displayValues.n, 'three')
})
@@ -0,0 +1,99 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { MASK, fileDownload, recordView, relatedRecordTarget, selectHelpContent, helpEntryText, tableViewHelp, valueText } from '../records/record-view.js'
import type { RecordLike, TableMetaLike } from '../records/associations.js'
import type { FieldMeta } from '../forms/fields.js'
const field = (name: string, extra: Partial<FieldMeta> = {}): FieldMeta => ({ name, label: name, type: 'STRING', ...extra })
const rec = (values: Record<string, unknown>, displayValues: Record<string, unknown> = {}): RecordLike => ({ values, displayValues })
test('LINK: a record link names the target table and key; a URL link shows the URL', () => {
const personId = field('personId', { type: 'INTEGER', adornments: [{ type: 'LINK', values: { toRecordFromTable: 'person' } }] })
assert.deepEqual(valueText(personId, rec({ personId: 1 }, { personId: 'Avery Sample' })), { text: 'Avery Sample → person 1', link: { tableName: 'person', primaryKey: '1' }, adornment: 'LINK' })
const site = field('site', { adornments: [{ type: 'LINK', values: {} }] })
assert.equal(valueText(site, rec({ site: 'https://qrun.io' })).text, 'https://qrun.io')
const dyn = field('ref', { adornments: [{ type: 'LINK', values: { toRecordFromTableDynamic: true } }] })
assert.deepEqual(valueText(dyn, rec({ ref: 5 }, { ref: 'Five', 'ref:toRecordFromTableDynamic': 'order' })).link, { tableName: 'order', primaryKey: '5' })
})
test('possible-value fields from a table link to the related record (only for known tables)', () => {
const speciesId = field('speciesId', { type: 'INTEGER', possibleValueSourceName: 'petSpecies' })
assert.equal(valueText(speciesId, rec({ speciesId: 1 }, { speciesId: 'Dog' }), { tableNames: ['petSpecies'] }).text, 'Dog → petSpecies 1')
assert.equal(valueText(speciesId, rec({ speciesId: 1 }, { speciesId: 'Dog' }), { tableNames: ['person'] }).text, 'Dog')
assert.deepEqual(relatedRecordTarget(speciesId, rec({ speciesId: 2 })), { tableName: 'petSpecies', primaryKey: '2' })
assert.equal(relatedRecordTarget(speciesId, rec({ speciesId: null })), undefined)
})
test('CHIP brackets, REVEAL masks unless revealed, ERROR marks, CODE_EDITOR is raw, RENDER_HTML is text', () => {
assert.equal(valueText(field('s', { adornments: [{ type: 'CHIP', values: { 'color.OPEN': 'success' } }] }), rec({ s: 'OPEN' }, { s: 'Open' })).text, '[Open]')
const secret = field('apiKey', { adornments: [{ type: 'REVEAL' }] })
assert.deepEqual(valueText(secret, rec({ apiKey: 'abc123' })), { text: MASK, adornment: 'REVEAL', masked: true })
assert.equal(valueText(secret, rec({ apiKey: 'abc123' }), { reveal: true }).text, 'abc123')
assert.equal(valueText(secret, rec({ apiKey: null })).text, '', 'nothing to mask')
assert.deepEqual(valueText(field('e', { adornments: [{ type: 'ERROR' }] }), rec({ e: 'bad row' })), { text: '! bad row', adornment: 'ERROR', error: true })
assert.equal(valueText(field('c', { adornments: [{ type: 'CODE_EDITOR', values: { languageMode: 'json' } }] }), rec({ c: '{"a":1}' }, { c: 'x' })).text, '{"a":1}')
assert.equal(valueText(field('h', { adornments: [{ type: 'RENDER_HTML' }] }), rec({ h: '<p>Hello <b>there</b></p><p>Two</p>' })).text, 'Hello there\nTwo')
})
test('TOOLTIP text follows the value; dynamic text wins', () => {
const f = field('t', { adornments: [{ type: 'TOOLTIP', values: { staticText: 'static tip', tooltipDynamic: true } }] })
assert.equal(valueText(f, rec({ t: 'v' })).text, 'v (static tip)')
assert.equal(valueText(f, rec({ t: 'v' }, { 't:tooltipDynamic': 'per record' })).text, 'v (per record)')
})
test('FILE_DOWNLOAD: the backend route becomes a download command; outside URLs stay URLs', () => {
const f = field('file', { type: 'BLOB', adornments: [{ type: 'FILE_DOWNLOAD' }] })
const r = rec({ file: '/data/doc/7/file/report%20q3.pdf' }, { file: 'report q3.pdf' })
assert.deepEqual(fileDownload(f, r), { fileName: 'report q3.pdf', route: { tableName: 'doc', primaryKey: '7', fieldName: 'file', filename: 'report q3.pdf' } })
assert.equal(valueText(f, r, { tableName: 'doc', primaryKey: '7' }).text, 'report q3.pdf (qqq table download doc 7 file)')
assert.deepEqual(fileDownload(f, rec({ file: 'https://cdn.example/a.png' })), { fileName: 'a.png', url: 'https://cdn.example/a.png' })
assert.equal(fileDownload(f, rec({ file: '//evil/a' })), undefined)
})
test('a BLOB without an adornment shows its size and the download command', () => {
assert.equal(valueText(field('b', { type: 'BLOB' }), rec({ b: 'aGVsbG8=' }), { tableName: 'fieldLab', primaryKey: '1' }).text, '<file, 5 bytes> (qqq table download fieldLab 1 b)')
})
test('WIDGET renders the value as widget text; a missing widget is an error', () => {
const f = field('w', { adornments: [{ type: 'WIDGET', values: { widgetName: 'stats' } }] })
const value = { type: 'statistics', label: 'Totals', count: 3 }
assert.ok(valueText(f, rec({ w: value }), { widgets: { stats: {} } }).text.length > 0)
assert.deepEqual(valueText(f, rec({ w: value }), { widgets: {} }), { text: 'Error: Could not load widget [stats]', adornment: 'WIDGET', error: true })
})
test('record view: hidden sections and hidden fields are never shown; unplaced fields come last', () => {
const table: TableMetaLike = {
name: 't', primaryKeyField: 'id',
fields: { id: field('id'), a: field('a'), b: field('b', { isHidden: true }), c: field('c'), d: field('d'), e: field('e') },
sections: [{ name: 's1', label: 'One', tier: 'T1', fieldNames: ['id', 'a', 'b'] }, { name: 's2', label: 'Secret', fieldNames: ['c'], isHidden: true }, { name: 'w', label: 'Lines', widgetName: 'lines' }],
}
const view = recordView(table, rec({ id: 1, a: 'A', b: 'B', c: 'C', d: 'D', e: null }))
assert.deepEqual(view.map((s) => [s.label, s.fields.map((x) => x.field.name), s.widgetName]), [['One', ['id', 'a'], undefined], ['Lines', [], 'lines'], ['Other Fields', ['d', 'e'], undefined]])
assert.equal(recordView(table, rec({ e: null }), { empty: '--' }).at(-1)!.fields.at(-1)!.value.text, '--', 'empty values get the placeholder')
})
test('help content: view roles first, then entries without roles; HTML and MARKDOWN as text', () => {
const entries = [{ content: 'edit help', roles: ['EDIT_SCREEN'] }, { content: 'general' }, { content: '<p>view <b>help</b></p>', format: 'HTML', roles: ['READ_SCREENS'] }]
assert.equal(helpEntryText(selectHelpContent(entries)), 'view help')
assert.equal(helpEntryText(selectHelpContent([{ content: '# x', format: 'MARKDOWN', contentAsHtml: '<h1>x</h1>', links: [{ label: 'Docs', url: 'https://d' }] }])), 'x (Docs <https://d>)')
assert.equal(selectHelpContent([{ content: 'e', roles: ['EDIT_SCREEN'] }]), undefined)
const table: TableMetaLike = { name: 't', fields: { a: field('a', { helpContents: [{ content: 'field help' }] }) }, sections: [{ name: 's', label: 'S', fieldNames: ['a'], helpContents: [{ content: 'section help' }] }], helpContents: { header: [{ content: 'table help' }] } }
const view = recordView(table, rec({ a: 1 }), { helpContent: true })
assert.equal(view[0]!.help, 'section help')
assert.equal(view[0]!.fields[0]!.help, 'field help')
assert.deepEqual(tableViewHelp(table), [{ slot: 'header', text: 'table help' }])
assert.equal(recordView(table, rec({ a: 1 }))[0]!.help, undefined, 'help only when asked')
})
test('DATE_TIME values show in the viewer\'s zone, or as the UTC instant with utc', () => {
const f = field('when', { type: 'DATE_TIME' })
const r = rec({ when: '2026-10-31T22:33:01Z' }, { when: '2026-10-31T22:33:01Z' })
assert.match(valueText(f, r, { dateTime: { timeZone: 'Asia/Tokyo' } }).text, /^2026-11-01 07:33:01 /)
assert.equal(valueText(f, r, { dateTime: { utc: true } }).text, '2026-10-31T22:33:01Z')
assert.equal(valueText(f, rec({ when: null }), { empty: '--' }).text, '--')
})
+1 -9
View File
@@ -6,7 +6,7 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { BACK, ProcessCancelledError, ProcessError, checkInputRecords, hasInputs, inputFields, runProcess, type ProcessMeta, type ProcessStep } from '../process/runner.js'
import { alternativeNames, buildScreen, downloadReference, safeDownloadName, screenToText, summaryLines, type ScreenBlock } from '../process/screen.js'
import { buildScreen, downloadReference, screenToText, summaryLines, type ScreenBlock } from '../process/screen.js'
type Handler = (request: Request, url: URL) => Promise<unknown> | unknown
@@ -239,11 +239,3 @@ test('screens: help content, HELP_TEXT preview, WIDGET named and ad hoc, Google
assert.deepEqual(summaryLines([{ status: 'OK', tableName: 'person', recordId: 4, linkPreText: 'Created ', linkText: 'Avery' }]), [{ status: 'OK', count: undefined, message: 'Created Avery', bullets: undefined, link: 'person 4' }])
})
test('download names from the server are reduced to a safe base name', () => {
assert.equal(safeDownloadName('../../etc/passwd'), 'passwd')
assert.equal(safeDownloadName('..\\..\\Windows\\win.ini'), 'win.ini')
assert.equal(safeDownloadName('report\u0007.csv'), 'report.csv')
for (const bad of ['', '.', '..', '../', 'a/..', '\u0001']) assert.throws(() => safeDownloadName(bad), /unsafe file name/)
const names = alternativeNames('out.csv')
assert.deepEqual([names.next().value, names.next().value, names.next().value], ['out.csv', 'out-1.csv', 'out-2.csv'])
})
@@ -0,0 +1,73 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { SavedViewError, buildViewJson, deleteSavedView, findSavedView, listSavedViews, parseViewJson, savedViewProcesses, storeSavedView, viewToQuery } from '../query/saved-views.js'
const table = {
name: 'person', label: 'Person', primaryKeyField: 'id',
fields: { id: { name: 'id' }, firstName: { name: 'firstName', label: 'First Name' }, isEmployed: { name: 'isEmployed', label: 'Employed', type: 'BOOLEAN' } },
clientCapabilities: { savedViews: { available: true, queryProcessName: 'qSV', storeProcessName: 'sSV', deleteProcessName: 'dSV' } },
}
test('saved-view processes come from clientCapabilities, else the legacy names when permitted', () => {
assert.deepEqual(savedViewProcesses(table, {}), { query: 'qSV', store: 'sSV', delete: 'dSV' })
assert.deepEqual(savedViewProcesses({ clientCapabilities: { savedViews: { available: false } } }, { processes: { querySavedView: {} } }), {})
assert.deepEqual(savedViewProcesses({}, { processes: { querySavedView: {}, storeSavedView: { hasPermission: false } } }), { query: 'querySavedView' })
})
test('views list, store and delete through the processes; viewJson round-trips', async () => {
const calls: Array<{ url: string; values: Record<string, unknown> }> = []
const fetchImpl: typeof fetch = async (input, init) => {
const values = JSON.parse(String((init!.body as FormData).get('values')))
calls.push({ url: String(input), values })
const row = (id: number, label: string, userId: string) => ({ values: { id, label, userId, tableName: 'person', viewJson: JSON.stringify({ queryFilter: { criteria: [{ fieldName: 'firstName', operator: 'EQUALS', values: ['A'] }] }, rowsPerPage: 50 }) } })
const list = String(input).includes('/dSV/') ? [] : String(input).includes('/sSV/') ? [row(9, String(values.label), 'me')] : [row(2, 'zeta', 'me'), row(1, 'Alpha', 'other')]
return new Response(JSON.stringify({ type: 'COMPLETE', values: { savedViewList: list } }), { status: 200, headers: { 'content-type': 'application/json' } })
}
const processes = savedViewProcesses(table, {})
const views = await listSavedViews('http://h', fetchImpl, processes, 'person')
assert.deepEqual(views.map((v) => v.label), ['Alpha', 'zeta'])
assert.equal(views[0]!.view.rowsPerPage, 50)
assert.match(calls[0]!.url, /\/qqq\/v1\/processes\/qSV\/init$/)
assert.deepEqual(calls[0]!.values, { tableName: 'person' })
const stored = await storeSavedView('http://h', fetchImpl, processes, { table: 'person', label: 'New', view: { queryFilter: {} }, id: 2 })
assert.equal(stored.id, 9)
assert.deepEqual(calls[1]!.values, { tableName: 'person', label: 'New', viewJson: '{"queryFilter":{}}', id: 2 })
await deleteSavedView('http://h', fetchImpl, processes, 9)
assert.deepEqual(calls[2]!.values, { id: 9 })
await assert.rejects(listSavedViews('http://h', fetchImpl, {}, 'person'), SavedViewError)
const failing: typeof fetch = async () => new Response(JSON.stringify({ type: 'ERROR', userFacingError: 'A view with this name already exists' }), { status: 200, headers: { 'content-type': 'application/json' } })
await assert.rejects(storeSavedView('http://h', failing, processes, { table: 'person', label: 'x', view: { queryFilter: {} } }), /already exists/)
})
test('find by id or name; duplicates and unknown views are reported', () => {
const views = [{ id: 1, label: 'Alpha', tableName: 't', view: { queryFilter: {} } }, { id: 2, label: 'dup', tableName: 't', view: { queryFilter: {} } }, { id: 3, label: 'Dup', tableName: 't', view: { queryFilter: {} } }]
assert.equal(findSavedView(views, '2').label, 'dup')
assert.equal(findSavedView(views, 'alpha').id, 1)
assert.equal(findSavedView(views, 'Dup').id, 3)
assert.throws(() => findSavedView(views, 'DUP'), /2 views are named/)
assert.throws(() => findSavedView(views, 'nope'), /No saved view "nope"/)
})
test('a view document is built as the web UIs store it, and reconciled when opened', () => {
const view = buildViewJson({ filter: { criteria: [{ fieldName: 'firstName', operator: 'EQUALS', values: ['A'] }], booleanOperator: 'AND', orderBys: [{ fieldName: 'id', isAscending: false }], limit: 5 }, columns: ['id', 'firstName'], allColumns: ['id', 'firstName', 'isEmployed'], rowsPerPage: 50 })
assert.deepEqual(view.queryColumns!.columns.map((c) => [c.name, c.isVisible]), [['__check__', true], ['id', true], ['firstName', true], ['isEmployed', false]])
assert.deepEqual(view.queryFilter.orderBys, [{ fieldName: 'id', isAscending: false }])
assert.equal((view.queryFilter as { limit?: number }).limit, undefined)
assert.deepEqual(parseViewJson(JSON.stringify(view)), view)
assert.deepEqual(parseViewJson('not json'), { queryFilter: {}, quickFilterFieldNames: [] })
const opened = viewToQuery(table, [], {
queryFilter: { criteria: [{ fieldName: 'gone', operator: 'EQUALS', values: ['x'] }, { fieldName: 'isEmployed', operator: 'GREATER_THAN', values: ['1'] }, { fieldName: 'firstName', operator: 'EQUALS', values: ['A'] }], booleanOperator: 'AND', orderBys: [{ fieldName: 'gone', isAscending: true }] },
queryColumns: { columns: [{ name: '__check__', isVisible: true }, { name: 'firstName', isVisible: true }, { name: 'old', isVisible: true }, { name: 'id', isVisible: false }] },
rowsPerPage: 100,
})
assert.deepEqual(opened.filter, { criteria: [{ fieldName: 'firstName', operator: 'EQUALS', values: ['A'] }], booleanOperator: 'AND', orderBys: [{ fieldName: 'id', isAscending: false }] })
assert.deepEqual(opened.columns, ['firstName'])
assert.equal(opened.rowsPerPage, 100)
assert.match(opened.warnings[0]!, /^2 fields that were part of this view are no longer in this table, and were removed from this view \(gone, old\)\.$/)
assert.match(opened.warnings[1]!, /Employed has an unsupported operator: GREATER_THAN/)
})
@@ -0,0 +1,96 @@
{
"core": "http://localhost:8077/qqq/v1/openapi.json",
"extensions": [
{
"name": "esb",
"version": "1.1.0",
"document": ".worktrees/qqq-develop/qqq-esb/src/main/resources/openapi/qqq-v1/esb.yaml",
"branch": "HEAD",
"commit": "6ac64fc66"
},
{
"name": "health",
"version": "1.1.0",
"document": ".worktrees/qqq-develop/qqq-middleware-javalin/src/main/resources/openapi/qqq-v1/health.yaml",
"branch": "HEAD",
"commit": "6ac64fc66"
},
{
"name": "alerts",
"version": "1.2.0",
"document": "qbit-alerts/src/main/resources/openapi/qqq-v1/alerts.yaml",
"branch": "develop",
"commit": "a3fc6ac"
},
{
"name": "assistant",
"version": "1.6.0",
"document": "qbit-assistant/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml",
"branch": "develop",
"commit": "bcf173f"
},
{
"name": "collaboration",
"version": "1.1.0",
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "develop",
"commit": "03407ae"
},
{
"name": "data-analysis",
"version": "1.5.0",
"document": "qbit-data-analysis/src/main/resources/openapi/qqq-v1/data-analysis.yaml",
"branch": "develop",
"commit": "368d2c0"
},
{
"name": "db-performance",
"version": "1.0.0",
"document": "qbit-db-performance/src/main/resources/openapi/qqq-v1/db-performance.yaml",
"branch": "develop",
"commit": "4fd2c30"
},
{
"name": "edi",
"version": "1.1.0",
"document": "qbit-edi/src/main/resources/openapi/qqq-v1/edi.yaml",
"branch": "develop",
"commit": "75a4e6b"
},
{
"name": "qa-capture",
"version": "1.0.0",
"document": "qbit-qa-capture/src/main/resources/openapi/qqq-v1/qa-capture.yaml",
"branch": "develop",
"commit": "1aee6cb"
},
{
"name": "quick-search",
"version": "1.1.0",
"document": "qbit-quick-search/src/main/resources/openapi/qqq-v1/quick-search.yaml",
"branch": "develop",
"commit": "bd50e1f"
},
{
"name": "release-notes",
"version": "1.0.0",
"document": "qbit-release-notes/src/main/resources/openapi/qqq-v1/release-notes.yaml",
"branch": "develop",
"commit": "8fed18e"
},
{
"name": "secured-data",
"version": "1.1.0",
"document": "qbit-secured-data/src/main/resources/openapi/qqq-v1/secured-data.yaml",
"branch": "develop",
"commit": "7b302ed"
},
{
"name": "sql-workbench",
"version": "1.1.0",
"document": "qbit-sql-workbench/src/main/resources/openapi/qqq-v1/sql-workbench.yaml",
"branch": "develop",
"commit": "a182076"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+30 -1
View File
@@ -16,6 +16,19 @@ import { dirname } from 'node:path'
const [specPath = 'packages/ui-api-client/openapi/qqq-v1.json', outPath = 'packages/core/src/api/operations.ts'] = process.argv.slice(2)
const spec = JSON.parse(readFileSync(specPath, 'utf8'))
const extensions = spec.info['x-qqq-extensions'] ?? []
const extensionByOperation = new Map()
for (const e of extensions) for (const id of e.operations ?? []) extensionByOperation.set(id, e.name)
const ESTABLISHED_PREFIXES = { esb: 'esb', alerts: 'alerts', assistant: 'assistant', 'sql-workbench': 'sql-workbench', health: 'health' }
/** The extension an operation belongs to: x-qqq-extensions, else its /qbit/{name} or established path prefix. */
function extensionOf(id, path) {
if (extensionByOperation.has(id)) return extensionByOperation.get(id)
const rest = path.replace(/^\/qqq\/v1/, '').split('/')
if (rest[1] === 'qbit' && rest[2]) return rest[2]
return ESTABLISHED_PREFIXES[rest[1]]
}
const operations = []
for (const [path, item] of Object.entries(spec.paths)) {
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
@@ -31,6 +44,7 @@ for (const [path, item] of Object.entries(spec.paths)) {
pathParams: params.filter((p) => p.in === 'path').map((p) => p.name),
queryParams: params.filter((p) => p.in === 'query').map((p) => p.name),
bodyTypes: Object.keys(op.requestBody?.content ?? {}),
...(extensionOf(op.operationId, path) ? { extension: extensionOf(op.operationId, path) } : {}),
})
}
}
@@ -41,7 +55,7 @@ const lines = [
' * Licensed under the Apache License, Version 2.0 (see LICENSE).',
' */',
'',
`// Generated by scripts/gen-operations.mjs from the UI API ${spec.info.version} document. Do not edit.`,
`// Generated by scripts/gen-operations.mjs from the UI API ${spec.info.version} document (core and ${extensions.length} extensions). Do not edit.`,
'',
'export interface OperationInfo {',
' id: string',
@@ -52,10 +66,25 @@ const lines = [
' pathParams: string[]',
' queryParams: string[]',
' bodyTypes: string[]',
' /** The extension (qbit or module) that serves this operation; absent for core operations. */',
' extension?: string',
'}',
'',
'/** An extension document merged into the client document (info.x-qqq-extensions). */',
'export interface DocumentExtension {',
' name: string',
' version: string',
' pathPrefix?: string',
' description?: string',
'}',
'',
`export const UI_API_DOCUMENT_VERSION = ${JSON.stringify(spec.info.version)}`,
'',
'/** The extensions merged into the client document, with the versions it was generated from. */',
'export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [',
...extensions.map((e) => ` ${JSON.stringify({ name: e.name, version: e.version, pathPrefix: e.pathPrefix, description: (e.description ?? '').split('\n')[0].slice(0, 160) })},`),
']',
'',
'export const OPERATIONS: OperationInfo[] = [',
...operations.map((o) => ` ${JSON.stringify(o)},`),
']',
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Builds the client document from a core document plus every extension document found in the
* sibling checkouts of the QRun-IO workspace, and records where each came from.
*
* Usage: node scripts/merge-extensions.mjs [--core <url|file>] [--root <QRun-IO dir>] [--out <file>]
*
* --core the core document; default the sample server's http://localhost:8077/qqq/v1/openapi.json
* (a server's document is already merged with the extensions that server has; those are
* checked for equality and recorded, not duplicated)
* --root the directory holding the qbit checkouts (default: QRUN_IO_ROOT, else the nearest
* ancestor of this repository that contains qbit-* directories)
* --out default packages/ui-api-client/openapi/qqq-v1.json
*
* Writes packages/ui-api-client/openapi/extension-sources.json: each extension's name, version,
* document path and the git branch and commit it was read at.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from 'yaml'
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2)
const option = (name, fallback) => {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : fallback
}
const core = option('--core', 'http://localhost:8077/qqq/v1/openapi.json')
const out = option('--out', join(repo, 'packages/ui-api-client/openapi/qqq-v1.json'))
function findRoot() {
if (process.env.QRUN_IO_ROOT) return process.env.QRUN_IO_ROOT
for (let dir = dirname(repo); dir !== dirname(dir); dir = dirname(dir)) {
if (readdirSync(dir).some((d) => d.startsWith('qbit-'))) return dir
}
throw new Error('No qbit-* checkouts found above this repository; pass --root or set QRUN_IO_ROOT.')
}
const root = option('--root', findRoot())
/** Where each repository keeps its extension documents (a qbit's own repo, or qqq for esb and health). */
function candidates() {
const found = []
const qqq = [join(root, '.worktrees/qqq-develop'), join(root, 'qqq')].find((d) => existsSync(join(d, 'qqq-esb')))
if (qqq) {
for (const module of ['qqq-esb', 'qqq-middleware-javalin']) {
const dir = join(qqq, module, 'src/main/resources/openapi/qqq-v1')
if (existsSync(dir)) for (const f of readdirSync(dir)) if (f.endsWith('.yaml')) found.push(join(dir, f))
}
}
for (const entry of readdirSync(root).filter((d) => d.startsWith('qbit-')).sort()) {
for (const base of [join(root, entry), join(root, entry, entry)]) {
const dir = join(base, 'src/main/resources/openapi/qqq-v1')
if (existsSync(dir)) for (const f of readdirSync(dir)) if (f.endsWith('.yaml')) found.push(join(dir, f))
}
}
return found
}
function git(dir, ...gitArgs) {
try {
return execFileSync('git', ['-C', dir, ...gitArgs], { encoding: 'utf8' }).trim()
} catch {
return ''
}
}
const documents = candidates()
if (!documents.length) throw new Error(`No extension documents under ${root}`)
execFileSync('node', [join(repo, 'scripts/merge-spec.mjs'), '--out', out, core, ...documents], { stdio: 'inherit' })
const sources = documents.map((file) => {
const info = parse(readFileSync(file, 'utf8')).info ?? {}
const dir = dirname(file)
return {
name: String(info.title ?? '').replace(/^QQQ Middleware API extension: /, ''),
version: info.version,
document: relative(root, file),
branch: git(dir, 'rev-parse', '--abbrev-ref', 'HEAD'),
commit: git(dir, 'rev-parse', '--short', 'HEAD'),
}
})
writeFileSync(join(dirname(out), 'extension-sources.json'), JSON.stringify({ core, extensions: sources }, null, 2) + '\n')
for (const s of sources) console.log(`${s.name.padEnd(16)} ${String(s.version).padEnd(7)} ${s.branch}@${s.commit} ${s.document}`)
+25 -3
View File
@@ -16,7 +16,11 @@
* Rules: paths and operations are unioned (a duplicate method on one path is an error); component
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1); the
* core document's info, servers and securitySchemes are kept; tags are unioned; every extension's
* name and version is recorded in info.x-qqq-extensions.
* name, version, path prefix and operationIds are recorded in info.x-qqq-extensions.
*
* An extension whose operations the core input already carries (a server's merged document, e.g.
* the sample server's esb) is accepted when each such operation is identical; it is then recorded
* in info.x-qqq-extensions like any other.
*/
import { readFile, writeFile } from 'node:fs/promises'
@@ -50,6 +54,14 @@ function deriveOperationId(method, path) {
return id
}
/** The path prefix an extension's operations share, relative to /qqq/v1 (`/qbit/{name}` or an established one such as `/esb`). */
function pathPrefixOf(name, paths) {
const qbit = `/qqq/v1/qbit/${name}`
if (paths.length && paths.every((p) => p === qbit || p.startsWith(qbit + '/'))) return `/qbit/${name}`
const first = new Set(paths.map((p) => p.replace(/^\/qqq\/v1/, '').split('/')[1]))
return first.size === 1 ? `/${[...first][0]}` : undefined
}
async function load(source) {
const text = /^https?:\/\//.test(source) ? await (await fetch(source)).text() : await readFile(source, 'utf8')
return source.endsWith('.json') ? JSON.parse(text) : parse(text)
@@ -72,12 +84,22 @@ const extensions = []
for (const source of extensionSources) {
const doc = await load(source)
const name = (doc.info?.title ?? '').replace(/^QQQ Middleware API extension: /, '') || source
extensions.push({ name, version: doc.info?.version, description: doc.info?.description })
const operationIdsOfExtension = []
const paths = Object.keys(doc.paths ?? {})
extensions.push({ name, version: doc.info?.version, description: doc.info?.description, pathPrefix: pathPrefixOf(name, paths), operations: operationIdsOfExtension })
for (const [path, operations] of Object.entries(doc.paths ?? {})) {
const target = (merged.paths[path] ??= {})
for (const [method, operation] of Object.entries(operations)) {
if (target[method]) throw new Error(`duplicate operation ${method.toUpperCase()} ${path} (from ${name})`)
if (!['get', 'put', 'post', 'patch', 'delete', 'head', 'options'].includes(method)) {
target[method] ??= operation // path-level parameters, summary
continue
}
if (operation.operationId) operationIdsOfExtension.push(operation.operationId)
if (target[method]) {
if (JSON.stringify(stable(target[method])) === JSON.stringify(stable(operation))) continue
throw new Error(`duplicate operation ${method.toUpperCase()} ${path} (from ${name})`)
}
target[method] = operation
}
}
+11 -10
View File
@@ -48,23 +48,23 @@ STATUS = {
'QRY-005': (X, 'grid presentation'), 'QRY-006': (X, ''), 'QRY-007': (P, 'error with code, status and trace id'), 'QRY-010': (P, 'all operators and shorthand'), 'QRY-011': (P, 'BETWEEN, IN lists'),
'QRY-012': (P, 'now(), now(-7d), thisPeriod(), startOf()'), 'QRY-013': (P, '--where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before'), 'QRY-014': (P, 'IS_BLANK, TRUE, FALSE'), 'QRY-015': (T, 'filters take ids; labels in filters todo'),
'QRY-016': (P, '--group with parentheses'), 'QRY-017': (P, '--print-filter gives the JSON the web links accept'), 'QRY-018': (P, 'any operator in --where'), 'QRY-019': (P, '--search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter'),
'QRY-020': (T, 'joins'), 'QRY-021': (T, 'joins'), 'QRY-022': (T, 'column statistics'), 'QRY-023': (T, ''), 'QRY-030': (P, '-r ids, --filter-json, --limit'), 'QRY-031': (T, 'bulk edit'), 'QRY-032': (T, 'bulk delete'),
'QRY-020': (N, '--join and table.field in --fields/--where/--order, labelled "Join: Field"; the sample exposes no joins, verified through an association join (person to pet)'), 'QRY-021': (P, 'a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct)'), 'QRY-022': (N, 'qqq table stats <t> <field> [-w …] through the columnStats process; the sample has neither QUERY_STATS nor the process, so --local (same figures from the matching rows) is what was verified'), 'QRY-023': (P, 'without QUERY_STATS (or the process) table stats exits 2 with the reason; --local is an explicit opt-in'), 'QRY-030': (P, '-r ids, --filter-json, --limit'), 'QRY-031': (T, 'bulk edit'), 'QRY-032': (T, 'bulk delete'),
'QRY-033': (P, 'process run --filter-json with limit'), 'QRY-034': (T, 'process list --table'), 'QRY-035': (P, 'server refuses; exit 3'), 'QRY-040': (P, 'table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out -'), 'QRY-041': (P, 'csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request'),
'QRY-042': (X, 'nothing to disable'), 'QRY-043': (P, 'server refuses'), 'QRY-050': (T, 'saved views'), 'QRY-051': (T, ''), 'QRY-052': (T, ''), 'QRY-053': (T, ''), 'QRY-054': (T, ''),
'QRY-060': (T, 'table variants'), 'QRY-061': (T, ''), 'QRY-062': (P, 'describe shows capabilities; server refuses'), 'QRY-063': (P, 'server refuses writes'), 'QRY-064': (P, ''), 'QRY-065': (P, 'POST /table/{t}/export'),
'QRY-066': (T, 'variants route'), 'QRY-067': (P, 'table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note'), 'QRY-068': (P, 'default columns follow sections by tier'), 'QRY-069': (X, 'locale formatting'), 'QRY-070': (X, 'shortcuts'), 'QRY-071': (T, 'tableVariant on processes'),
'QRY-042': (X, 'nothing to disable'), 'QRY-043': (P, 'server refuses'), 'QRY-050': (N, 'qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server'), 'QRY-051': (N, 'saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView'), 'QRY-052': (N, 'saved-view save --replace, rename, delete (asks; --yes)'), 'QRY-053': (N, 'a name in use is refused before any change; an unknown view exits 2 with the list'), 'QRY-054': (N, 'views shared with you are listed as shared by their owner; changes to them are the server\'s to refuse, its message shown'),
'QRY-060': (N, 'table variants; --variant on query, count, get, copy, export, writes and process run; a terminal asks; not remembered between commands; no variant table in the sample'), 'QRY-061': (N, 'no --variant and no terminal (or no answer): exit 2, nothing queried; no variant table in the sample'), 'QRY-062': (P, 'describe shows capabilities; server refuses'), 'QRY-063': (P, 'server refuses writes'), 'QRY-064': (P, ''), 'QRY-065': (P, 'POST /table/{t}/export'),
'QRY-066': (N, 'GET /table/{t}/variants (called live; the sample lists none)'), 'QRY-067': (P, 'table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note'), 'QRY-068': (P, 'default columns follow sections by tier'), 'QRY-069': (X, 'locale formatting'), 'QRY-070': (X, 'shortcuts'), 'QRY-071': (N, 'process run --variant: tableVariant on init, every step and status; sent live, no variant table to observe'),
'QRY-080': (X, 'quick filters UI'), 'QRY-081': (X, ''), 'QRY-082': (X, ''), 'QRY-083': (X, ''), 'QRY-084': (X, ''), 'QRY-085': (T, 'weekday criteria'), 'QRY-086': (P, 'expressions'), 'QRY-087': (P, 'IN lists; quoted values'), 'QRY-088': (X, ''), 'QRY-089': (P, 'behaviors'),
**{f'QRY-{i:03d}': (X, 'saved views and column UI') for i in (90,91,92,93,94,95,96)}, **{f'QRY-{i:03d}': (X, 'phone layout') for i in (8,9,24,36,55)},
# REL
'REL-001': (P, 'display values and labels'), 'REL-002': (T, 'child lists: table get --associations partial'), 'REL-003': (T, 'create child'), 'REL-004': (T, ''), 'REL-005': (P, 'server'), 'REL-006': (P, 'server'), 'REL-007': (T, ''), 'REL-008': (T, ''), 'REL-009': (P, 'server'),
'REL-001': (P, 'display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one'), 'REL-002': (P, 'table get --associations lists exactly the record\'s children; table children pages a list with its count (View All)'), 'REL-003': (P, 'table create <child> --parent table=pk; table get --associations shows it at once'), 'REL-004': (P, 'person > pets > notes: each pet lists only its own notes'), 'REL-005': (P, 'records are never cached: update or delete a child, the next get/children shows the current rows'), 'REL-006': (N, 'unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested)'), 'REL-007': (N, 'composite joins and widget-section labels in the kernel, unit-tested; the sample declares none'), 'REL-008': (N, '--parent sets every join value of a composite association, unit-tested; the sample declares none'), 'REL-009': (P, 'table delete of a parent: its pets are gone, other parents keep theirs'),
# REC
'REC-001': (P, 'table get by section and tier'), 'REC-002': (T, 'hidden sections'), 'REC-003': (P, 'exit 5'), 'REC-004': (X, 'tabs'), 'REC-005': (P, 'validated locally before any request'), 'REC-006': (P, ''), 'REC-007': (P, ''), 'REC-008': (X, 'guard'),
'REC-001': (P, 'table get by section and tier'), 'REC-002': (N, 'empty values show --; hidden sections and fields are dropped by the kernel record view (unit-tested; the sample has none)'), 'REC-003': (P, 'exit 5'), 'REC-004': (X, 'tabs'), 'REC-005': (P, 'validated locally before any request'), 'REC-006': (P, ''), 'REC-007': (P, ''), 'REC-008': (X, 'guard'),
'REC-009': (P, 'update sends only changed fields'), 'REC-010': (P, 'non-editable fields excluded'), 'REC-011': (P, ''), 'REC-012': (P, 'confirm unless --yes'), 'REC-013': (P, 'qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped'), 'REC-014': (P, ''), 'REC-015': (P, ''), 'REC-016': (P, 'defaults applied'),
'REC-017': (P, ''), 'REC-018': (P, 'typed and shown in TZ, stored UTC, --utc for raw; DST edges tested'), 'REC-019': (X, 'display zones'), 'REC-020': (T, 'raw HTML; $EDITOR todo'), 'REC-021': (P, 'server masks'), 'REC-022': (T, 'blob upload and download'), 'REC-023': (P, 'server'),
'REC-024': (P, 'maxLength checked locally'), 'REC-025': (P, 'behaviors'), 'REC-026': (P, 'ranges checked locally'), 'REC-027': (P, 'server message'), 'REC-028': (T, 'adornments'), 'REC-029': (T, ''), 'REC-030': (X, ''), 'REC-031': (T, ''), 'REC-032': (T, ''),
'REC-033': (T, 'reveal'), 'REC-034': (T, 'file download'), 'REC-035': (T, 'file upload'), 'REC-036': (X, ''), 'REC-037': (T, ''), 'REC-038': (T, ''), 'REC-039': (T, 'help'), 'REC-040': (T, ''), 'REC-041': (T, ''), 'REC-042': (T, 'audits'), 'REC-043': (T, ''),
'REC-024': (P, 'maxLength checked locally'), 'REC-025': (P, 'behaviors'), 'REC-026': (P, 'ranges checked locally'), 'REC-027': (P, 'server message'), 'REC-028': (P, 'record links name table and key, --follow opens them; URL links show the URL'), 'REC-029': (N, 'chip as [value]; colors and icons not shown (unit-tested; none in the sample)'), 'REC-030': (X, ''), 'REC-031': (N, 'raw code on view; no code editor (unit-tested)'), 'REC-032': (N, 'HTML shown as text (unit-tested)'),
'REC-033': (N, 'masked until --reveal; no copy (unit-tested; none in the sample)'), 'REC-034': (N, 'name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16'), 'REC-035': (N, 'create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample'), 'REC-036': (N, 'tooltip text in parentheses after the value (unit-tested)'), 'REC-037': (N, 'value rendered through the widget render model as text (unit-tested)'), 'REC-038': (N, 'value marked with ! (unit-tested)'), 'REC-039': (N, 'table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone'), 'REC-040': (N, 'section help with --help-content (unit-tested)'), 'REC-041': (N, 'table helpContent and helpContents slots with --help-content (unit-tested)'), 'REC-042': (N, 'qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server'), 'REC-043': (N, 'without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests)'),
'REC-044': (P, 'recordLabel'), 'REC-045': (P, 'labels resolved, defaults looked up'), 'REC-046': (P, 'server'), 'REC-047': (X, ''), 'REC-048': (P, 'fieldErrors by field'), 'REC-049': (X, ''), 'REC-050': (P, ''), 'REC-051': (P, ''), 'REC-052': (P, 'contingent fields'),
'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (T, 'variants'), 'REC-064': (T, 'joins'), 'REC-065': (P, 'an unchanged wall-clock time keeps the original instant and its precision'),
'REC-053': (T, 'developer view'), 'REC-054': (T, ''), 'REC-055': (X, ''), 'REC-056': (X, ''), 'REC-057': (X, ''), 'REC-058': (X, ''), 'REC-059': (X, ''), 'REC-060': (T, 'adjusters wired, no sample to test'), 'REC-061': (T, ''), 'REC-062': (P, 'inline enums'), 'REC-063': (N, 'get, copy and update -i read with --variant; v1 writes ignore it (server finding #19)'), 'REC-064': (N, 'table get --join adds joined fields (queryJoins); verified with an association join'), 'REC-065': (P, 'an unchanged wall-clock time keeps the original instant and its precision'),
# SEC
'SEC-001': (P, ''), 'SEC-002': (T, 'disabled tables listed with a reason'), 'SEC-003': (P, ''), 'SEC-004': (P, ''), 'SEC-005': (P, ''), 'SEC-006': (T, 'copy'), 'SEC-007': (P, ''), 'SEC-008': (T, ''), 'SEC-009': (P, 'widget get shows the refusal'), 'SEC-010': (P, ''),
'SEC-011': (T, 'saved views and reports'), 'SEC-012': (P, 'server'), 'SEC-013': (P, 'server'), 'SEC-014': (P, ''), 'SEC-015': (P, ''), 'SEC-020': (P, 'qqq login on a MOCK server'), 'SEC-021': (P, 'qqq logout calls POST /logout'), 'SEC-022': (P, 'exit 3 with the sign-in hint'),
@@ -82,7 +82,8 @@ STATUS = {
'RPT-001': (T, 'report run exists; no sample report to verify'), 'RPT-002': (T, ''), 'RPT-003': (T, ''), 'RPT-004': (T, ''), 'RPT-005': (T, ''), 'RPT-006': (T, 'report inputs'), 'RPT-007': (P, 'server'), 'RPT-008': (T, 'report list'),
**{f'RPT-{i:03d}': (T, 'saved reports, schedules, sharing') for i in (9,10,11,12,13,14,15,16,17,18,19)}, 'RPT-020': (T, ''),
# HLP
'HLP-001': (N, 'qqq help show app|widget|instance; the sample carries no help content'), 'HLP-002': (N, 'qqq help show table <t> [field]: table, section and field help; no sample content'), 'HLP-003': (N, 'field help is the column help; table action help slots print with their slot name; no sample content'), 'HLP-004': (N, 'qqq help show process <p>: process, step, HELP_TEXT and step field help; no sample content'), 'HLP-005': (P, 'qqq help-search'), 'HLP-006': (X, 'author affordance'),
'HLP-001': (N, 'qqq help show app|widget|instance; the sample carries no help content'), 'HLP-002': (N, 'qqq help show table <t> [field]: table, section and field help; no sample content'), 'HLP-003': (N, 'field help is the column help; table action help slots print with their slot name; no sample content'), 'HLP-004': (N, 'qqq help show process <p>: process, step, HELP_TEXT and step field help; no sample content'), 'HLP-005': (P, 'qqq help-search'),
'QRY-093': (X, 'no screen state to keep: views are opened by name'), 'QRY-094': (N, 'saved-view list counts quick views with doCount (--counts counts all)'), 'QRY-096': (N, 'opening a view drops removed fields with Material\'s warnings on stderr'), 'HLP-006': (X, 'author affordance'),
}
rows = []