Read settings and secrets

Most entities are rows: there are many posts, and a handler reads them through a repository. Some entities have exactly one instance: the address and key of a search engine, the settings of a catalog. Such an entity is declared like any other and asked for by its type:

// entities/Meili.ts
export default class Meili extends entity({
  url: url(),
  key: text(),
  timeoutMs: number({ integer: true, default: 2000 }),
}) {}

// services/Search.ts
export default class Search {
  constructor(private meili: Meili) {}
}

Search receives { url, key, timeoutMs }. The code names no variable and no file: where the value comes from is said in fougere.config.ts.

import '@fougere/adapter-sql';
import '@fougere/adapter-env';

export default defineFougere({
  sources: {
    main: { type: 'sqlite', path: '.data/app.db' },
    env: { type: 'env', values: ['Meili'] },
  },
});

An entry of sources: lists two kinds of entities: rows:, the ones it keeps as rows, and values:, the ones it keeps as one value each. An entity is one or the other, never both.

Where a value comes from

sourcewhere Meili is read
envMEILI_URL, MEILI_KEY, MEILI_TIMEOUT_MS — one variable per field, in SNAKE_CASE
file<path>/meili.yaml (or .json, .yml) — one file, beside the directories of rows
sqlite, pg, mysql, mssql, d1, memorythe row meili of the table fougere_values

Text is read as the field declares: a number, a boolean (true, false, 1, 0), JSON for an object or a list. A table of values is part of the schema like any other table: fougere migrate creates it, and the boot refuses when it is missing.

Production and staging are two deployments, each with its own environment, or its own directory of files:

config: { type: 'file', path: `config/${process.env.APP_ENV}`, values: ['Catalog'] },

Changing what a source gave

A secret kept elsewhere — a vault, a cloud's secret manager — is a link in front of where the value is read, Env<Meili>:

// fronds/search/services/MeiliFromVault.ts
import { Env } from '@fougere/core';
import Meili from '../entities/Meili.js';
import Vault from './Vault.js';

export default class MeiliFromVault implements Partial<Env<Meili>> {
  constructor(private inner: Env<Meili>, private vault: Vault) {}

  async read() {
    return { ...await this.inner.read(), key: await this.vault.secret('meili') };
  }
}

It touches Meili and nothing else: the key is Env<Meili>, as written. What it answers is judged and completed like what the source holds, once, at boot — a class asking for Meili receives the value, never the link. The value is read once: a key that changes while the app runs is picked up at the next restart.

What the boot checks

The value is read once, after the schema is checked and before the seeds. The defaults of its fields fill what is missing, and the fields judge the rest. A value that does not hold refuses the boot, one line per field, with the place to fix it:

Fougere boot refused: 2 value(s) do not hold:
  meili.url: Does not match format "uri" — sources.env, MEILI_URL
  meili.key: Required — sources.env, MEILI_KEY

A value nothing sets is not refused for being absent: it is refused for each required field it lacks, and a value whose fields all have defaults holds with nothing set at all.

A value has no repository and no table of its own. Reaching it as rows — Repository<Meili> — refuses the boot, and so does asking for a row entity as one value.

Who receives it

A value is registered in the frond that declares its entity, and in the fronds under it. A value several fronds need lives in the frond they all extend: such a frond may hold no rows, and it may hold values.

Built with Fougere and Nuxt, hosted on GitHub Pages.