Documentation menu

Documentation / Foundation / practice

Practice

Package
@wabbit/tome-practice
Version
v0.2.0
npm install @wabbit/tome-practice
First install from our registry?
  1. Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
FoundationPreview

Tome practice layer — who works at a business, what it offers and what people come looking for, as linked records: people, offerings, topics and groups with a vocabulary global, a server read API that filters both sides of every link and computes review state, availability and credential expiry at read time, and an isomorphic filters entry.

Overview

Who works at a business, what it offers, and what people come looking for, as linked records. A practice-people document is one person. A practice-offerings document is one thing the business does. A practice-topics document is one thing a visitor searches for by name. Each links to the others, so a person's page lists what they offer, an offering's page lists who does it, and a topic's page says which offering answers it and who to see.

It is vertical-neutral. A law firm's attorneys, practice areas and questions; a trade's crew, services and problems; a studio's coaches, programs and goals; a surgery's staff, treatments and complaints all fit the same shapes. The site names things through its settings ("Attorney", "Practice area"), and nothing in this package's code, defaults, options or comments names a trade.

Peer dependencies

PeerRangeOptional?
@wabbit/tome-core>=1.32.0 <2.0.0no — the layer registry, the capability check, the field-shape vocabulary, the shared unstable_cache loader and the safeHref link rule.
payload>=3.67.0no — the collections, the global and their hooks are Payload config. The site needs an upload collection for photos (media by default) and a rich-text editor.
next>=15.0.0yes — @wabbit/tome-practice/server caches reads with Next's unstable_cache, and saves revalidate through next/cache. Without Next, reads go straight to Payload and revalidation is skipped.

server-only is a runtime dependency, imported by @wabbit/tome-practice/server alone.

Module surface

EntrySafe in a client bundleWhat it carries
@wabbit/tome-practiceno (Payload config)createPracticeLayer, the four collection factories and the settings global factory, the access helpers, slugs, markers, cache tags, the capability name, the rule validators, and the @wabbit/tome-practice/filters exports
@wabbit/tome-practice/typesyes (types only)the entry, record, group-index, settings and filter shapes
@wabbit/tome-practice/settingsyesmergePracticeSettings, PRACTICE_DEFAULT_SETTINGS, normalizePracticeBasePath
@wabbit/tome-practice/filtersyesparsePracticeFilter, serializePracticeFilter, matchesPracticeQuery, practiceKey, and the date arithmetic a page computes when it is read (isPastExpiry, reviewStateOf, addMonths, daysUntil)
@wabbit/tome-practice/serverno (server-only)listPracticePeople, getPracticePerson, listPracticeOfferings, getPracticeOffering, listPracticeTopics, getPracticeTopic, getPracticeGroupIndex, getPracticeSettings, the cache tags and revalidatePractice

Server/client posture: @wabbit/tome-practice/filters, @wabbit/tome-practice/types and @wabbit/tome-practice/settings import no payload, server-only, React, Node module, date library or other Tome package, so they run in a server component, a Payload field hook and a client island alike. A render pack imports those three, never the layer root, which registers collections and pulls in Payload.

Setting it up

  1. Register the layer in payload.config.ts:
   import { createPracticeLayer } from '@wabbit/tome-practice'

   const practice = createPracticeLayer({ adminGroup: 'Practice' })

   export default buildConfig({
     // ...
     collections: [...otherCollections, ...practice.collections],
     globals: [...otherGlobals, ...practice.globals], // or plugins: [practice.plugin]
   })

Then regenerate types and, on a SQL database, generate and run a migration as for any new collection. The package ships no migration; the consumer owns it.

  1. Name things. Under Practice → Practice settings set what the site calls a person, an offering and a topic, where each lives (/people, /offerings, /topics by default), and the four availability words and the word for an offering's duration line (durationLabel, "Duration" by default).
  1. Add groups, then offerings, people and topics. Each link is stored once, by its owner: an offering holds its people (the first is the lead), a topic holds its offerings and any direct people. A person's offerings and topics are read-only views.
  1. Read it. In a server component or route handler:
   import { getPracticeGroupIndex, getPracticePerson, getPracticeTopic, listPracticePeople } from '@wabbit/tome-practice/server'
   import { parsePracticeFilter } from '@wabbit/tome-practice/filters'

   const filter = parsePracticeFilter(searchParams)
   const people = await listPracticePeople(payload, { topic: filter.topic, availability: filter.availability, limit: 12 })
   const person = await getPracticePerson(payload, { slug })
   const topic = await getPracticeTopic(payload, { slug })
   const picker = await getPracticeGroupIndex(payload)

Collections and global

KindSlug (default)dbNamePurpose
Collectionpractice-peoplepr_peopleOne person who works here. Drafts on, autosave off.
Collectionpractice-offeringspr_offeringsOne thing the business does. Owns the person link. Drafts on.
Collectionpractice-topicspr_topicsOne thing a visitor looks for by name. Owns the offering link and any direct person link. Drafts on.
Collectionpractice-groupspr_groupsThe site's grouping for offerings and topics, kept apart from blog categories.
Globalpractice-settingspr_settingsVocabulary, base paths, availability words, the review interval and its strings, accepted plans, sample labels.

