web fixes

This commit is contained in:
Priec
2026-08-14 18:46:31 +02:00
parent 0e6207fb44
commit 1c3d292479
13 changed files with 1458 additions and 105 deletions

240
web-reality-gaps.md Normal file
View File

@@ -0,0 +1,240 @@
# 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.
---
## 1. Indexing a FKlink — blocking
**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.
## 6. The append screen could not link to a shared table — missing
**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.
## 7. A link could point at the table being created — blocking
**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.