Files
komp_ac/web-reality-gaps.md
2026-08-14 18:46:31 +02:00

11 KiB

Where the web UI disagreed with the server

Thirteen places where the Add-table builder and the append-columns panel offered, promised or accepted something the backend does not do. All thirteen are fixed in the web crate; the server is untouched. Ordered by severity.

Severity is what the gap costs the user:

  • Blocking — the UI leads you to a definition the server refuses outright.
  • Wrong — the UI states something untrue about the table you are creating.
  • Missing — a real backend capability the UI gives you no way to reach.
  • Misleading — accurate enough to act on, wrong enough to confuse.

cargo test -p web: 158 passed, up from 134. Clippy clean for every file touched.


Was: the column panel offered Indexing: yes/no for a link like any other column, and the column list gave it an index toggle.

Reality: a link is a foreign key, and the server builds an index for every one of them as it creates the table (server/src/table_definition/managed_table.rs:361). Naming a link in indexes is not redundant — it is refused:

Link 'billing_customer' is indexed automatically

post_table_definition.rs:514, and the same rule on the append path at add_table_columns.rs:322. So the whole table failed to create, after the Create button, for a checkbox the UI itself offered.

Now: ColumnDraft::is_indexable is false for a link, the Indexing field is replaced by "Indexed automatically — a link is a foreign key, and the server indexes every one of them", the column list reports indexed automatically with no toggle, selected_index_names can never name a link, and a rebuilt or tampered post is refused here with the server's own reason instead of being forwarded. Both screens.

2. Global tables and the columns that post to a profile's books — blocking

Was: the shared scope offered ACCOUNTING, ACCOUNTING_TRANSFER and the quantity-ledger switch exactly as a profile scope does.

Reality: post_table_definition.rs:280"Global tables cannot use accounting or quantity-ledger columns". A shared table belongs to every profile at once, so there is no one set of books for it to post to.

Now: the two types are not offered in the shared scope, the quantity-ledger field is not rendered there, and both are refused with the reason. Switching an existing draft to the shared scope reports it at the switch rather than at save — the picker going quiet is not an explanation on its own.

3. Table names could be 63 characters — blocking

Was: validate_identifier(…, "Table name"), the plain 63-character identifier rule.

Reality: a table name is a prefix that longer identifiers are built from — idx_<table>_<column>_fk has to fit in 63 too — so the server's limit is 38 (catalog/object_naming.rs:35). Anything from 39 to 63 characters passed the browser and failed at the backend.

Now: validate_table_name, with the limit derived from the same arithmetic and the same common constant the server reads, so a system column added there moves it here too. The test asserts it lands on 38.

4. Reserved table names — blocking

Was: nothing stopped a table called accounts or general_ledger.

Reality: every profile is given general_ledger, journal_lines, quantity_ledger, accounts and custom_exchange_rates when it is created; post_table_definition.rs:221 refuses the names.

Now: refused in the builder, naming the table and why.

5. required was unreachable — missing

Was: proto_columns hardcoded required: false. No control anywhere.

Reality: required is a live column property. The server stores it and enforces it on every row written (table_validation/runtime.rs:75) — a row that omits a required column is rejected. The web UI could not produce a required column at all, and could not tell you a column was one.

Now: a Required field on both column panels, carried through the form, listed as a required tag, shown in the preview and sent on the request.

Was: its link-target picker read page.tables, which for a profile scope is that profile's own tables.

Reality: the server resolves a link against the profile's tables or any global one (post_table_definition.rs:684). Shared tables — currencies, code lists — were simply absent from the picker with no way to reach them.

Now: link targets are their own list, built by table_scope::linkable_tables (the same helper the builder uses), while the browse list stays the scope's own tables. This is the same class of bug as the "No table chosen" one fixed earlier in this session: one question, three answers.

Was: the builder filtered its own name out of the picker, and that was all. The table-name field has no hx-trigger by design, so adding link(invoice) and then naming the table invoice sailed through.

Reality: post_table_definition.rs:400"Link 'x' cannot point at the table being created".

Now: a rule in validate, not just a filter on a list, so the order the fields were filled in cannot get round it.

8. link(accounts) was refused only by the server — blocking