The practice- prefix is reserved for this layer. people, services and topics are never defaults: registerLayer refuses a collision at boot rather than letting Payload merge two collections.

People

name, slug, honorific, postNominals, roleLine, summary, fact, agesSeen, photo with photoAlt and photoLabel, bio (or layout when layoutBlocks is passed), languages, order, archived, sample. Plus:

  • credentials: kind, label, issuer, number, showNumber (off by default), jurisdiction, validFrom, expiresOn, verifyUrl, verifyLabel, sample. A credential past expiresOn stops being returned. number is removed from every public read, and from the cache, unless showNumber is on.
  • availability: state (open, limited, waitlist, closed) and expiresOn, required whenever a state is set. Past the expiry day nothing shows. The admin shows how many days remain and warns inside 14.
  • booking: label, href, newTab. A link-out, not a booking engine. Saved only as http(s), mailto, tel or a path.
  • locations and officeDays: present only when the site passes locationsSlug.
  • groups: optional relationship to practice-groups (a department, a practice group). A roster scopes by it with the group filter. An entry carries groups only when the person has any.
  • offerings, topics: read-only joins.

Offerings

title, slug, group, summary, image with imageAlt and imageLabel, body (or layout), people (ordered), duration, price, booking, noticeLine, review, order, archived, sample, and the read-only topics join. review has the same fields, rules and computed state as a topic's, byline included; an entry carries review only when the record has a stamp or a byline. The first person listed is the responsible one, by convention: it is the lead a page names first and the person a topic's single path picks. price is mode (fixed, from, range, market, request), amount, amountMax and note, in integer minor units (1700 is 17.00), the modes and units local-price-list uses. A site with no price leaves mode empty and nothing shows. A fixed, from or range price requires an amount, and a save without one is refused; a price that is quoted later is request, not a fixed price with no amount.

Topics

title, slug, listLabel (the short wording a list row uses; falls back to title), aliases (other names people search for; matched by list filtering, never printed as keywords), group, summary, image with imageAlt and imageLabel, body (or layout), offerings (ordered; the first leads), people (direct), review, order, archived, sample. review is reviewedBy, reviewedOn and byline; the first two are saved together or not at all, and a review cannot be dated in the future.

Groups

title, key, descriptor, order, appliesTo (offerings, topics, or both), and leadPeople, an ordered list naming who a visitor is likely to see for the group. Deleting a group that records still use is refused.

The factory

createPracticeLayer(config) returns { collections, globals, plugin, slugs }. Its config speaks tome-core's LayerFactoryConfig vocabulary (adminGroup; access and hooks appended and merged one key at a time; extraFields, fieldOverrides, omitFields and fieldOrder keyed by final slug; staff).

OptionDefaultPurpose
slugspractice-people, practice-offerings, practice-topics, practice-groups, practice-settingsRename any owned slug. The five must be distinct.
mediaSlugmediaThe upload collection for photos.
layoutBlocksnoneBlocks for a layout field that replaces each record's rich-text body.
locationsSlugnoneThe site's own offices collection. When passed, people gain locations and officeDays.
editCapabilitypractice:edit-recordsThe capability that may edit every record.
grantRoles['admin', 'editor']Roles granted the capability at startup. false grants nothing.
adminGroupPracticeAdmin sidebar group.
dbNamespr_people, pr_offerings, pr_topics, pr_groups, pr_settingsdbName overrides.
settingstrueBuild the settings global. false uses the neutral defaults everywhere.
requirePhotofalseA photo is required to publish a person.
credentialKindslicense, insurance, bond, certification, membership, admissionThe values of a credential's kind.
hrefFor${basePath}/${slug}({ kind, id, slug, sample, basePath, defaultHref }) => string: the address of a record in every entry.href. Keeps a demo record out of public paths. A hook that throws or returns nothing usable falls back to the default.

It registers the layer with tome-core's registry and grants the capability, each wrapped so a repeat evaluation under hot reload is harmless. A slug collision with another layer is the one failure that is never swallowed.

Access

Slugreadcreate, update, deletereadVersions
practice-people, practice-offerings, practice-topicseditors: everything; everyone else: published and not archived, as a where-constraint, so REST and GraphQL never serve a draft or an archived recordpractice:edit-recordspractice:edit-records
practice-groupsopenpractice:edit-recordsnot applicable
practice-settingsopenupdate: practice:edit-recordsnot applicable

The check is tome-core's sessionHasCapabilityOrLegacyAdmin. It is never bare authenticated: on a site with public signup, that is any stranger. A signed-in account with no staff role can read what a visitor can and nothing more. A credential's number has its own field-level read rule: staff always, everyone else only when that row sets showNumber.

The read API

Every function takes the Payload instance and optional { req, draft, now, noCache }. Reads go through Payload's local API with access control applied (overrideAccess: false and the caller's req).

