@uniweb/api
v1.7.0
Published
The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.
Readme
@uniweb/api
The client a foundation uses to talk to its site's backend service — sign-in, the
site's members, and the content they create.
You import it the way you import @uniweb/kit. It is bundled into your foundation,
tree-shaken, and inert on a site that has no backend service: nothing throws, no request
leaves, and your components render the version of themselves that does not need one.
npm install @uniweb/apiDoes the site have the service?
Ask before you draw. This is a synchronous read of the site's own configuration, not a probe — there is nothing to await.
import { isBackendEnabled } from '@uniweb/kit'
if (!isBackendEnabled()) return <StaticVersion />⛔ And when the answer is no, draw nothing — not a disabled button, and not an explanation. A visitor has no stake in which services the site's operator set up, and "sign-in is unavailable" reads like a breakage when it is simply a feature this site does not have.
The session
import { useSession, useSignIn, SignedIn, SignedOut } from '@uniweb/api'
function Account() {
const { viewer, signOut } = useSession()
const { signIn, status, error } = useSignIn()
return (
<>
<SignedIn>
{viewer.handle} <button onClick={signOut}>Sign out</button>
</SignedIn>
<SignedOut>
<button onClick={() => signIn({ username, password })}>Sign in</button>
{error?.kind === 'unverified' && <span>Check your email to confirm your address.</span>}
{error?.kind === 'auth' && <span>Wrong username or password.</span>}
</SignedOut>
</>
)
}viewer is flat — viewer.uuid, viewer.username, viewer.handle, and viewer.roles,
a list of { role, scope_unit_id }. ⭐ Roles describe the session, and on a site a
session holds the account's roles as a reader's until it is elevated. A site runs
your foundation's code with the signed-in member's session, so an administrator signed
in on it reads what their role reaches — the operator reads every member's content —
and changes nothing through the role until they confirm it is them (Acting as an
administrator, below). Their own content they edit, delete and share as always. An
ordinary member holds no role ([]). viewer.workspace — the site service's home org,
{ handle: 'home', … } — is the same for everyone. To decide whether to show an edit
control for a particular record, read that record: entity.can_edit is the backend's
own answer.
Signing up — useSignUp().signUp({ username, email, password }) answers
{ status: 'verification_required', email }, the same answer for an address already in
use. The account cannot sign in until the address is confirmed from the email the
backend sends — its link lands on your site (below); until then signIn fails with
error.kind === 'unverified'. A username already taken fails with status 409.
canSignUp (like canSignIn) says only that the site has a backend, not that sign-up
is open.
What the page offers — useSignInOptions() reads what the site's owner set:
options.providers ([{ id: 'google' }, …]), options.signup ('open' or
'invite_only') and options.email (whether the backend delivers mail). It is read once
per page and shared; status is 'absent' with no backend. The answer comes as the
backend gives it, with no defaults: draw a rule only when a field states it.
Signing in through a provider — signInWithProvider(id, { returnTo }) leaves for
the provider's sign-in, with id taken from options.providers. It is a navigation, so
call it from a button's handler and never render it as a link, which the site's router
could take for one of its own pages. It comes back to returnTo, by default the page the
viewer is on with its query kept, carrying one of ?auth=ok (signed in; the session the
page probes says so), ?link=<id> (that address already has an account not linked to the
provider) or ?error=<reason> (for example signup_by_invitation_only).
Password reset — usePasswordReset().request({ email }) asks for one; the email's
link lands on your site, where useMailedLink finishes it. Second factor — when
signIn answers { ok: false, challenge: { kind: 'totp' } }, finish with
completeChallenge(code).
The links the backend mails — a sign-up's confirmation, a password reset, an
invitation sent to an address with no account here: each is a link to your site's root,
a token in the query (?verify=, ?reset=, ?offer=), and the page it lands on
finishes the step. useMailedLink() says which step, and finishes it:
import { useMailedLink } from '@uniweb/api'
// Wherever the site's root renders it — a header or a layout is on every page.
function FromYourEmail() {
const { step, status, error, confirm } = useMailedLink()
if (step === 'reset' && status !== 'done') {
return <NewPassword onSave={(password) => confirm({ new_password: password })} error={error} />
}
if (step === 'verify' && status === 'done') return <p>Your address is confirmed. Sign in.</p>
if (step === 'reset' && status === 'done') return <p>Your password is changed. Sign in.</p>
if (step === 'offer' && status === 'idle') return <p>Sign in, or create an account, to take up your invitation.</p>
if (error?.kind === 'invalid' || error?.kind === 'absent') return <p>This link can no longer be used.</p>
return null // a claimed offer: what it holds is listed by useInvitations, below
}verifyfinishes by itself.resetwaits forconfirm({ new_password, code? })—codewhen the account has a second factor — and supplies the token; a password the backend's rule refuses iserror.extensions.reason === 'password_too_short'(withmin_length), and the same link works again.offerwaits until someone is signed in — whatever their account's own address — and then claims by itself.- An offer answers counts,
{ invitations, grants }— invitations now waiting for this account's answer, and entities now shared with it — never names. Both0means it bound nothing. ⛔ A token unknown, revoked or already used is ONE answer,error.kind === 'absent': say one sentence, naming no cause. It is not "no invitation": a member who confirmed the address the offer was sent to holds what it bound already, and its link then answersabsent. - Once the backend has judged the token, it leaves the address bar, so a reload asks
nothing again; a link the backend could not be reached about (
kind: 'unavailable') stays, and works on a reload. However many components use the hook, the link is finished once.dismiss()forgets it unfinished.
Invitations — to join an organization on the site's backend. One sent to a member's
account waits at once (its email is a plain link to your site: they sign in); one sent to
an address with no account here waits once its ?offer= link is claimed, or once someone
signs up with that address and confirms it. useInvitations() lists the ones waiting,
and answers them:
import { useInvitations } from '@uniweb/api'
function Invitations() {
const { status, invitations, accept, decline, answering } = useInvitations()
if (status !== 'ready') return null
return invitations.map((invitation) => (
<p key={invitation.id}>
{invitation.unit_name ?? 'An organization'} invited you.
<button disabled={answering === invitation.id} onClick={() => accept(invitation.id)}>Join</button>
<button disabled={answering === invitation.id} onClick={() => decline(invitation.id)}>Decline</button>
</p>
))
}- An invitation is
{ id, unit_uuid, unit_handle, unit_name, invited_by_account_id, created_at }, as the backend lists it;unit_handleandunit_namemay benull. absent— no backend, or nobody signed in.readywithinvitations: []— none waiting.accept(id)anddecline(id)answer one and reject as the backend does;answeringis the id being answered,answerErrorthe last refusal. After either the list reads again. After an accept, so does everything else the viewer has read, and the session is asked again.- An invitation that no longer waits — answered in another tab, or withdrawn by its
organization — is refused with
kind: 'rule', and the list reads again without it. Itsdetailsays which, for people: branch onkind, never on the words.absentmeans the id is not this member's to answer. - ⭐ Draw it wherever a signed-in member lands, not only after an
?offer=link: an invitation sent to an account comes with no link to follow.
Acting as an administrator — an administrator on the site reads what their role
reaches; to change something through it — remove a member's post, grade a submission —
they confirm it is them, and for a while the session acts through the role.
useElevation() offers it:
import { useElevation } from '@uniweb/api'
function AdminMode() {
const { available, elevated, until, elevate, lower, error } = useElevation()
if (!available) return null
if (elevated) {
return <p>Acting as administrator until {new Date(until).toLocaleTimeString()}. <button onClick={lower}>Stop</button></p>
}
return <ConfirmItsYou onSubmit={(password, code) => elevate({ password, code })} refused={error?.kind === 'auth'} />
}available— the account holds a role that does more than read. Offer it only then.elevate({ password, code })— the account's password again, andcodewhen it has a second factor. Nothing tells you whether it has one, so offer the field to whoever might. A wrong password or code iserror.kind === 'auth', one answer for both.- It lasts as long as the backend decides;
untilsays when it ends.elevatedturns false by itself then, and the session is asked again. Elevating again starts the time again; nothing extends it on its own.lower()ends it early. - While it lasts,
viewer.rolesnames the role, and every record read before is read again —entity.can_editis the record's answer for the session as it is now. rolesInFull— draw controls that act through a role from this: true while elevated, and always on a backend that asks for no elevation.- An account with no password elevates through its provider:
elevateWithProvider('google')goes to the provider's sign-in and back to this page. If the identity that comes back is not this account's,error.extensions.reasonis'oauth_step_up_not_linked'.
Elevating does not open the backend's administration — the Admin link does.
The Admin link — some of the site's members administer its backend, the operator
among them. For them, adminHref is the address that takes them to the backend's
administration, already signed in; for everyone else it is null, so draw the link
only when it is set:
const { adminHref } = useSession()
{adminHref && <a href={adminHref}>Admin</a>}⛔ Navigate to it — a link, or location.assign(adminHref) — never fetch it. It
answers with a redirect carrying a one-time credential that no script on the site is
to hold. The link decides what to offer, not what is allowed: on the site, an
administrator's session acts through their role only while elevated, and administering
the backend happens at the other end of the link.
What a sign-in and a sign-up report
On a site with tracking on, a sign-in reports an engagement named sign_in, including
on the way back from a provider, and a sign-up a conversion named sign_up. Your
foundation adds nothing and sends nothing else: neither carries an account's details. A
sign-up says someone asked, since the backend answers the same for an address already
taken. So don't report these again yourself.
Reading
import { useRecords, useEntity } from '@uniweb/api'
const { status, records, hasMore } = useRecords({ schema: '@/session', scope: 'mine' })
// records[i].brief — the entity's summary; records[i].uuid — its id⭐ absent and an empty ready are different answers. absent means there is no
live source — no backend service, or nobody signed in — so render your site's own content.
ready with records: [] means the service answered and there is nothing there.
Showing "nothing yet" for the first case tells a visitor their content is missing
when it is simply not being asked for.
Whose records. Everyone signed in on the site reads their own entities, what was
given to them (via: 'grant' — see Access to an entry), and what a role of theirs
reaches — the operator's reaches every member's; other members' are private unless the
site's service was set up to let members read each other's. scope: 'mine' lists only
the viewer's own.
A record is a summary: record.brief holds the fields of the Model's brief section. It
carries no items — read the entity for those. hasMore is true when a page came back
full (limit, default 50); matched counts the records in this answer, not a total.
all: true reads the whole list in one request.
const { status, entity } = useEntity({ schema: '@/lesson', uuid, via: course.uuid })
// entity.hydrated.items — the content: { id, section, section_id, data, … } each
// entity.hydrated.entity.brief — the summary
// entity.can_edit — whether this viewer may write to it
const sessions = entity.hydrated.items.filter((item) => item.section === 'sessions')item.section is the section's name, the word a write takes (parent/child for a nested
section). The package resolves it from the Model's definition, so a component never
handles a section id. item.id is what useEntityWriter takes to update, move or remove
the item.
Its absent covers both not-found and not-permitted, on purpose: render your paywall
or sign-in prompt on it and never say "deleted".
Name your foundation's own types @/lesson, as meta.js and queries.yml do. A built
foundation sends one in the scope in its name — @acme/lesson for a foundation named
@acme/marketing, the name register stored the type under — and under uniweb dev it is
sent as written, which is how your mock's seed names it. Name any other type in full: a
standard one, @std/person, or another foundation's. Outside a Uniweb build nothing knows
your foundation's scope, so @/lesson is refused before anything is sent (kind: 'invalid').
A read is in the visitor's language. Each localized field comes back as one value: the
page's language, else the site's default, else any other language the site has — a string
for a text field, one document for a rich text (a json field with format: richtext, which
holds a document per language, { en: doc, fr: doc }). A field held in none of the site's
languages is left out of the answer. An editor reads every language: see Translated
fields, below.
A Model's definition — what a form is built from
import { useDefinition } from '@uniweb/api'
const { status, definition } = useDefinition('@/cv')
const brief = definition?.sections.find((s) => s.brief)
// brief.fields → [{ key: 'title', kind: 'string', required: true, label: 'Title', max_length: 250 }, …]Each field is its declaration as the backend stores it — key, kind, required,
multi, and what the schema declares: label, description, localized, format,
max_length, min, max, and enum. Each section is { name, path, kind, brief,
fields, rules }, plus its label and description; path is the name a write takes,
parent/child for a nested section. Sections come in the order the schema declares them. ⭐
These are the rules every write is checked against, so a form that reads them checks
what the backend will. readDefinition({ schema }) is the same outside React.
In the page's language. Labels and descriptions — the Model's, each section's, each
field's — come in the page's language when the schema translates them (its i18n), and as
the schema writes them otherwise. A page in another language reads its own.
A field's choices
A field that picks from a list — in a data schema, options: naming the section of a lists
type that holds the choices, options: '@/lists/countries' — stores the uuid of the item
chosen (the definition calls it a field of kind item_ref). Its choices are that section's
items:
import { useChoices } from '@uniweb/api'
const { items, hasMore } = useChoices({ schema: '@/cv', field: 'country', q, searchIn: 'label' })
// items[i].uuid — the value to store; items[i].data — the choice, in the site's language
// a dependent list: the provinces of the country chosen above
const provinces = useChoices(country ? { schema: '@/cv', field: 'province', parent: country } : null)- A page at a time —
limit, 50 by default and at most 1000; passnextCursorback ascursor. ⚠️ A searched page can be short while more follow:hasMoreis the end, never the page's length. - A search —
qanywhere in the choices' fieldsearchIn, ignoring case; a localized field in the site's language. - One level at a time —
parentreads the items under one item: the next level of a list whose items nest (each sayshas_children), or a dependent field's choices under the item chosen in the field it depends on. - Whose lists — the items of every entry of the lists type the viewer may read.
- Read back, the field answers
{ item, brief }: the chosen item's uuid and its data.
listChoices is the same outside React, and listSectionItems({ section }) reads a
section by its uuid.
Writing
An entity's content is items, each in a section of its Model. A section that holds
one item (the brief) takes one create and is updated after that; a many-item section
takes as many as you add.
import { useEntityWriter } from '@uniweb/api'
const programme = useEntityWriter({ schema: '@/track', uuid: track.uuid })
await programme.create({ title: 'Keynote' }, { section: 'sessions', position: 'last' })
await programme.update(item.id, { ...item.data, room: 'Hall A' })
await programme.move(item.id, { after: other.id })
await programme.remove(item.id)
await programme.batch([...]) // one transaction: all of them, or noneitem.id is the id of an item on a read. Name a section as the Model does — the
package resolves it to what the backend's item route takes.
- Concurrency is handled. Every write carries the version of the item the viewer last saw — from a read or from the previous write — and the response updates it. You never touch a token.
sectionis required oncreate. An entity has several, and a rule declared on one — insert-only, say — does not reach an item that landed in another.- A successful write refreshes what it changed, so a list you are showing reflects it without a manual reload.
- ⛔ A conflict is reported, never retried.
writer.conflictis set when someone else changed the item first. A retry would succeed, by overwriting a change nobody looked at — so what happens next is your application's decision, and usually it is to tell the person. - A write the Model's rules refuse — an insert-only section, a second item of a one-item
section under one parent — is an error of
kind: 'rule', not a conflict: nobody else is involved, and the same write fails the same way again. A one-item section under a list holds one item for each of the list's: one thesis per degree.
⚠️ update replaces the item's data whole. Spread what you are not editing. A translated
field is saved one language at a time — Translated fields, below.
To create an entity, name each item's section:
import { createEntity } from '@uniweb/api/client'
await createEntity({ schema: '@/course', items: [{ section: 'course', data: { title: 'Intro' } }] })An entry and its nested items in one create. Give an item a uuid — uuidv7() mints
one — and a later item of the same create names it as its parent: a uuid names an item by
its uuid, a number by its id. Items are created in the order given, so a parent comes first.
import { createEntity, uuidv7 } from '@uniweb/api/client'
const degree = uuidv7()
await createEntity({
schema: '@/cv',
items: [
{ section: 'identity', data: { name: 'Ada' } },
{ section: 'degrees', uuid: degree, data: { title: 'PhD' } },
{ section: 'degrees/thesis', parent: degree, data: { topic: 'Engines' } },
],
})The uuids serve that one write: the answer is the entry, and no read carries an item's uuid
— a read names items by id, and an item's parent_item_id holds the hierarchy. A writer's
create takes uuid and parent the same way, so a batch can add an item and the items
under it at once.
Where it lands. Everything a member creates lands at the root of the organization the site's backend is founded with. A solution that uses units — a department, a program, a course — lands an entity in one by naming it:
import { useUnits, createEntity } from '@uniweb/api'
const { units } = useUnits() // the units the viewer is placed in
const course = units.find((u) => u.handle === 'intro-101')
await createEntity({ schema: '@/submission', unit: course.unit, items: [...] })- A unit is
{ unit, id, handle?, name?, parent?, depth, primary }:unitis the uuid a create names,idis what an entity'sunit_idcarries,depthis 1 at the root. - A create may land in a unit the viewer is placed in, or — while their session is
elevated — one where a role of theirs edits. Any other is
absent, the same answer as a unit that does not exist. - Landing changes where the entity is, not who may open it: that is still the organization's rules — what its units' members may read, and the roles it gives.
Errors
Every failure is an ApiError with a kind: auth (sign in), unverified, absent,
forbidden (error.extensions.op names what was refused), invalid
(error.extensions.field names a field the Model refused), conflict, rule,
rate-limited, unavailable, disabled. Branch on kind; detail is prose.
Translated fields
A field the Model declares localized holds a map of languages, { en: 'Hello', fr: 'Bonjour' },
or one plain value, which is the value in every language; a rich text holds one document per
language. A read answers it in the page's language — so that one value, written back as the
field's whole value, would become the value in every language, and the others would be lost.
Read every language to edit. languages: 'all' reads each translated field as stored. It is a
cache entry of its own, beside the read in the page's language:
const { entity } = useEntity({ schema: '@/profile', uuid, languages: 'all' })
// entity.hydrated.items[0].data.bio → { en: '…', fr: '…' }, or one plain valueSave one language. update with { language } takes each translated field you name as its value
in that language, merges it into the languages the item holds, and keeps every language of a
translated field you leave out:
await writer.update(item.id, { ...item.data, bio: 'Bonjour' }, { language: 'fr' })- It reads the item again for its other languages, and writes it guarded by the version you were
shown: an item someone changed since is a
conflict, never a merge into their change. - A plain value gains a language only when a second one is added, and only you know which language
it was written in: the site's default language, unless you name another —
{ language, plain }. - ⛔ On a site with more than one language, an update without
{ language }that gives a translated field one plain value is refused (kind: 'invalid',title: 'Read In One Language') when the item was read in the page's language: it would replace every language. Pass{ language }, or read withlanguages: 'all'and write every language.
Helpers, for a form that edits the map itself — pure functions, for a localized field's value
only (a json field's { lat, lng } is not a map of languages, and they cannot tell):
| | |
|---|---|
| languagesOf(value) | the languages it holds, its @ metadata aside — [] for a plain value, which is every language |
| inLanguage(value, languages?) | its value as a read in a language answers it — by default the one on this page; a regional language reads its base language after it (fr-CA, then fr); undefined when it holds none |
| withLanguage(value, language, next, { plain? }) | the value with one language set, the others and its @ metadata kept; next undefined takes the language out; a plain value needs plain, the language it is written in |
Access to an entry
Whoever administers an entry — its owner, someone given admin on it, or a role that
reaches it while the session is elevated — can give other people access to it: read,
edit or admin. An instructor enrols students in a course this way, or adds a
co-instructor; what you call it — "enrolled", "course staff", "shared with you" — is yours.
An entry someone was given lists for them with via: 'grant'.
import { useAccess } from '@uniweb/api'
function Roster({ course }) {
const { status, grants, grant, revoke, working } = useAccess({ schema: '@/course', uuid: course.uuid })
if (status !== 'ready') return null // 'forbidden': not this viewer's to give
return (
<>
{grants.map((g) => (
<p key={g.account_id}>
{g.account?.handle} — {g.permission}
<button disabled={working} onClick={() => revoke(g)}>Remove</button>
</p>
))}
<EnrolByHandle onSubmit={(handle) => grant({ handle }, 'read')} />
</>
)
}status—readyfor whoever administers the entry:owner,grants(each withaccount, the person, andpermission) andpending.forbiddenfor everyone else who can read it — the ordinary answer, so draw nothing.absentfor no backend, nobody signed in, or an entry the viewer cannot read.- Naming a person. By account id — a number, or anything carrying
account_id: a candidate, a grant'saccount, an entity'sowner_id— or by{ handle },{ uuid }(an account's, asviewer.uuid) or{ email }. An address nobody holds gets an offer by mail, which waits inpending— but an organization's entry is given only to its members, so on a site's backend an address with no account is refused. - Many at once.
grant([id, id, …], 'read')gives a list of people, by account id, in one call, and answers{ results: [{ account, ok, error? }] }— each person on their own, so one refused leaves the rest given.revoke([…])takes a list back. - Giving again replaces.
readaftereditlowers what the person had. Taking back what was never given answers the same as taking it back. listAccessCandidates({ schema, uuid })lists the people an entry may be given to: the members of its organization.- Giving it up. Someone given access can give it up:
leaveAccess({ schema, uuid }). When nothing is theirs to give up — they own it, or reach it through their organization — it is refused withkind: 'rule', anderror.extensions.reasonsays which. - A type made from a data schema can be given unless its schema says
grantable: false; one that cannot is refusedforbidden,error.extensions.reason: 'model_not_grantable'.
Outside React: grantAccess, revokeAccess, listAccess, listAccessCandidates and
leaveAccess from @uniweb/api/client, each taking { schema, uuid, … }.
Rules, lists, and what stays in your foundation
The backend checks every write against the Model's declaration, and refuses to register a schema with a declaration it does not take. So where a rule is written decides who keeps it.
On a field — checked on every write
| declare | the backend |
|---|---|
| required: true | refuses a write without the field |
| max_length on string, text | counts characters — each language of a localized value on its own |
| min, max on int, decimal | refuses a value outside them |
| format — email, url on string and text; currency on string; markdown on text | checks email, url and currency (an ISO 4217 code); markdown marks what the text is. A url is http:// or https://: one written without a scheme that starts with a host name, example.com/cv, is stored as https://example.com/cv, and another scheme (ftp:, mailto:) or a path (/about) is refused — a backend from before 2026-10-15 takes any scheme://… and refuses example.com. It is also one a browser can open: a port above 65535, an unclosed [, a character no host may hold or an empty host is refused, by the mock as by a backend since 2026-10-17 |
| pattern on string | refuses a value the regular expression does not match whole — '[A-Z]{2}[0-9]{4}' is a code like AB1234, not one somewhere in the value; a url is matched as stored. A backend from before 2026-10-15 refuses the declaration |
| string and text — translatable unless translatable: false | holds one string for every language, or one per language (localized in the definition) |
| format: year-month or format: year on date | holds a month, '2024-05', or a year, '2024' — ISO 8601's reduced precision, stored as written, and always text: a year written as the number 2024 is refused (a backend from before 2026-10-14 refuses the declaration). With no format, a date holds a day, 2024-05-14 — written short, 2024-5-3, it is stored in full |
| mime_types, max_size on file | keeps an upload to those types ('image/*') and that many bytes |
| enum: [...] | keeps the field to those values — in the definition, as its section's one_of rule |
| options: '@/lists/countries' | keeps the value to an item of that section — an item of another is 409, kind: 'rule' |
A refused value is kind: 'invalid', its extensions naming the field (data.title,
data.label.fr for one language), the rule it broke and the bound
({ rule: 'max_length', max_length: 250 }, { rule: 'format', format: 'year-month' },
{ rule: 'pattern', pattern: '[A-Z]{2}[0-9]{4}' }). An
enum's refusal, rule: 'one_of', names its field too — a backend from before 2026-10-14
names it only in detail.
⛔ A declaration the backend does not take stops your build first, naming the field and
the key — a format its kind does not take (month-day on a date, year on an int), a
key a data schema does not have (maxLength — it is max_length), one the backend does not
check yet (min_length), a field's help (write it as its description), or a pattern in
syntax a backend reads differently from JavaScript — look-around, a back-reference, a named
group, a flag such as (?i), a \p{…} class. uniweb validate reports the same. A backend
refuses the whole schema for any of them.
On a section — rules across fields, and across items
A section's constraints — a list of rules, in its definition as rules. In a schema
written with fields:, they sit beside fields: and rule its one section; a rule on one
field's own declaration is refused, since it names the section's fields:
| rule | keeps |
|---|---|
| { kind: 'exclusive', fields: [...] } | at most one of the fields set — "another organization" beside a picked one |
| { kind: 'required_one_of', fields: [...] } | at least one of them set |
| { kind: 'required_when', field, when: { field, equals } } | the field set — not absent, null or '' — whenever another field equals a value |
| { kind: 'at_most_one_where', field, equals } | at most one of an entry's items in the section holding the value: one primary address |
| { kind: 'field_order', before, after } | one date no later than another |
| { kind: 'unique_field', field }, { kind: 'unique_combination', fields } | a value — or values together — once among an entry's items in the section |
| { kind: 'max_items', value }, { kind: 'min_items', value } | how many items the section holds — min_items refuses a delete below it, and does not make anyone add one |
| { kind: 'show_when', field, when: { field, equals } } | nothing: when a form shows a field — read it from the definition |
Shared lists
Put the lists your fields pick from in one lists type, a section per list — not a type per list — and its entries where the people who should see them can read them. Then:
- A field that picks from one names its section in
options:('@/lists/countries', a nested one by its path,'@/lists/countries/subdivisions'), anduseChoicesoffers it. A lists type with one list may be named alone ('@/countries'); with several, the build asks which. A field cannot pick from a section of its own type. - A list too long for a dropdown is searched:
qwithsearchIn, a page at a time. - A dependent list — a country, then one of its provinces — is a section nested in
the other:
countriesholdingsubdivisions, a field picking from each. The second field's choices areuseChoices({ …, parent: country }), and it holds only a province. - A list in levels — a discipline, then its branches — is a section whose items nest
(
self_nesting: true): read it a level at a time withparent. - A list people may add to is one they may edit: who may write an entry's items is
who may edit the entry. They add a choice with
useEntityWriteron the lists entry, andrefresh()reads the choices again.
What stays in your foundation
The backend keeps content and checks it. How a field is edited is your foundation's — the toolbar of a rich text field, a default for a new entry, a field shown read-only, an autocomplete, a value computed from others (a currency converted, shares summed to 100 before an entry is complete). None of these is a declaration the backend keeps.
⭐ Who may change a field is who may edit its entry — the backend decides access per entry, never per field. A field only some people may change belongs on an entry only they edit.
The service on your machine
Building against a live service is slow and puts a shared database behind your
experiments. Name a local one in site.yml:
services:
backend: true # ask your host for it, on a site you publish
$devBackend: ./mock/api.js # what answers it in `uniweb dev`; never published// mock/api.js
import { createMockBackend } from '@uniweb/api/mock'
export default createMockBackend({
seed: {
accounts: [
{ username: 'me', password: 'me', operator: true }, // runs the site, and gets the Admin link
{ username: 'member', password: 'member' },
],
schemas: {
'@/track': {
sections: {
track: { kind: 'single', brief: true, fields: { name: { type: 'string', required: true } } },
sessions: { kind: 'multi', fields: { title: { type: 'string', required: true } } },
},
},
},
entities: [
{
uuid: '01926d5e-0000-7000-8000-000000000001', // a UUID, as on the wire
model: '@/track',
items: [{ section: 'track', data: { name: 'Main hall' } }],
},
],
},
}).fetchuniweb dev mounts it at an address of its own on the dev server, and hands your site that
address the way a host hands over its own — same origin, so cookies behave as they will in
production, and nothing in your site's configuration changes between the two.
⭐ The mock answers what the backend answers — the same statuses, bodies and
refusals, for every route this package uses. It enforces what your schemas declare —
which sections are insert-only, the fields a section takes and their rules (required,
max_length, min and max, a date's form, an enum, the email and url formats, a
pattern, a choice from a list), a section's required_when and at_most_one_where, one
item of a one-item section per parent, and the uuids a create gives its items. It keeps a day
and a url as the backend stores them, answers a definition in the page's language from a
seed schema's i18n, and it is as strict as the backend about requests, so a mistake fails
here rather than in front of a user.
Everyone signed in reads and writes their own entities, and a member nothing of anyone
else's (memberFloor: 'read' in the seed models a service set up to let members read
each other's — or per Model, memberFloor: { '@/course': 'read' }, so the
operator's content is shared while each member's own records stay private). The
operator, as on a real site, reads every member's entities and changes them only once
elevated, with its password, for fifteen minutes. The mail the backend would send — a sign-up's
confirmation, a password reset — is printed where the mock runs, in the terminal of
uniweb dev, its link on your site's root as the backend's is, so following it is
trying your page; the messages are in mock.outbox too. To try an invitation, seed one:
offers: [{ email: '[email protected]', invitations: 1 }] is mailed when the mock starts,
and claiming it — or signing up with that address and confirming it — leaves the
invitation waiting. An offer to an address a seeded account holds waits for that account
from the start.
The operator's Admin link answers too, landing on a page of the mock's own — the backend's
administration is not part of it. State is in memory: restart to reset.
Whoever administers an entry gives people access to it and takes it back, as on the
backend, and deleting an entry takes admin on it. A seeded entity can start with access
given — grants: { member: 'read' } — and with units in the seed, a create can land in one:
units: [
{ handle: 'science', name: 'Science' },
{ handle: 'intro-101', name: 'Intro', parent: 'science', members: ['me', 'member'], floor: { '@/lesson': 'read' } },
],
entities: [{ model: '@/lesson', owner: 'me', unit: 'intro-101', items: [...] }],members are placed in the unit, and its floor — as memberFloor, which is the root's —
covers what lands in it, for them and for those placed in the units below it. The mock
models no roles but the operator's: no reader or editor held at a unit, and nothing a
reference opens.
Name a seed's types as your foundation's calls send them under uniweb dev: your own as
@/track, any other in full — the mock matches a name exactly, so @std/person is not
@/person. Real-backend testing is a job for a built foundation, which sends your own types in
full. Write the schemas as you write them for a backend — type: 'int', multiple: true,
grantable: false for a type whose entries are never shared, options: — and the mock's definition answers what a backend keeps of them, so a key no
backend takes is left out of it (a backend refuses it when you register). Seed a list's
choices as items of a lists entry, each item holding the items that sit under it:
{ section: 'countries', uuid: '01926d5e-0000-7000-8000-0000000c0001', data: { code: 'CA', label: { en: 'Canada' } }, items: [
{ section: 'countries/subdivisions', data: { name: { en: 'Ontario' } } },
] }An item's uuid is new each time the mock starts unless the seed gives one, as Canada has
above: a UUID no other item has. That is how a seeded entry picks a choice — country:
'01926d5e-0000-7000-8000-0000000c0001' in a seeded CV reads back as Canada.
A field whose options: names no section of a seeded Model is reported when the mock
starts, and offers no choices — a backend refuses to register it.
There is also a standalone server, for a frontend that is not a Uniweb site — --site
names that frontend's origin, where the mail's links land:
npx uniweb-api-mock --port 8787 --site http://localhost:3000Outside React
@uniweb/api/client carries the same operations as plain functions and imports no
React: probeSession, signIn, listEntities, readEntity, writeItems,
createEntity, deleteEntity, adminHref, for elevation, elevate, lower,
elevateWithProvider, isElevated and rolesInFull, for the links the backend mails,
mailedLink, verifyEmail, claimOffer and forgetMailedLink, for invitations,
listInvitations, acceptInvitation and declineInvitation, for units, listUnits, and for
access to an entry, listAccess, listAccessCandidates, grantAccess, revokeAccess and
leaveAccess, and for translated fields, languagesOf, inLanguage and withLanguage —
readEntity({ …, languages: 'all' }) reads every language.
License
Apache-2.0
