tunablestunables version 0.2.0

Get started

What it is for

tunables keeps the values someone decides while the app runs. Most apps have the same handful of kinds of them. This page names each, with a short example from Tasklane, a todo app with workspaces and the people in them, and the feature that does it.

Limits staff tune live

How many projects a workspace may have. How large an attachment may be. Values that start as constants, get moved to environment variables, and then need changing on a Friday afternoon without a deploy.

export const limits = defineGroup({
  id: 'limits',
  title: 'Limits',
  place: { label: 'Admin › Limits', href: '/admin/limits' },
  fields: {
    maxProjects: number({ label: 'Projects', unit: 'count', default: 3, max: 10000 }),
    attachmentMb: number({ label: 'Attachment size', unit: 'megabytes', default: 10, max: 2048 })
  }
})

A group, its form placed beside what it affects with TunablesGroupForm, and a revision for every change.

Plans and tiers

Free gets 3 projects, Pro 50, Team 500. A plan is a named set of values a workspace is on. Staff edit Pro once and every workspace on Pro has the new limits.

export const plans = definePresets({
  id: 'plans',
  title: 'Plans',
  slots: ['plan'],
  layer: { group: 'limits', scope: 'workspace' },
  fields: { maxProjects: number({ label: 'Projects', unit: 'count', default: 3 }) },
  seed: [
    { key: 'free', slot: 'plan', label: 'Free', values: { maxProjects: 3 } },
    { key: 'pro', slot: 'plan', label: 'Pro', values: { maxProjects: 50 } }
  ]
})

await tunables.presets('plans').assign('acme', 'plan', 'pro', { actor })

Presets, assigned by reference. With layer, the plan is read as a layer of Limits, so get('limits', { scope: { workspace: 'acme' } }) returns Pro's values. Without layer, resolve the preset yourself with presets('plans').resolve('acme', 'plan').

Per-workspace and per-person values

Acme wants fewer projects than Pro allows. Dana's week starts on Sunday. Each workspace and person sets its own, within what it inherits.

maxProjects: number({ label: 'Projects', unit: 'count', default: 3, override: { workspace: 'at-most' } }),
weekStartsOn: number({ label: 'Week starts on', unit: 'weekday', default: 1, override: ['workspace', 'user'] }),
await tunables.save('limits', { maxProjects: 20 }, { scope: { workspace: 'acme' }, actor: 'Olivia' })
const { weekStartsOn } = await tunables.get('workspace', { scope: { workspace: 'acme', user: 'dana' } })

Scopes: scopes declares the chain, override says who may set a field and how far, and a scoped form lets an owner or a person change their own, allowed by authorize.

Cost and AI models

Which model drafts task suggestions, and what one costs. A list of allowed choices that settings pick from, where an entry still in use cannot be removed.

export const models = defineCatalog({
  id: 'models',
  title: 'Models',
  fields: { label: text({ label: 'Label', default: '' }), centsPerSuggestion: number({ label: 'Cost a suggestion', unit: 'count', default: 1 }) },
  seed: [{ id: 'small', values: { label: 'Small and quick', centsPerSuggestion: 1 } }]
})

suggestionModel: pick({ label: 'Suggestion model', catalog: 'models', default: 'small', affects: ['cost'] })

A catalog. Retiring Small and quick while the system or any workspace picks it is refused: "Small and quick is in use by Workspace › Suggestion model; move them to another model first." Mark the effect cost with warn: true and the screens flag every field that spends money.

Settings that bound each other

Due soon may not be longer than the time before done tasks are archived. Two settings that only make sense together.

rules: values => values.dueSoonDays > values.archiveAfterDays
  ? [{ key: 'dueSoonDays', message: 'Due soon may not be longer than the time before done tasks are archived.' }]
  : []

A group's rules, run on the whole group as it would be after the change, at whatever scope the change is made.

Presets bounded by settings

No plan may offer more projects than the system's hard ceiling. A preset is checked against settings, and a setting cannot be lowered below a preset.

within: (values, settings) => {
  const { projectCeiling } = settings.get('ceilings') as { projectCeiling: number }
  return values.maxProjects > projectCeiling ? [`its Projects, ${values.maxProjects}, is above the ceiling of ${projectCeiling}.`] : []
}

A preset set's within. Lowering the ceiling below Team's 500 is refused: "Plan "Team": its Projects, 500, is above the ceiling of 400." Plans that are a layer are also held by every workspace's own values: editing Pro below what Acme keeps is refused, naming Acme.

Changes with consequences

Turning on Guests shows every task in a project to people outside the workspace. Allowed, but asked first.

confirm: (next, previous) => next.guestAccess && !previous.guestAccess
  ? 'Guests will see every task in the project they are invited to, including its comments and attachments.'
  : undefined

confirm: the form shows the question in a dialog, tunables set asks at the terminal, and nothing is saved until the change is sent again confirmed.

Guards that check your app's own data

Lowering Tasks per project below what a project already holds strands it. The check needs your app's tables, not tunables'.

const projectsWithin: Guard = async ({ next, scope, host }) => {
  const db = (host as Sql | undefined) ?? sql
  const over = await db`select name, open_tasks from projects
    where open_tasks > ${Number(next.maxTasksPerProject)} and (${scope.workspace ?? null}::text is null or workspace = ${scope.workspace ?? null})`
  return over.map(project => `Project "${project.name}" has ${project.open_tasks} open tasks, above ${String(next.maxTasksPerProject)}.`)
}

createTunables({ store, groups, guards: { limits: [projectsWithin] } })

Guards, registered per group, run on every save with the scope it is made at. Writes in your app that depend on a limit take hold, so a lowering waits for them and its guard sees them.

Reading values in handlers and jobs

A request handler checks the limit for the person asking. A worker sends reminders at each person's hour. Read at the point of use, every time.

// In a request handler: the value for this person.
const limits = await tunables.get('limits', { scope: { workspace: person.workspace, user: person.id } })

// In a long-running job: keep working on the last values read if the database blips.
const { reminderHour } = await tunables.get('workspace', { scope, lastGood: true })

// Inside your own transaction: hold the settings while you write what depends on them.
await sql.begin(async (tx) => {
  const inTx = tunables.within(tx)
  await inTx.hold()
  const { maxTasksPerProject } = await inTx.get('limits', { scope: { workspace: project.workspace } })
  if (project.openTasks >= maxTasksPerProject) throw new Error('This project is full.')
  await tx`insert into tasks (project_id, title) values (${project.id}, ${title})`
})

get returns every field or throws; never write a fallback. lastGood returns the last values this process read when a read fails. within and hold join your transaction; committed() tells onChange listeners once it has committed.

Deployment values are not settings

The database URL, a lock id, the log level. Set per deployment, never tuned while the app runs.

export const environment = defineEnvironment({
  DATABASE_URL: env.string({ description: 'The Postgres the app and tunables share.' }),
  TASKLANE_LOG_LEVEL: env.choice({ options: ['debug', 'info', 'warn', 'error'], default: 'info', description: 'How much the server logs.' })
})

Deployment values, declared once and written to .env.example. A value is one or the other, never both, so there is no precedence between the environment and the database.

Testing

A test that needs a workspace at its limit. Your own definitions, in memory.

import { setForTest, testTunables } from 'tunables/testing'

const tunables = await testTunables({ groups, presets, catalogs, effects, scopes })
await setForTest(tunables, { limits: { maxProjects: 1 } })

tunables/testing syncs your definitions over a memory store; setForTest puts values in place past the checks a person would meet.