Was: the builder's loader dropped accounts from the picker, but nothing validated a draft that carried the link anyway.

Reality: "The account relationship is built into ACCOUNTING and cannot be declared as a link" (post_table_definition.rs:406).

Now: a rule in validate, shared by both screens.

9. A declared column could collide with a generated one — blocking

Was: duplicate names were checked against the declared columns only.

Reality: the server checks against every name the table will hold, generated ones included — "Column 'debit' conflicts with a column generated for ACCOUNTING" (models.rs:450). Declaring debit beside an ACCOUNTING row was accepted by the builder and refused by the backend.

Now: ColumnDraft::claimed_names is the set both add and validate check, in both directions — the declared column first or the definition row first. Companions named after their own column (work_phone_ext) still never collide.

10. Rows could not be identified by a generated column — missing

Was: row-display candidates were the declared, non-compound columns.

Reality: the server accepts any of the table's real column names, generated ones included (post_table_definition.rs:482), and even maps a display column of accounting onto whatever its name companion ended up called. A table whose only readable column is generated — an ACCOUNTING row's name — had nothing to be shown by.

Now: every column the table will really hold is a candidate, under its aliased name, and the definition row itself still is not one.

11. The preview omitted row_revision — wrong

Was: the preview claims to be "the schema as it will exist" and listed id, deleted, … created_at.

Reality: every managed table also carries row_revision (common/src/system_column.rs:20). It was also missing from the names an alias may not take, so row_revision could be asked for as a generated column's alias and refused by the server.

Now: in the preview and in the reserved set.

12. "already exists in profile ``" — misleading

Was: the duplicate-table-name error always named a profile. In the shared scope there is no profile, so it rendered an empty pair of backticks.

Now: scope-aware. The shared scope says the name has to be free in every profile; a profile scope says the name may also be taken by a shared table, which is what the check really covers.

13. Index and options were reported twice — misleading

Was: option_label() included indexed, and the column list rendered it as a tag beside the Indexed column that said the same thing.

Now: the list's Indexed column is the only place index state is reported; the options column carries required, quantity ledger and currency.


Deliberately left alone

  • The link's <n>_version column. Every link creates one (managed_table.rs:324), and the preview does not show it. It is an internal column — common/src/system_column.rs says so — never exposed by the data API, so listing it would be noise rather than honesty.
  • account_id shown as well as account. Both are real: one is the physical column, one is the API name. The preview already explains the relationship.

Suggested server changes

None of these are needed for the fixes above — the web crate works around each one — but each is a place where a client can only be correct by knowing something the backend never tells it.

  1. ListColumnTypes should say which types a shared table may not use. A profile_only: bool on ColumnType would let a picker be right without naming ACCOUNTING and ACCOUNTING_TRANSFER in client code. Today web/src/schema/mod.rs has to hardcode the pair (PROFILE_ONLY_TYPES), and a third type with the same restriction would be silently offered.

  2. ListColumnTypes should say a link is indexed automatically. Same shape of problem as (1): the rule is real and client-visible, but the only way to know it is to have read managed_table.rs. An indexed_automatically: bool would make finding #1 impossible to reintroduce in any client.

  3. The reserved table names should be reportable. general_ledger, journal_lines, quantity_ledger, accounts and custom_exchange_rates are constants of three separate server modules. Every client that wants to refuse them early has to copy the list, and will drift when the server adds one. They belong on a capability or catalog response.

  4. MAX_TABLE_NAME_LENGTH should be reportable too, for the same reason. The web crate now derives it from the same common constant the server does, which holds only because both compile the same source file — a coincidence of layout rather than a contract.

  5. Projection columns are unreachable from any client. post_table_definition handles a from(link_column.source_column) field type (link_column_projection.rs:30), but COLUMN_TYPE_CATALOG does not report it and ColumnTypeSpelling has no variant for it — only Bare, Decimal and Link. So a feature that exists in the backend cannot be offered by the web UI or the TUI. Either add the spelling and catalog entry, or the code is dead.

  6. The append path checks the global quantity-ledger rule only at execute time. add_table_columns.rs:238 runs after the table is resolved, so a client has to work out for itself whether the table it is appending to is global. The web crate now infers it from the profile tree; reporting it on the table would be sturdier.