⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

Changelog

Release notes for @createcms/core, generated from Changesets.

0.2.11

Patch Changes

  • #29 ba326e3 Thanks @weepaho3! - Fix createNotificationRouter<typeof cms> typing when no plugin contributes notification types: the empty plugin registry resolved to Record<string, never> (a string index signature), which widened the known-type union to string and collapsed every resolver's n.meta to never. The registry's index signature is now stripped, so core (and app NotificationMetaMap) meta types correctly even with no notification-type plugins.

    Notification meta now also carries everything a deep link needs, so the synchronous createNotificationRouter never has to look anything up:

    • approvalRequested / approvalApproved / approvalRejected now include rootId and branchName (previously only branchId).
    • comment now includes rootId (it already had messageId/threadId), and the reply-path mention notification carries rootId too.

    CoreNotificationMetaMap is updated to match, so router resolvers get the new fields typed. No schema change — these are meta (jsonb) fields populated from data already joined at emit time.

0.2.10

Patch Changes

  • #26 ba13c71 Thanks @weepaho3! - Notifications now carry everything you need for deep links and showing the responsible user, with types to match:

    • createNotificationRouter (new, from @createcms/core/react) — define a resolver per notification type that builds a deep link from the item's fields; each resolver gets meta narrowed to that type. Pass createNotificationRouter<typeof cms>(…) to type core and plugin-contributed types. A required fallback keeps routing total. Pure and client-side — no realtime peer, no server or schema change.
    • Plugin-extensible notification types — a new notificationTypes plugin seam (a Zod meta map): its keys fold into the notification_type enum at createcms generate (so a plugin persists its own type) and are inferred into typeof cms so the router types each plugin meta. The emit side (cms.notify / notificationService.notify) accepts plugin/app type strings. App-only custom types can also be typed by augmenting NotificationMetaMap.
    • Typed actorUserlistNotifications (with withUser) and useNotifications now type actorUser off your user config (a partial of the user-table row) instead of unknown, inferred straight from typeof cms.
    • Actor on the live push — the realtime notification event now carries actorUser, resolved server-side from the user config's exposeColumns (batched). The responsible user's name/avatar are available the instant a push lands, no second poll. actorUser is also passed to onNotification handlers.
  • #26 87b41c0 Thanks @weepaho3! - useNotifications now takes your typed createCMSClient instance directly — no as unknown as Parameters<typeof useNotifications>[0] cast. The hook's internal client shape brands query.withUser as true (matching the client's WithUserQuery) instead of plain boolean, so a real typed client is structurally assignable.

    userId is now optional. Pass it straight from your auth session (session?.user?.id) instead of the ?? '' workaround: while it's undefined the hook stays poll-only (seeded from listNotifications) and opens the notif:<userId> subscription once it resolves. The CMS has no current-user endpoint, so your app still supplies the id.

0.2.9

