21. Authors with references
Show who wrote each asset with one reference declared in collection.yaml, a profiles collection keyed by user id, and expand=author(name,avatar) on the list: the cost headers before and after selecting fields, a profile nobody can read, and writes that keep the asset's own ETag.
The gallery shows titles, but not who saved them. The name lives somewhere else: the account (email, password, roles) is private, and the directory of members is business data. The usual fix is one more request per card, or copying the name into every asset and keeping it up to date. A reference is the third way: the asset says where its author is, and a read can bring the author along.
A profile per person
A profile is what other members see of a person. It belongs to its owner (_meta.owner, set by backd), and its id is the person’s own id: Ana’s profile is at /v1/shelf/main/profiles/<Ana's user id>. There is at most one per person because an id is unique, with no index to maintain, and anyone who knows a user id (every document they own carries it in _meta.owner) can read their profile directly.
examples/config/shelf/main/profiles/schema.json
{
"$comment": "How a member appears to the others (chapter 21): one profile per user. Assets point at it through the `author` reference, so a list can show who wrote each one without a request per card. The profile belongs to its owner (_meta.owner), which is how the reference finds it.",
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 80, "description": "The name shown next to what the member wrote." },
"avatar": { "type": "string", "maxLength": 300, "description": "An initial, an emoji or a short text shown beside the name." },
"bio": { "type": "string", "maxLength": 300, "description": "One line about the member." }
},
"required": ["name"],
"additionalProperties": false
}Its rules are short: every signed-in member reads every profile, and only the owner changes their own. The line that does the rest is custom_id: user:
examples/config/shelf/main/profiles/collection.yaml
# Chapter 21: a profile is what other members see of a person. The account
# (email, password, roles) is private; a profile holds only what the person
# chooses to show, so it can be read by every member and expanded into lists.
#
# A profile lives at the person's own id: /v1/shelf/main/profiles/<user id>. backd checks
# that the id of a create is the creating user's, and an id is unique, so there is at most
# one profile per person without an index to keep it so.
custom_id: user
rules:
# Every signed-in member reads every profile; anonymous visitors none, so the
# public gallery shows no authors (the reference expands to null there).
read: user != nil
# A member makes their own profile; the id says whose, and a second create is a 409.
create: user != nil
# Only the owner changes or removes it.
update: user != nil && document._meta.owner == user.id
delete: user != nil && document._meta.owner == user.id
# A profile goes with its person when they are erased: what they wrote stays,
# and its author is an unreadable reference (null) from then on.
on_owner_delete:
action: deleteWith custom_id: user backd takes the id of a create from the client and checks that it is the creating user’s: a profile at anyone else’s id is a 400 invalid_id, and a second profile of the same person is a 409 id_taken. Add the two files as config/shelf/main/profiles/schema.json and collection.yaml.
await profiles.create({ id: user.id, name: 'Ana', avatar: '🦊' }) // the typed client requires the id
const mine = await profiles.get(user.id) // and finds it by it
Declare the reference
An asset’s author is the person who owns it, so the source is _meta.owner, and the target is the profile whose id is that value: the default key of a reference is id, so nothing more is needed. Add this at the end of config/shelf/main/assets/collection.yaml:
references:
author:
field: _meta.owner
collection: profiles
backd checks it when it starts: the collection exists and the source is something a reference can read. Nothing is stored for the reference: it is a read-time lookup, and a key other than id would have to be a field with a unique index.
Ask for it
A read says which references it wants, and which fields of them:
GET /v1/shelf/main/assets/<id>?expand=author
{
"id": "…", "title": "The backd handbook", "kind": "link",
"_meta": { "owner": "…", "version": 1, … },
"_expanded": {
"author": { "id": "…", "name": "Ana", "avatar": "🦊", "bio": "…", "_meta": { … } }
}
}
Without parentheses the whole profile comes back, as a direct read would return it. A gallery card needs two fields, and asking for two keeps the response small:
GET /v1/shelf/main/assets?expand=author(name,avatar)&limit=9
The app does it in one line, with the builder that checks the form:
import { expand } from 'backd-js'
const page = await assets.list({ where, orderBy, limit: 9, expand: expand(['author', ['name', 'avatar']]) })
page.items[0]._expanded?.author // { id, name, avatar } or null
What it costs
The answer says what the expansion did:
X-Backd-Expand-Queries: 1 one query on profiles, for the whole page
X-Backd-Expand-Docs: 9 nine authors placed
A page of nine cards costs one extra query, not nine. Those numbers don’t change with field selection: choosing name,avatar makes the response smaller, not the lookup cheaper. What makes it expensive is asking for more paths, more levels or a many reference with long arrays; nothing is expanded unless asked, and the server refuses past its limits.
A profile nobody can read
profiles reads need a sign-in. The public gallery of chapter 1 has no signed-in visitor, so there the author is null, and so it is for an author who never made a profile or whose profile was erased. The three look identical on purpose: a reader can’t use the field to find out which of them it was. The card just doesn’t print a name.
Writes are the asset’s own
ETag of an expanded read is still the asset’s version, so If-Match works with it. Expanded responses are never cached or answered 304, and a write never expands: sending back what you read (_expanded included) changes nothing, because _expanded is ignored like _meta.
where and order_by use the asset’s own fields: where={"author.name":"Ana"} is a 400 that says so. To find the assets of one person, filter on _meta.owner.
You should see
- A second profile for the same member is refused (
409 id_taken), and a profile at someone else’s id is400 invalid_id; the profile’sidis the member’s user id. expand=authorreturns the profile in_expanded.author; the asset and itsETagare as before, and the response isprivate, no-cache.- With
author(name)onlyidandnamecome back;X-Backd-Expand-Queriesis1for one asset and for a whole page. - An unknown reference is
400 invalid_expandthat names it;whereonauthor.nameis400 invalid_query. - The gallery cards show by Ana; signed out they show no author.
Read Expanding references for the limits and the client for typed _expanded.
Next: chapter 22 — search and a team dashboard.