Relation picker permissions & unnamed records — frontend integration
Relation picker permissions & unnamed records — frontend integration
Two fixes to the relation picker → relation write round-trip. Both are value-level; no key was renamed, added, or removed.
- The picker no longer offers partner records the caller cannot attach, so the "pick, submit, get a 400" dead end is gone for the relation shape that hit it.
- A relation item for a record with no name now carries an empty
nameinstead of that record's slug, so the client can render its own placeholder.
1. The picker filters by write permission where writing needs it
GET /object/data/select-options/:objectSlug/:fieldSlug used to scope its
options by the caller's view level on the partner object. That is the
right gate for most relations, but not for one shape.
A reverse direct-FK relation (many-side, no junction table) stores the link
as a column on the partner row. Attaching a target therefore UPDATEs that
partner row, and the write path bounds the UPDATE by the caller's edit
level. A caller with view = A and edit = M was shown every record and
rejected on all but their own:
400 Relation 'relation-ms3efde1': you do not have permission to modify
1 of the 3 target record(s)For that shape the picker now scopes by the narrower of view and edit, so
an option that appears is an option the caller can actually attach.
Unchanged for the other shapes, deliberately — attaching does not touch the
partner row there, so edit is not required and demanding it would hide
records the caller may legitimately link:
| Relation shape | Attaching writes | Picker scoped by |
|---|---|---|
| Reverse direct FK (many-side, no junction) | the partner row | min(view, edit) |
| Junction table (many-to-many) | a junction row | view |
| Forward FK (belongs-to) | our own record's FK column | view |
FE impact: none — no shape change. Expect fewer options in a reverse-FK picker for non-admin users. That is the fix, not a regression: those options were never attachable.
2. The rejection names the records
When the write is still rejected — the picker is a snapshot, and rights can change between opening it and submitting — the 400 now lists the offending record slugs, in the order they were submitted:
// before
"Relation 'relation-ms3efde1': you do not have permission to modify 1 of the 3 target record(s)"
// now
"Relation 'relation-ms3efde1': you do not have permission to modify 1 of the 3 target record(s): a556a7bc942b"The slugs are the ones the client sent, so they can be matched straight back to the submitted array and highlighted.
3. An unnamed record's name is empty, never its slug
The server-side display-name projection used to fall back to the record's slug
when name was blank. An opaque 12-hex slug is indistinguishable from a real
display value, so a relation badge rendered 5c9696e92c84 and the client's own
"(Untitled)" placeholder never fired.
The fallback is gone. What each surface emits for a record with no name:
// records read — relations[<relation_slug>].items[]
{ "id": 12, "name": null, "slug": "5c9696e92c84", "random_id": "R12", … }
// GET /select-options/:objectSlug/:fieldSlug — data.options[]
{ "type": "relation", "slug": "5c9696e92c84", "name": "" }name was already typed nullable on relation items; the picker keeps its
non-null string contract and sends "". Either way it is now falsy, so a
name || "(Untitled)"-style guard works everywhere.
FE impact: if a client special-cased a slug-shaped name, that guard is now
dead code — harmless to keep, safe to delete.
Ordering is unchanged: unnamed records still sort by slug in the position the
old expression put them, because the list's ORDER BY deliberately keeps the
slug fallback (it has to match the shipped idx_<table>_display_btree
functional index verbatim, or the sort stops using it).