tunablestunables version 0.2.0

Concepts

Settings and groups

A setting is a value someone tunes while the app runs: a limit, an interval, a budget, a switch. A group is a set of settings edited together, on one form, beside what they affect. Every setting belongs to exactly one group. A group's values are stored as one row, with one more for each workspace or person that sets its own.

Declaring a group

import { defineEffects, defineGroup, number, range, toggle } from 'tunables'

export const effects = defineEffects({
  members: { label: 'What everyone in the workspace sees', icon: 'i-lucide-users' },
  cost: { label: 'AI cost', warn: true, icon: 'i-lucide-coins' }
})

export const limits = defineGroup({
  id: 'limits',
  title: 'Limits',
  description: 'How much a workspace may hold and use.',
  place: { label: 'Admin › Limits', href: '/admin/limits' },
  fields: {
    maxProjects: number({
      label: 'Projects',
      help: 'The most projects a workspace may have open at once.',
      unit: 'count',
      default: 3,
      section: 'What a workspace holds',
      affects: ['members']
    }),
    titleLength: range({
      label: 'Task title length',
      help: 'How short and how long a task\'s title may be.',
      unit: 'characters',
      default: { min: 1, max: 200 },
      section: 'What a workspace holds',
      affects: ['members']
    }),
    aiSuggestions: toggle({
      label: 'AI suggestions',
      help: 'Whether people may ask for suggested tasks.',
      default: true,
      section: 'AI',
      affects: ['members', 'cost']
    })
  }
})

A group says:

OptionWhat it is
idThe group's stable name, used to read and store it.
title, descriptionHow it reads on its form and in All settings.
fieldsIts settings, by key. Keys are the names your code reads.
placeWhere it is edited in your app, so All settings can link there.
entityThe pdd-kit Product Model Entity its fields are facts of. See Explanations.
rulesChecks between its own fields. See Checks and guards.
confirmA question to answer before a costly change is saved.

What a field says

Every kind of field takes the same information about itself:

OptionWhat it is
defaultIts value until someone changes it. The one home of the default.
labelThe name a person reads. Defaults to fact, then to the key.
helpAn explanation in the product's words, shown beside the label.
factThe Product Model fact the field is. Its explanation comes from there.
sectionThe sub-heading it is listed under within its group.
affectsWhat changing it changes, by effect id.
overrideWhere a workspace or a person may set its own value, and how far. See Scopes.

Each kind adds its own options, such as a number's unit and bounds; see Kinds and units. createTunables refuses a definition whose default does not pass its own check, whose unit or effect is not declared, or whose pick names a catalog that does not exist. A mistake in a definition fails at start, in words, not on someone's first save.

Effects

affects names what changing a field changes, by the ids you declare with defineEffects. Each effect has a label, an optional Iconify icon, and warn for a change that costs money or reaches people. The screens show the effects under each field, and All settings can filter by them.

Sections

section groups a form's fields under sub-headings, in the order their first field appears. Fields without one are listed first, under General.

Reading values

createTunables takes your groups and returns typed reads:

const tunables = createTunables({ store, groups: [limits], effects })

const { maxProjects, titleLength } = await tunables.get('limits')
// maxProjects: number
// titleLength: { min: number, max: number }
  • Read at the point of use, every time. A read is one query. A value captured at boot needs a restart to change.
  • Never write a fallback. get returns every field or throws: a ?? 3 beside it would be a second home for the default.
  • get throws TunablesMissing when the group, or one of its fields, has not been stored yet: run tunables sync.
  • It throws TunablesInvalid when a stored value no longer passes its check, for example after its bounds were tightened.
  • A long-running worker can pass { lastGood: true }: when a read fails, it returns the last values this process read or saved, if it has any.
  • With scopes, pass { scope: { workspace: 'acme', user: 'dana' } } to read the values as that workspace and person get them, through their plan and their own overrides. See Scopes.

To react to changes made in this process, subscribe:

const stop = tunables.onChange('limits', (values) => {
  suggestions.setEnabled(values.aiSuggestions)
})

The listener runs after a change to the group's system values commits. Other processes learn of it on their next read.

The entry

tunables.entry(id) returns what a form needs: the stored values, the defaults, which keys are changed from their default, each value and default as it reads to a person (display), notes from your notes provider, and who changed the group last and when.

const entry = await tunables.entry('limits')
entry.display.values.titleLength // "1–200 characters"
entry.changed // ["maxProjects"] once it differs from its default

Saving values

A save takes the fields that change, merges them over the stored values, and checks the whole group before anything is written:

const outcome = await tunables.save('limits', { maxProjects: 5 }, {
  actor: { id: user.id, name: user.name },
  reason: 'More room on the free tier'
})

if (!outcome.saved) {
  for (const problem of outcome.problems) console.log(problem.key, problem.message)
}

The outcome says what the change came to. Nothing is stored unless saved is true.

FieldWhat it holds
savedWhether the change was stored.
problemsEach problem in words, with the key of the field it is about when there is one.
confirmSet when the change must be confirmed: send it again with confirmed: true.
revisionThe revision recorded. Absent when nothing changed.
valueThe group's entry after the save.

The change options are the same everywhere:

OptionWhat it is
actorWho made the change: a name, or { id, name }. Stored as the name.
reasonWhy, kept on the revision.
confirmedThe change was sent again after it asked to be confirmed.
hostYour open transaction or request context, handed to guards and notes.
scopeWhere the change is made, such as { workspace: 'acme' }. Absent: the whole system. See Scopes.

reset puts fields back at their defaults through the same checks:

await tunables.reset('limits', ['maxProjects'], { actor: 'Ada' })
await tunables.reset('limits') // every field

Explanations

A field explains itself beside its label: the question mark on the form, the help in All settings. When the field names a fact and its group names an entity, the explanation is the Product Model's own description of that fact; otherwise it is the field's help. With a pdd-kit Product Model, write fact instead of help, run tunables explain and pass the module it writes to createTunables as explanations. tunables check reports a field that carries help for a fact the model describes, and a fact on a group that names no entity. See Check and generate.

Where a group goes

Put settings that bound one another in one group, because a group's rules see only its own values. Otherwise group by what the settings govern and who tunes them, and give each group a place beside what it affects: limits on the admin's Limits page, how a workspace works on the workspace's own settings page. All settings lists every group in one place, read-only, with a link to each.