Patch Changes

  • #24 238208f Thanks @weepaho3! - Add an optional c15t adapter for the consent gate at @createcms/core/plugins/consent/c15t.

    c15t is a consent-management platform (banner + storage + Consent Mode); the createCMS consent gate is the consumer-side layer that buffers the CMS's own A/B + analytics effects until consent is decided. This adapter bridges them:

    • consentModeFromC15t(consents, mapping?) — pure mapper from c15t's categories to Consent Mode v2 signals (default: measurementanalytics_storage, marketingad_storage/ad_user_data/ad_personalization; necessary/functionality/experience ignored). The mapping is overridable.
    • useC15tConsentBridge(client, { consents, hasConsented }, mapping?) — a React hook that pushes c15t's decision into the gate once the visitor has decided.

    It takes c15t's consent record as input and has no @c15t/* dependency, so it works with any c15t version — the consumer wires useConsentManager() in. (If c15t already emits Consent Mode commands onto window.dataLayer via GTM, the gate's auto-read picks them up and no adapter is needed; this is for the offline / no-dataLayer case or driving the gate explicitly.)

  • #24 57029a5 Thanks @weepaho3! - Media gate is now addressed by the asset id, and content images are served with no read-path resolution.

    • Gate by id. The public gate is GET /media/asset/{id} (the stable asset id, which is exactly what content stores), a 302 redirect to the object. An <img src="/media/asset/{id}"> survives swapping the bytes behind an asset id (new slug/object key, same id) with no content change and no re-render — the gate re-resolves the id to the current object. The redirect is short-cached (max-age=300, no longer immutable) so such a swap propagates within minutes, while the object bytes stay long-cached at the CDN (each version has its own object key). A CDN in front of the gate must include the query string in its cache key (the redirect target varies by ?format/?w/?download).
    • Two latent gate bugs fixed along the way (the gate never worked over real HTTP before, because content used direct CDN URLs): the route was registered with OpenAPI {param} braces — rou3 only matches :param, so every request 404'd at the router before the handler ran — and the handler set the redirect via a returned { headers, body } object that better-call never applies to the HTTP response (it answered 200 with an empty body). Both are fixed; the gate is now covered by tests that drive a real Request through cms.router.handler.
    • Reverted the read-path image→{ id, slug } resolution shipped in 0.2.8 (resolveImageAssets / ResolvedImage). With the id-addressed gate the renderer builds the URL straight from the stored id, so no read-time resolution is needed; an image block property is a plain asset-id string on both the write and read paths.
  • #24 584d981 Thanks @weepaho3! - Add media.moveAssets — move assets between folders (and to the root).

    moveAssets({ assetIds, folderId }) sets the folder of one or more assets (folderId: null moves them to the root) — the missing write counterpart to moveFolder for drag-and-drop in a media library. Bulk-by-ids and scoped like updateAssetStatus: non-existent, out-of-scope, and archived ids are skipped and returned in skipped so a batch partially succeeds; a moved asset's variants follow it into the same folder so an original and its variants are never split apart (and a variant id passed on its own is skipped — variants are not moved directly). Returns { moved, movedIds, skipped }. Throws FOLDER_NOT_FOUND for an unknown (or out-of-scope) target folder, ASSET_NOT_FOUND if none of the ids reference a live asset.

  • #24 584d981 Thanks @weepaho3! - Add media.replaceAsset — swap the bytes behind an existing asset, keeping its id.

    replaceAsset({ assetId, file }) replaces an asset's content while keeping its id (and folderId / status) stable, so every content reference picks up the new image with no content change and no re-render — content stores the id and the id-addressed gate re-resolves it (the short-cached redirect propagates the swap within minutes). The classic use case: a logo / brand image changes — replace it once, and it updates everywhere it's used.

    A new slug / object key is minted (not an overwrite) so the long CDN cache on the old object can't keep serving the stale image. The endpoint is server-side and atomic: the new object is uploaded first, then — only on success — the row is repointed in a single transaction that also archives the asset's old variants (they depict the old bytes and are unreachable from the new slug, so callers should regenerate variants afterward). The old object is left in the bucket for a future pruning pass. Throws CANNOT_REPLACE_VARIANT if the target is itself a variant (replace the original instead), ASSET_NOT_FOUND, FILE_TOO_LARGE / INVALID_FILE_TYPE, or UPLOAD_FAILED (which leaves the asset unchanged).

  • #24 7cdf688 Thanks @weepaho3! - A/B live results ride the shared realtime connection (fixes a never-working path).

    The A/B live-dashboard delta stream previously lived inside the plugin (its own mis-constructed realtime instance + a bare EventSource) and never actually delivered. It now uses the shared realtime layer end-to-end: the trackEvent ingest publishes each delta over ctx.realtime to the public ab:live:<testId> channel — decoupled from the analytics storage adapter, so it works with the Postgres adapter too — and useLiveResults rides the same RealtimeProvider connection as useNotifications.

    useLiveResults moves off the client proxy to its own subpath, @createcms/core/plugins/ab-test/live (which pulls in the optional @upstash/realtime peer, keeping the main A/B client peer-free). It applies live increments and reconciles against the absolute getResults aggregate on (re)connect; without realtime the stream never connects and the SSR initial (+ any getResults reconcile) stands.

  • #24 8c43239 Thanks @weepaho3! - Real-time notifications — automatic per-user push + a realtime-only useNotifications hook.

    When realtime is configured, every notification is pushed to its recipient's private channel automatically — a built-in handler rides the existing dispatch, so all notification sources (comments, merges, approvals, …) deliver live with no extra wiring.

    On the client, wrap your app once in RealtimeProvider (from @createcms/core/react/realtime) — <RealtimeProvider baseURL="/api/cms"> — to open one shared connection, then call useNotifications(client, { userId }). It seeds list + unread count from the listNotifications poll, prepends live pushes de-duped by id, and the provider replays anything missed across a reconnect. useNotifications is realtime-only and type-requires client.notifications, so it only compiles when notifications are enabled. Without realtime there's no built-in polling hook — read the durable list yourself via client.notifications.listNotifications.

  • #24 5bdaa2c Thanks @weepaho3! - Add an optional, Upstash-backed realtime layer — and a notifications on/off switch.

    Configure realtime: { url, token } (your Upstash Redis credentials; @upstash/realtime + @upstash/redis are optional peers) to mount a shared /realtime SSE route. The route authenticates each connection via your authMiddleware (the session is read from the request cookie — EventSource can't send auth headers) and authorizes every channel against that identity: a user may subscribe only to their own private notif:<userId> channel (fails closed when unauthenticated), while ab:live:<testId> stays world-readable. The server is the broker — the browser only ever talks to your same-origin route; the Upstash credentials never leave the server. Realtime is Upstash-only (no pluggable transport).

    Separately, notifications: false on createCMS fully disables the notifications feature: the tables aren't generated, the routes never register, and client.notifications plus cms.notify are absent from the inferred types (a stray call is a compile error). Default: enabled. Use a literal false. notifications and realtime are independent — A/B live results can use realtime with notifications: false.

0.2.8

Patch Changes

  • #22 8ca638b Thanks @weepaho3! - Resolve image block properties to { id, slug } on the rendered read path.

    image properties store the asset id (ast_…). getPublishedContent (and getBlockTree unless raw) now resolves each one to { id, slug } — exactly as link and reference properties are resolved — so a renderer builds the gate URL /media/asset/{slug} straight from the slug, with no second lookup. This keeps the SEO-friendly slug in the URL and routes every image request through the status-checked gate, while the id stays the stored value (usage tracking and the archive guard are unaffected).

    Resolves to null when the asset is archived or out of scope — the resolver is scoped, so a forged cross-tenant id in content never leaks another tenant's slug, symmetric with link resolution. A raw read keeps the stored id for editor re-picking. Type: in resolved mode an image property now infers as ResolvedImage ({ id, slug } | null).

0.2.7

Patch Changes

  • #20 a78f391 Thanks @weepaho3! - Media: ready public url per asset, new listFolders, removed getAssetUrlAuthenticated.

    • listAssets (and the createSignedUpload / uploadAssets responses) now include a direct object url per asset (${publicUrl}/${objectKey}), built server-side — so internal/admin tooling (a media library) needs no URL helper and never has to know publicUrl itself. This URL bypasses the gate (no status check, no transforms); it is for admin display, not for embedding in content. Content references an asset by id and is served through the gate, GET /media/asset/{slug}.
    • New listFolders({ parentId? }) read endpoint: returns the direct child folders of parentId (or the root-level folders when omitted), sorted by name. This is the missing read counterpart to createFolder/moveFolder/deleteFolder, so a media-library UI can navigate the folder tree.
    • Removed getAssetUrlAuthenticated (and the internal signGetObject helper). Uploaded objects are public-read, so the presigned-GET path was redundant — status is a visibility flag gating the public /media/asset/{slug} redirect, not a hard-privacy boundary. Serve assets through that gate; flip an asset to public with updateAssetStatus to serve it there.

0.2.6

Patch Changes

  • #15 0fcc2b4 Thanks @weepaho3! - Add a link block-property type — a language-aware link resolved to the current path at read time.

    A link is a discriminated union over kind: internal (an entry), external (a URL), email, or phone. The property config takes optional allowedKinds and allowedCollections. On a raw: false read (getBlockTree / getPublishedContent) every kind is normalised to an href: an internal link is resolved to the target entry's current, language-aware path (via the same reference resolver + path resolver redirects use — the active-language sibling, ancestor-aware, following slug changes), with fragment / query appended; external/email/phone are static pass-throughs (url / mailto: / tel:). A gone / out-of-scope target resolves to href: null. With raw: true the stored value is returned unchanged so the editor can re-pick the target.

    Unlike reference, a link resolves only a path — nothing is embedded. Internal link targets are indexed in contentUsages (targetKind: 'link') for the usage UI, but deleting a link target is a warning, not a hard block (a dangling link is recoverable).

    Schema change, no backfill (beta): the content_usage_target enum gains 'link'. Recreate the database.

0.2.5

Patch Changes

  • #13 341195c Thanks @weepaho3! - Add an opt-in getBlockTree({ includeReferencePreviews: true }) flag that returns a references sidecar alongside the tree.

    The sidecar is a Record<storedReferenceValue, tree> of the published render tree of every reference embedded in the entry (its own nested references resolved and {{variables}} substituted, through the active tenant/language scope). This lets a page editor fetch the raw editable tree and all embedded reusable-block previews in a single call instead of one getPublishedContent per reference (the N+1). Combine with raw: true to keep the main tree editable while still getting rendered previews. References that are not published (or out of scope) are omitted. Opt-in because the resolution is more expensive; existing getBlockTree callers are unaffected. Reuses the same resolution machinery as getPublishedContent (no duplication).

  • #13 341195c Thanks @weepaho3! - listBranches now returns hasPublications (a boolean) per branch, so callers can tell which branches are currently published without a separate query — analogous to hasPublications on listRoots. The value was already computed internally (it drives isDeletable); it is now exposed on each BranchListItem.

  • #13 341195c Thanks @weepaho3! - branchProtection can now be overridden per collection. A collection definition accepts its own branchProtection (a Partial<BranchProtectionConfig>): each field set there wins over the global config for that collection only, and unset fields inherit the global value (then the default).

    This makes governance flexible per content type — e.g. a reusableBlock collection can set branchProtection: { protectPublishedBranches: false } to stay directly editable, while pages keep the global protection. The same applies to requireApprovalToMerge, requireApprovalBeforePublish, and requiredReviewers. Backward compatible: collections without an override behave exactly as before. (defaultBranchName and mergeStrategy remain global.)

  • #13 341195c Thanks @weepaho3! - Templates now participate in i18n / multi-tenant scoping and are applied server-side on createBlock.

    • Scoped templates. With the i18n plugin a template is per language; with multi-tenant it is per tenant. All template CRUD is scope-filtered, and the (collection, blockType, propertyKey) uniqueness is enforced within the active scope — so the same key can have a different default per language and per tenant. (The core DB unique was demoted to a lookup index; per-scope uniqueness is the app-level authority, mirroring redirects.)
    • Server-side application. createBlock now seeds any optional property the caller leaves unset from its template — no client wiring needed. Required properties must still be provided (input is validated before defaults apply); duplicateBlock and updateBlocks do not re-apply templates (they copy / apply a client-authoritative tree). Caller-provided values always win. The raw template string is stored, so embedded {{variables}} stay live (resolved at read time), not frozen at creation.
    • Validated targets. createTemplate now rejects a template whose propertyKey does not exist on the block type, or is not a text property (string / richText), with the new TEMPLATE_PROPERTY_INVALID error — a string template can no longer be seeded into a number/select/image/reference field.

    Schema change, no backfill (beta): the templates unique index is demoted to non-unique, and the i18n / multi-tenant plugins add a language / tenant_slug column to templates. Recreate the database.

  • #13 341195c Thanks @weepaho3! - Variables now participate in multi-tenant and i18n scoping.

    • multi-tenant — variables are partitioned per tenant. The same key is independent across tenants, and content resolves the active tenant's value.
    • i18n — variables are per-language with fallback, exactly like a translated entry: a value is resolved in the active language, falling back through the configured chain to the default language when it has no value there. Define shared values once (in the default language) and override only the few that need translating. Content rendering (getBlockTree / getPublishedContent) and template-embedded variables both resolve through this. Implemented via a plugin-provided VariableResolver on the resolved scope (mirrors the reference resolver).
    • Management (create/list/update/delete) targets the exact active cell (tenant + language) — no fallback when editing. Uniqueness is per (tenant, language, key), enforced at the app level (the core key unique index is demoted to a lookup, since the compound key can't be expressed by either plugin alone). The delete guard and revalidation are tenant-scoped (language-spanning, since a base value can be rendered via fallback in any language).

    Schema change, no backfill (beta): the variables unique index is demoted to non-unique, and the i18n / multi-tenant plugins add a language / tenant_slug column to variables. Recreate the database.

0.2.4

Patch Changes

  • #11 bd91957 Thanks @weepaho3! - Fix a client/server path mismatch that made the variables, templates, and search namespaces unreachable from the client.

    The client proxy builds every request URL as /<namespace>/<method> (e.g. client.variables.listVariables()/variables/listVariables), but these endpoints were mounted at hand-written paths that didn't follow that convention (/variables, /variables/get, /templates/create, /search, …). Every such call 404'd. Handler-level tests didn't catch it because cms.api.<ns>.<method>() invokes the handler directly and never exercises HTTP routing.

    • All variables endpoints now mount at /variables/<method> (e.g. /variables/listVariables, /variables/getVariable).
    • All templates endpoints now mount at /templates/<method> (e.g. /templates/listTemplates, /templates/getTemplate).
    • search now mounts at /search/search (matching client.search.search()).

    A new test asserts every RPC endpoint is mounted at exactly /<namespace>/<method>, so this class of drift can't regress. (Direct-URL routes with a path parameter, like the public /media/asset/{assetSlug} redirect, are intentionally exempt.)

  • #11 ff516e9 Thanks @weepaho3! - Add a configurable merge strategy for executeMerge.

    • mergeStrategy (CMS config) — 'fast-forward' (default) or 'merge-commit'. Controls how executeMerge integrates when a fast-forward is possible (the target has not diverged). 'merge-commit' always records an explicit merge commit (git's --no-ff) so every integration is visible in history. A diverged target always produces a merge commit regardless.
    • executeMerge({ noFastForward }) — per-call override of the configured strategy. true forces a merge commit, false forces a fast-forward.

    A merge with nothing to integrate (the source and target heads are already equal) stays a no-op fast-forward even under noFastForward/'merge-commit', so no empty merge commit is fabricated. Default behavior is unchanged ('fast-forward').

  • Replace branchProtection.protectMain with branchProtection.protectPublishedBranches.

    Breaking: protectMain (shipped in 0.2.3) is removed. It protected the default branch by name; the replacement instead locks a branch against direct content mutations for exactly as long as it is published — published content is the live, production-facing tree, so it is made immutable in place. Changes go via another branch + merge, then a re-publish; unpublishing makes the branch directly editable again. This applies to any published branch (a root can have several at once, e.g. A/B variants), not just the default one, and a never-published branch is freely editable.

    • Enforced by a shared assertBranchWritable guard on every content-mutation route, including revertBranch (which rewrites a published branch's head in place).
    • createRoot is never gated (it seeds a fresh, unpublished branch).
    • Still throws PROTECTED_BRANCH (403).

    Migration: rename protectMain: true to protectPublishedBranches: true. Note the new semantics — protection now follows the publication state, not the branch name.

0.2.3

Patch Changes

  • #9 be8c643 Thanks @weepaho3! - Add branch-protection and approval governance to the CMS config, plus a configurable default branch name.

    • branchProtection.protectMain — reject direct content mutations on the default branch (create/update/delete/move/duplicate of blocks, and updateRoot); edits must go via a branch + merge. createRoot is exempt. Throws the new PROTECTED_BRANCH (403) error.
    • branchProtection.requireApprovalBeforePublish — make publishBranch always require approvals, not just when one was explicitly requested. Default false (existing conditional behavior).
    • branchProtection.requiredReviewers — minimum distinct approved reviewers for the merge / publish gates (default 1).
    • defaultBranchName — the branch every root is seeded with, replacing the hard-coded 'main' throughout (rename/delete guards, read/search resolution, and i18n translation copy-seeding).

    Breaking: branchProtection.requireApprovalToMerge defaults to false. Previously executeMerge ALWAYS required approvals; merges now succeed without approval unless you set requireApprovalToMerge: true. Set it explicitly to keep the prior gate.

  • #9 1d9bf1f Thanks @weepaho3! - Add a forceCommitMessage option to the CMS config. When true, every content mutation (createRoot / createBlock / updateBlock / deleteBlock / moveBlock / duplicateBlock / updateBlocks / updateRoot) requires a non-empty message — an empty or whitespace-only message is rejected with the new COMMIT_MESSAGE_REQUIRED error instead of falling back to an auto-generated default. Off by default, so existing behavior is unchanged.

  • #9 a45021a Thanks @weepaho3! - getRootHistory now attributes each commit to the branch it was created on, deterministically — fixing wrong branch labels for shared ancestors.

    Previously the branch label was inferred with a recursive "nearest branch tip wins (MIN(depth))" heuristic, which mis-attributed commits that lie on more than one branch's first-parent chain (a feature branch with fewer post-fork commits could "claim" main's shared history). The originating branch is now stored on each commit and read directly.

    • commits gains branchId (links to the live branch — follows renames; no FK) and originBranchName (a deletion-proof name snapshot). Both are set at commit-write time.
    • getRootHistory resolves branch = COALESCE(live branch name, originBranchName) via a simple join — the recursive CTE is gone (O(n), deterministic).

    Schema change, no backfill (beta): the new origin_branch_name column is NOT NULL; recreate the database. There is no migration of existing commit rows.

0.2.2

Patch Changes

  • #7 1cc595f Thanks @weepaho3! - Fix createTrackedBlocks(...).useTrackedBlock('myBlock') rejecting a block that declared events when the collection is used in its declared form (e.g. typeof myCollection). events is optional on BlockDefinition, so the FunctionalBlocks key-filter saw TEvents | undefined and filtered out every block ((X | undefined) extends Record<…> is false). The key-filter now NonNullables the events access, matching the value side — functional blocks are detected again and fire stays narrowed.

  • #7 9009209 Thanks @weepaho3! - Add an optional group string to block property definitions — an editor hint for the field-group (fieldset/section) a field is shown under in the property panel (e.g. group: 'SEO'). Presentational only; free-form, use a shared as const for consistent, autocompleted group names. Mirrors the block-level group.

0.2.1

Patch Changes

  • #5 d9f6988 Thanks @weepaho3! - Add an optional group string to block definitions — an editor hint for the block-picker category a block appears under (e.g. group: 'Forms'). Presentational only; the package does not act on it. Free-form by design; reference a shared as const object for consistent, autocompleted group names across blocks.

  • #5 ff46dc7 Thanks @weepaho3! - Fix BlockProps&lt;typeof collection, 'blockType'> failing to compile. The helper required a non-optional blocks field, but blocks is optional on CollectionDefinition, so passing a collection definition errored with "Type 'undefined' is not assignable to type 'Record<string, AnyBlockDefinition>'". The constraint now accepts the optional shape and resolves it via NonNullable, so BlockProps&lt;typeof myCollection, 'myBlock'> works and the block name still autocompletes.

  • #5 060e6ae Thanks @weepaho3! - createBlocksMap now bundles the collection definition on the returned BlocksMap (a typed _collection), so a single object can drive both rendering and an editor — components, events, and the collection's schema/placement/grouping in one handoff, with no separate collection prop. BlocksMap gained an optional type parameter that defaults to the erased collection type, so existing BlocksMap annotations and BlocksRenderer are unaffected.

0.2.0

Minor Changes

  • #3 f263c1f Thanks @weepaho3! - Block placement constraints. Collections now take a structure map that controls which blocks may be nested where, replacing the removed allowedChildBlocks field.

    • structure is keyed by parent block name (or the literal 'root') with three mutually exclusive modes per entry: open (&#123;} / &#123; accepts: '*' }), whitelist (&#123; accepts: ['x'] }, fail-closed), or blacklist (&#123; excludes: ['x'] }, fail-open). A concrete accepts list together with excludes is a compile error. Block names autocomplete against the collection's blocks and typos are caught at compile time.
    • allowChildren is now enforced on the server: a non-container block (without allowChildren: true) rejects all children. The root always accepts children.
    • createBlock, moveBlock, and duplicateBlock enforce these rules and throw the new BLOCK_NOT_ALLOWED_IN_PARENT error; the visual editor reads the same rules for drop-zone gating, so the two can't diverge.

    Breaking: allowedChildBlocks is removed — express the same intent with structure (e.g. structure: &#123; section: &#123; accepts: ['featureItem'] } }). Blocks that hold children must now declare allowChildren: true.

0.1.1

Patch Changes

  • 028d2f2 Thanks @weepaho3! - Fix createcms generate failing on configs that use the idiomatic defineCollection / defineCollections / defineAuthMiddleware API. The config-loading shim now stubs these helpers (they are pure identity functions at runtime), so a config written exactly as the docs show loads correctly during schema generation.

On this page