FunctionReturns
listPracticePeople(payload, { offering, topic, location, availability, language, group, includeCredentials, limit })PracticePersonEntry[] by order then name; filtered by an offering, the lead first, or by a topic, or by a group's key. With includeCredentials: true each entry also carries its current credentials, from the same batch (no per-person read), under the same rules as a person page: expired credentials are dropped and a number appears only when its row sets showNumber.
getPracticePerson(payload, { slug } or { id }){ person, credentials, offerings, topics }, or null.
listPracticeOfferings(payload, { person, group, limit })PracticeOfferingEntry[], each carrying the offices of its people.
getPracticeOffering(payload, { slug } or { id }){ offering, people, topics }, or null.
listPracticeTopics(payload, { person, offering, group, q, limit })PracticeTopicEntry[]; q matches the title, the row label and the aliases.
getPracticeTopic(payload, { slug } or { id }){ topic, offerings, people, match }, or null.
getPracticeGroupIndex(payload)The need picker: each group that applies to topics and has a published topic, with its topics and one lead person.
getPracticeSettings(payload)The settings merged over the neutral defaults, key by key.

Every public read filters to published, unarchived records in the query, on both sides of a link. A draft or archived person never appears on a published offering, and a draft offering never appears on a published person. Links are stored as ids and resolved in memory against the visible sets only, so an id that points at something not visible simply resolves to nothing. Archiving a record hides it everywhere and keeps its links; publishing it back restores them.

  • A topic's people are the union of its direct people and the people on its offerings, de-duplicated: direct people first, in stored order, then the rest by order.
  • A person's topics are the same relationship read from the other side.
  • match on a topic is the single path: its first offering, then that offering's first person, otherwise its first direct person.
  • The group index lead is the first of the group's leadPeople who is visible, otherwise the first person by order on the group's topics, otherwise absent.
  • A topic's review reads as none when its reviewer is not visible: a stamp nobody can stand behind is not shown.

Computed at read, never stored

Review state (none, current, aged), an expired availability and an expired credential are decided on every read from the raw dates, against now (UTC days). The cache holds the dates, not the verdicts, so a cached row cannot outlive its expiry. Availability stored with no expiry day is not shown.

Batched

One read makes a fixed number of queries however many rows there are: the settings, one query each for people, offerings, topics and groups, one for the pictures and one for the offices. Several reads on one req share a single load of the records.

Caching

Only visitor reads (no signed-in user, no draft) are cached, under collection:practice-people, collection:practice-offerings, collection:practice-topics, collection:practice-groups and global:practice-settings. A save revalidates its collection, its own document tag and the tags of the records it links; a person's save also revalidates the offering and topic lists, because a rename changes every page they appear on.

Limits

Not in this layer: booking, scheduling slots and availability calendars (a booking is a label and a link), structured-data output, payer or plan filtering (acceptedPlans is editable text), reviews and testimonials, and any field about the people a business serves. Join fields (the reverse views) have been exercised on SQLite with drafts on; they have not been run against Postgres.

  • CHANGELOG

Exports

Import path
@wabbit/tome-practice
@wabbit/tome-practice/types
@wabbit/tome-practice/settings
@wabbit/tome-practice/filters
@wabbit/tome-practice/server

Changelog

0.2.xMinor

  • Four additive changes; a site that uses none of them sees no difference.

    listPracticePeople takes includeCredentials (each entry also carries its current credentials from the same batch, so a roster makes no per-person read) and group (only people in that group). People gain an optional groups relationship.

    Offerings gain an optional review with the same shape, byline and computed state as a topic's. Settings gain durationLabel, default "Duration".

    The README now says that a fixed, from or range price requires an amount, that a price quoted later is request, and that the first listed person is the responsible one by convention.

0.1.xMinor

  • New @wabbit/tome-practice package: who works at a business, what it offers and what people come looking for, as linked records.

    Free, foundation family.

    createPracticeLayer adds practice-people, practice-offerings, practice-topics and practice-groups collections and a practice-settings global. The site names things through the settings, so nothing in the package names a trade.

    Each link is stored once, by its owner (an offering holds its people; a topic holds its offerings and any direct people); the reverse views are read-only joins. Writes need practice:edit-records, never bare authenticated.

    The @wabbit/tome-practice/server read API (listPracticePeople, getPracticePerson, listPracticeOfferings, getPracticeOffering, listPracticeTopics, getPracticeTopic, getPracticeGroupIndex, getPracticeSettings) filters to published, unarchived records on both sides of every link, in a fixed number of queries however many rows there are. Review state, expired availability and expired credentials are decided at read time and never stored, so a cached row cannot outlive its expiry.

    A credential number reaches no public read unless its row sets showNumber. The ./filters entry parses and serializes list filters for server and browser alike.

    Requires a @wabbit/tome-core release that exports @wabbit/tome-core/utilities/safeHref. The package ships no migration; the consumer owns it.

Full changelog across all packages