qqq 1.0.0: the command-line client and the full-screen terminal UI (qqq ui) for QQQ applications over the UI API (/qqq/v1), with @qrun-io/qqq-cli 1.0.0, @qrun-io/qqq-core 1.0.0, @qrun-io/qqq-ui-api-client 1.8.0 and @qrun-io/qqq-ui-api-conformance 1.6.0 going final together. Squash of the release/1.0.0 branch at 79ba68e (the branch is kept with the full signed history; the tags v1.8.0 and cli-v1.0.0 point at that commit, which is what the released binaries were built from). See CHANGELOG.md [1.0.0] and docs/release-notes/1.0.0.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
16 KiB
16 KiB
UI API server findings from the terminal clients
Observed while building the CLI and TUI against QQQ 4.1.0-SNAPSHOT (develop 6ac64fc66, UI API 1.8.0) with
the sample app and MOCK authentication. Filed on 2026-10-31 (James's OK); the Issue column links each one. Finding 10 was already open as #784, 9 and 21 share #1343, 18 is in qbit-collaboration.
| # | Finding | How to reproduce | Client workaround | Issue |
|---|---|---|---|---|
| 1 | A bearer session UUID cannot continue an asynchronous process step: the job fails with Permission denied for process state. The same UUID as the sessionUUID cookie works. |
POST /manageSession (MOCK), then init and step sleepInteractive with stepTimeoutMillis=0 using Authorization: Bearer {uuid}; poll status. |
Session sign-ins are sent as a cookie (sessionCookie option of the client). |
qqq#1335 |
| 2 | Process init and step silently ignore a url-encoded values JSON field; only multipart/form-data is read. No 400. |
curl -d 'values={"sleepMillis":4000}' runs with the default 1000 ms. |
Clients always send multipart. A 415 or 400 would help. | qqq#1336 |
| 3 | GET /jobs answers 404 although /version lists durableJobs in features. |
Sample server, qqq job list. |
The CLI explains; the TUI footer shows jobs n/a. Either drop the feature flag when no job store exists, or serve an empty list. |
qqq#1337 |
| 4 | searchTablePossibleValues with ids: ["Narnia"] on an INTEGER possible-value field returns 500 QException (Error executing query). |
qqq api searchTablePossibleValues --path tableName=placeAddress --path fieldName=countryId --body-json '{"ids":["Narnia"]}' |
Clients only send ids that can parse for the field type. A 400 with fieldErrors would be right. |
qqq#1338 |
| 5 | searchTablePossibleValues with searchTerm matches case-sensitively (or by prefix) on this source: can does not find Canada. |
Same table, {"searchTerm":"can"} on countryId. |
Clients fetch the first page and match client-side when the search returns nothing. Document the matching rule in the spec. | qqq#1339 |
| 6 | GET /table/{t}/{pk} returns { record: {...} } while query returns { records: [...] } with bare records; the design doc and the 0.1 CLI expected a bare record. |
Any record. | Clients unwrap both. Worth a note in UI-API-CLIENTS.md. |
qqq#1340 |
| 7 | Process init on clonePeople without records answers ERROR with No records were selected… only after init; getProcessMetaData says minInputRecords 0. |
qqq process run clonePeople. |
Clients show the message. The metadata could carry the real minimum. | qqq#1341 |
| 8 | manageSession has no documented username and password fields for TABLE_BASED, so a native client cannot sign in to such an app without the browser. |
Spec manageSession request body. |
qqq login refuses TABLE_BASED with a message. The design doc's nice-to-have "documented TABLE_BASED bearer recipe". |
qqq#1342 |
| 9 | searchRecords returned no results for Avery although person has search fields; unclear whether the sample app configures a search provider. |
qqq search Avery. |
None; needs a look at the sample's search setup. | qqq#1343 |
| 10 | The sample app configures no uploaded-file archive table (QJavalinMetaData.uploadedFileArchiveTableName), so every bulk-insert upload step and POST /uploads fail with UploadFileArchiveTableName was not specified in javalinMetaData. Bulk load cannot be exercised against the sample. |
qqq api initProcess --path processName=person.bulkInsert --form 'values={"tableName":"person"}' then step upload with a file. |
None in the client. The sample needs an archive table (memory or filesystem backend) and the javalin setting; the Next UI acceptance fixtures may already have one. | qqq#784 |
| 11 | By design; clients send CASE_INSENSITIVE. CONTAINS, STARTS_WITH and ENDS_WITH match case-sensitively on memory, H2 and PostgreSQL (MySQL only looks insensitive through collation); the opt-in is the criteria option CriteriaOption.CASE_INSENSITIVE on each criterion (qqq ruling, 2026-10-31). |
qqq table query person -w "lastName ~ sample" |
The kernel grammar and quick search add options: ["CASE_INSENSITIVE"] to text matches by default (~=, OPERATOR:CS or --case-sensitive ask for a case-sensitive match). Over v1 the option does not arrive yet: finding 27. |
qqq#1344 |
| 12 | POST /table/{t}/export answers 500 INTERNAL_ERROR for an unsupported format (Unsupported report format: pdf.) and for an unknown name in fieldNames (Field name bogus was not found on the Person table.). Both are request errors. |
qqq api exportTable --path tableName=person --body-json '{"format":"pdf"}'; same with "fieldNames":["bogus"]. |
qqq table export checks the format and the field names before sending (exit 2). A 400 with fieldErrors would be right. |
qqq#1345 |
| 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). |
qqq#1346 |
| 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. |
qqq#1347 |
| 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). |
qqq#1348 |
| 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). |
qqq#1349 |
| 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. |
qqq#1350 |
| 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": []. |
qbit-collaboration#52 |
| 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. |
qqq#1351 |
| 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. |
qqq#1352 |
| 21 | GET /version lists the recordSearch feature, but POST /search on the sample answers {"results":[],"tables":[],"provider":{"status":"NONE"}} for any text: the feature is advertised with no provider behind it, so a client cannot tell "nothing matched" from "this server cannot search" without reading provider.status. |
qqq api searchRecords --body-json '{"query":"avery"}' |
The TUI palette reads provider.status and says the server has no record search. Either list recordSearch only when a provider is configured, or document provider.status as the signal. |
qqq#1343 |
| 22 | The multiStatistics widget payload names its list statisticList (singular) inside statisticsGroupData, while other statistics payloads and the kernel's first reading used statisticsList; the OpenAPI document does not type the group, so a client can only guess. |
qqq widget get SampleMultiStatisticsWidget --output json |
The kernel render model accepts both names. Type MultiStatisticsData group items in the spec. |
qqq#1353 |
| 23 | The realtime SSE writer (RealtimeStreamSpecV1.handleOutput) serialises event data with JsonUtils.toJson, whose mapper is NON_EMPTY, so every empty array is dropped from every event while the HTTP JSON endpoints keep them (ALWAYS). Root cause of finding 18; also hits an empty fields on record.* events. |
Subscribe to /realtime/stream; emit an event whose data has an empty list; the key is missing. |
The kernel treats a missing array in an event as empty. Fixed in qbit-collaboration#52 for presence; core should serialise with ALWAYS. |
qqq#1354 |
| 24 | A weekday criterion (fieldFunction WeekdayOfDate, as the web UI sends for "day of week is any of") fails on the sample with 500 INTERNAL_ERROR (Error executing query): the RDBMS backend renders it as MySQL WEEKDAY(col) + 1, and H2 has no WEEKDAY function (JdbcSQLSyntaxErrorException: Function "WEEKDAY" not found). |
POST /qqq/v1/table/person/query with {"filter":{"criteria":[{"fieldName":"birthDate","operator":"IN","values":["1"],"fieldFunction":{"fieldName":"birthDate","functionTypeIdentifierName":"WeekdayOfDate"}}],"booleanOperator":"AND"}}; qqq ui, :filter weekday(birthDate) IN mon. |
The TUI sends the criterion as the web UI does and shows the server error in the grid. The backend needs a per-dialect weekday expression (H2 ISO_DAY_OF_WEEK, MySQL WEEKDAY()+1). |
qqq#1355 |
| 25 | A JSON null in a process step's values answers 500 QRuntimeException (Non-serializable value in param map under key [values][birthDate]: Null). Bulk edit clears a switched-on field by sending it empty, and null is the natural JSON for "no value". |
qqq api stepProcess on person.bulkEdit step edit with values={"birthDate":null,"bulkEditEnabledFields":"birthDate"}. |
The kernel sends "" for a cleared field, as the Next UI does (bulkEditValues). Treat null as no value, or answer 400. |
qqq#1356 |
| 26 | The bulk-insert mapping suggester (suggestedBulkLoadProfile) maps one file column to a main-table field and to a child-table field with the same label (Birth Date to birthDate and pets.birthDate) with layout TALL, so submitting the suggestion unchanged builds child records whose required fields are unmapped; the suggestion also carries no version while bulkLoadProfile has "version":"v1". |
Bulk insert a CSV with First Name, Last Name, Email, Birth Date into person; read the upload step's suggestedBulkLoadProfile. |
The kernel (cleanSuggestion) drops a child field that reuses a column a main field reads and uses FLAT when no child field is left. |
qqq#1357 |
| 27 | The CASE_INSENSITIVE criteria option (finding 11's opt-in) cannot be sent over v1: QFilterCriteriaDeserializer reads fieldName, operator, values and fieldFunction but not options, and the spec's criterion schema does not declare options. A query with "options":["CASE_INSENSITIVE"] answers as if it were absent (no error). |
qqq api queryTable --path tableName=person -b '{"filter":{"criteria":[{"fieldName":"lastName","operator":"CONTAINS","values":["sample"],"options":["CASE_INSENSITIVE"]}],"booleanOperator":"AND"}}' finds no one on the sample (Sample finds five). |
The CLI and TUI send the option already, so case-insensitive matching starts working when the server reads it. Read options in the deserializer and declare it (an enum list) in the spec. |
qqq#1358 |
| 28 | The sample app can be started with OAUTH2 against an owned provider (OAuth2MetaDataProvider, OAUTH2_* environment) but has no AUTH_0 configuration, so AUTH_0 sign-in (the server's Auth0AuthenticationModule verifying the JWT against the tenant) cannot be exercised end to end without a real Auth0 tenant. |
qqq-sample-project/src/main/java/.../metadata/: only OAuth2MetaDataProvider reads environment variables. |
The CLI signs in to AUTH_0 servers with the tenant from values.baseUrl, the client from values.nativeClientId (else clientId) and the audience (so the access token is a JWT); unit-tested in the client package. An AUTH0_* environment switch in the sample (pointing at the conformance dev IdP) would let SEC-029 run locally; SEC-030 needs tenant credentials. |
qqq#1359 |