# Designs and the resolver

> How better-supabase/preset reads a preset name or a design, resolves its blocks and writes the config and wiring Create shows.

Source: https://bettersupabase.com/docs/presets/designs

`better-supabase/preset` is the model behind `preset` in the config and
behind [Create](https://app.bettersupabase.com/create). It runs on every
runtime, including the browser: the resolver reads a generated catalog of
the blocks and never imports the SQL renderers.

```ts
import {
  presetConfigSource,
  presetFromSpec,
  resolvePreset,
} from "better-supabase/preset";

const preset = presetFromSpec({ api: "api", blocks: ["comments"] });
const resolved = resolvePreset(preset);
// resolved.blocks: comments, plus tenant, access and updated-at, each with a reason
console.log(await presetConfigSource(resolved));
```

## The preset key [#the-preset-key]

The config's `preset` key takes a preset name, such as `"saas"`, or an object
with the conventions that differ from the defaults and the blocks to install:

```ts title="better-supabase.config.ts"
import { defineConfig } from "better-supabase/config";

export default defineConfig({
  preset: { casing: "camel", access: "provider", blocks: ["profiles", "tags"] },
});
```

`presetSpec(preset)` turns a `Preset` into that value: the name when the preset
equals a named one, otherwise the object with only the fields that differ.
`presetFromSpec` and `presetOfSpec` read it back; `presetOfSpec` takes an
unknown value and throws for an unknown field or value. `CONVENTION_FIELDS`
lists the fields in a fixed order.

## Conventions [#conventions]

Every field has a default, so a preset sets only what differs.

| Field          | Values                                                                                         | Default        | What it sets                                                                                                                          |
| -------------- | ---------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `framework`    | `next`, `vite`, `tanstack-start`, `expo`, `sveltekit`, `nuxt`, `hono`, `astro`, `react-router` | `next`         | the glue Create shows                                                                                                                 |
| `platforms`    | `web`, `native`, `both`                                                                        | `web`          | the glue Create shows; `native` needs `expo`                                                                                          |
| `casing`       | `snake`, `camel`                                                                               | `snake`        | `casing`                                                                                                                              |
| `idType`       | `uuid`, `text`, `bigint`, `integer`                                                            | `uuid`         | `blocks.permissions.idType`                                                                                                           |
| `scope`        | `tenant`, `user`, `none`                                                                       | `tenant`       | `database.sql.owner`; under `user` or `none`, blocks that need tenants can't install, and the rest need neither `tenant` nor `access` |
| `tenantName`   | `organization`, `workspace`, `team`                                                            | `organization` | `blocks.memberships.names.organization`; a block whose tables or columns the name would take over can't install                       |
| `access`       | `roles`, `catalog`, `provider`, `custom`                                                       | `roles`        | `blocks.permissions.model`                                                                                                            |
| `api`          | `none`, `api`                                                                                  | `none`         | `database.sql.api`                                                                                                                    |
| `integrations` | `next`, `hono`, `orpc`, `edge`, `expo`, `mcp`, `client`, `react` (any number)                  | none           | `integrations`                                                                                                                        |

## Designs [#designs]

A design is plain JSON: the preset, the [app tables](/docs/blocks/app-tables),
extra columns on block tables and where each table sits on the Create canvas.
It rides on a Create URL as `?design=`, compressed:

```ts
import {
  createUrl,
  encodeDesign,
  namedPreset,
  presetAndDesignOf,
  presetInstallCommand,
  resolvePreset,
} from "better-supabase/preset";

const resolved = resolvePreset(namedPreset("crm"));

const design = {
  appTables: { projects: { columns: { name: { type: "text" } } } },
  extraColumns: { organizations: { tier: "text" } },
  positions: { "public.projects": [120, 40] },
};

await createUrl(resolved, design);
// https://app.bettersupabase.com/create?design=0...

await presetInstallCommand(resolved, design);
// npx better-supabase init --preset "https://app.bettersupabase.com/create?design=0..."

const { preset, design: read } = await presetAndDesignOf(
  await createUrl(resolved, design),
);
```

`createUrl` writes `?preset=<name>` for a named preset with nothing else, and
`?design=<code>` with the preset inside the design otherwise.
`presetAndDesignOf` reads a preset name, a Create URL or a bare design code: the
design's own `preset` comes first, then a `preset=` parameter, then the default
preset. The `preset` key takes a name or an object, as in the config.

A design code starts with `0`, which no preset name starts with, followed by
base64url of the design's JSON compressed with `deflate-raw`. It uses
`CompressionStream`, so encoding and decoding are async and run on every
runtime.

The JSON's own format goes in a `version` key. `designFormat(design)` returns
the lowest format that reads the design: `1` when a table or column uses a key
added after the first release (`format`, `options`, `min`, `max`, `comment`,
`identity`, or a table's `id`, `unique`, `indexes` or `comment`), `2` for a
table `subject`, `3` for `rollups` or `lookups`, `4` for `events` or subject
`versions`, `5` for column `access`, `6` for column `encrypted` or table
`auditReads`, `7` for `preset`, and `0` otherwise. `DESIGN_FORMAT` is the
newest format this release reads; a code with a higher `version` fails with a
message to update better-supabase instead of losing keys.

### Codes from 0.14 [#codes-from-014]

Releases up to 0.14 wrote a separate preset code (`?preset=1...`). Reading one
now throws an error that names `better-supabase codemod 0.15`, which turns a
code in the config's `preset` into the name or object it holds, and a 0.14
design file into a design with its preset inside.

`decodeDesign` and `designOf` reject what isn't a design: an unknown key, a
table that `tables` wouldn't accept, a column type outside a plain SQL
type, a block with no table for extra columns, or a code that inflates past
256 KB. `encodeDesign` throws a `DesignParseError` for an invalid design, and a
`DesignTooLongError` (a `DesignParseError` with the code's `length`) when a valid
design would pass `MAX_DESIGN_CODE_LENGTH` (6000 characters); write a
[design file](#design-files) then. Check for `DesignTooLongError` to tell the
two apart.

`parseDesign(value)` checks the shape of a design object, such as a JSON body
or an AI tool's input, with the same rules, and returns it with its empty
parts dropped. It throws a `DesignParseError` naming the first problem. To
check a design against a preset as well, use
[`checkDesign`](/docs/presets/create#check-a-design).

`safeParseDesign(value)` runs the same checks without throwing, so a route
handler can answer a bad body with a 400 and the issue:

```ts
import { safeParseDesign } from "better-supabase/preset";

const result = safeParseDesign(await request.json());

if (!result.ok) {
  return Response.json({ issues: result.issues }, { status: 400 });
}

// result.design is the checked design
```

`designSchema` is the same check as a [Standard Schema](https://standardschema.dev)
(v1), for a library that takes one, such as a form library, a router or an AI
SDK tool. Its output is the design with its empty parts dropped, and each
issue's `path` lists the keys of the JSON Pointer below:

```ts
import { designSchema } from "better-supabase/preset";

const result = await designSchema["~standard"].validate(body);
// { value: { appTables: { ... } } } or { issues: [{ message, path: ["appTables", "tasks"] }] }
```

[`schemas/design-v1.json`](https://unpkg.com/better-supabase/schemas/design-v1.json)
is the JSON Schema of a design's shape, for an editor or a tool outside
TypeScript. It accepts what `designSchema` accepts for the shape; the names,
defaults and limits are checked only by `designSchema` and `parseDesign`.

A `DesignParseError` is a `TypeError` with a `code` and a `path`, and each
issue in `issues` is the same three keys as plain JSON. `path` is a
[JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901) to the key at fault,
such as `/appTables/tasks/columns/title/type`, or its nearest parent when
the key is missing. The codes are stable; the messages may change.

| Code                  | What it means                                                      |
| --------------------- | ------------------------------------------------------------------ |
| `invalid_design`      | a value has the wrong type, such as a design that isn't an object  |
| `unknown_key`         | a key a design or design file doesn't have                         |
| `unsupported_format`  | `version` is newer than this release reads                         |
| `too_large`           | more tables, extra columns or positions than a design may hold     |
| `unknown_block`       | `extraColumns` names a block that doesn't exist                    |
| `no_extra_columns`    | `extraColumns` names a block that takes no extra columns           |
| `block_not_installed` | `extraColumns` names a block the preset doesn't install            |
| `invalid_name`        | a column or table name that isn't a lower snake identifier         |
| `invalid_type`        | an extra column whose type isn't a SQL type                        |
| `invalid_position`    | a canvas position that isn't two finite numbers                    |
| `invalid_app_table`   | an app table that `tables` rejects                                 |
| `invalid_code`        | a design code that is malformed, damaged or too long to read       |
| `too_long`            | a valid design whose code is too long for a URL                    |
| `invalid_file`        | a design file that isn't JSON or lacks its keys                    |
| `invalid_preset`      | a preset that isn't a preset name or object, or a file without one |

### Design files [#design-files]

A design too long for a URL travels as a file instead. `designFileSource`
writes it, and `init --preset` and `add --preset` read it from a path ending
in `.json`. `DESIGN_FILE` is the default name, `better-supabase.design.json`.

```ts
import {
  defaultPreset,
  designFileSource,
  readDesignFile,
  resolvePreset,
} from "better-supabase/preset";

const source = designFileSource(resolvePreset(defaultPreset()), design);
// {
//   "version": 7,
//   "preset": "saas",
//   "appTables": { ... },
//   "extraColumns": { ... }
// }

const { preset, design: read } = readDesignFile(source);
```

The file is the design JSON with its `version` and `preset`, with no length
limit. `readDesignFile` checks it with the same rules as `parseDesign`, needs a
`preset`, and throws a `DesignParseError` for anything else.
`safeDesignFileSource(resolved, design)` returns `{ ok: true, source }` or the
issues instead of throwing.

Two helpers check a design before it is encoded. `appReservedColumns(tenant)`
lists the columns every app table can get from its scope (`id`,
`created_at`, `updated_at`, `user_id` and `<tenant>_id`), which a design's
columns may not name. `isAppDefault(type, value)` says whether a column of
`type` accepts `value` as its default.

`diffDesigns(before, after)` lists what changes between two designs, and
`diffAppTables(before, after, { keepMissing })` does the same for two
`appTables` objects; with `keepMissing: true`, a table missing from `after`
is kept instead of dropped, as `add --preset` merges a design. Each `DesignChange`
has a `kind`, the `table` and `column`, a one-sentence `summary`, and
`breaking`. A dropped or renamed table or column, a type change and a column
made required without a default are breaking, and their `steps` give the
expand-and-contract migration that makes them safe. A change to a table's
rollups or lookups is the safe `table.computed`, because their view keeps
no rows. A change to its `events` is the safe `table.events` and a change to
its subject's `versions` is the safe `table.versions`, because each only
replaces a trigger:

```ts
import { diffAppTables } from "better-supabase/preset";

const changes = diffAppTables(
  { notes: { columns: { body: { type: "jsonb" } } } },
  { notes: { columns: { body: { type: "text" } } } },
);
// [{ kind: "column.type", table: "notes", column: "body", breaking: true,
//    summary: "Changes notes.body from jsonb to text.", steps: [...] }]
```

The AI instructions for a design are at `/create/prompt.md?design=<code>`,
or `/create/prompt.md?preset=<name>` for a named preset.

| Function               | What it does                                                                                          |
| ---------------------- | ----------------------------------------------------------------------------------------------------- |
| `designConfig`         | the preset's config with the design merged in; throws for extra columns on a block it doesn't install |
| `createUrl`            | the `/create` URL for a preset and design; throws `DesignTooLongError` past the URL limit             |
| `presetAndDesignOf`    | the preset and design a name, a `/create` URL or a design code holds                                  |
| `parseDesign`          | checks the shape of a design object and drops its empty parts; throws a `DesignParseError`            |
| `designSchema`         | `parseDesign` as a Standard Schema (v1)                                                               |
| `safeParseDesign`      | `parseDesign` without the throw: `{ ok: true, design }` or `{ ok: false, issues }`                    |
| `designFileSource`     | the design file: the design JSON with its preset                                                      |
| `safeDesignFileSource` | `designFileSource` without the throw: `{ ok: true, source }` or `{ ok: false, issues }`               |
| `readDesignFile`       | the preset and design from a design file; throws a `DesignParseError` for anything else               |

### The default preset [#the-default-preset]

`DEFAULT_PRESET_NAME` is `"saas"`, the preset `/create` and `init` start
from. `defaultPreset()` returns it as a `Preset`, so an app that opens a new
design doesn't hard-code it.

## Resolving [#resolving]

`resolvePreset(preset)` returns:

| Key            | What it holds                                                                                                         |
| -------------- | --------------------------------------------------------------------------------------------------------------------- |
| `blocks`       | every block to install, in install order, with `selected`, `requiredBy` and a `reason` such as "required by comments" |
| `integrations` | the soft integrations active between blocks in the set (`comments` works with `notifications`)                        |
| `conflicts`    | what can't install and why: a block that needs tenants under scope `user`, or Expo on `web` only                      |
| `manifests`    | the block manifests that cover the set, for `lib/blocks.ts`                                                           |

What a block requires can depend on the access model: under `provider` and
`custom`, `access` no longer requires `tenant`. `presetBlockConflict` says
why one block can't install before it is selected, which is how Create
disables a block.

`defineConfig` throws when a preset has conflicts, listing each one.

## Generated files [#generated-files]

`presetConfigSource`, `presetInstallCommand` and `presetApplyCommand` are
async, because the URL in their output is.

| Function               | Writes                                                                                        |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `presetConfigSource`   | `better-supabase.config.ts` with every block, each added block marked with its reason         |
| `presetBlocksSource`   | `lib/blocks.ts`: the blocks with a TypeScript client, wired with `defineBlocks`               |
| `presetInstallCommand` | `npx better-supabase init --preset <name or URL>`, which starts a project from the preset     |
| `presetApplyCommand`   | `npx better-supabase add --preset <name or URL>`, which applies it to an existing project     |
| `presetGlue`           | the registry glue items for the preset's framework and platforms; `glueCommand` installs them |

`scripts/build-catalog.ts` writes the catalog (`src/preset/catalog.generated.ts`)
from the SQL module registry and the block manifests, and
`pnpm catalog:check` fails when it is stale.