# Registry format

> The published JSON contract — index, item payloads, and the validation rules an authored registry has to pass.

Source: https://docs.astra-void.xyz/facet/reference/registry-format/

This is a **published format**. `facet add` consumes it, forks emit it, and changing it
incompatibly means bumping `RegistryIndex.version`.

## What a registry serves

```
<base>/index.json      the index the CLI reads first
<base>/<name>.json     one payload per item, source text inlined
```

Plus, at the site root rather than under the registry base, two generated files:

| Path | |
| --- | --- |
| `schema.json` | The JSON Schema every `facet.json` names in its own `$schema` |
| `revisions.json` | Which immutable revisions exist, and which one the moving registry mirrors |

Both are generated by the deploy that publishes the registry rather than committed, so neither can
be a version behind the CLI that writes files pointing at them.

The CLI reads `index.json` first and item payloads lazily, only for what is being added, removed, or
diffed. Remote reads send `accept: application/json` and time out after 15 seconds.

## Revisions

The published registry is served twice — once at a path that moves, and once per push at a path that
never will:

```
r/index.json          the moving registry every CLI reads by default
r/button.json
r/a1b2c3d/index.json  the same bytes, frozen, forever
r/a1b2c3d/button.json
```

A revision is keyed by the **commit SHA** that produced it, because that is the only identifier
already immutable, already unique per publish, and already meaningful outside the registry. A CLI
version would not work: three components shipped under `0.3.0` on separate pushes, since a component
reaches users by being published to the registry rather than by a CLI release.

The two copies are byte-for-byte identical, not re-rendered — an `add` from a pin has to produce what
`add` produced at the time — and a test asserts they match.

`revisions.json` at the site root lists what exists, newest first:

```json
{
  "latest": "d345608",
  "revisions": [
    { "revision": "d345608", "generatedBy": "0.4.0", "components": 23 },
    { "revision": "821604a", "generatedBy": "0.3.1", "components": 10 }
  ]
}
```

It is rebuilt by scanning the published tree rather than appended to, so it cannot claim a revision
that is not there. `latest` is the revision `r/` currently mirrors.

> **A revision is forever**
>
> Deleting one breaks every project pinned to it, and unlike a bad npm release there is no version to
> move past. Publishing a broken registry is guarded by `registry:check` before the build — this
> raises the cost of getting it wrong from "fix it on the next push" to "fix it on the next push, and
> the bad revision stays".

To read from one, point at it as a base — see [pinning a revision](https://docs.astra-void.xyz/facet/reference/facet-json.md#pinning-a-revision).

## `index.json`

```json
{
  "version": 1,
  "generatedBy": "0.4.0",
  "items": [
    {
      "name": "button",
      "type": "registry:ui",
      "description": "Button with variant and size recipes",
      "files": ["ui/button.tsx"],
      "registryDependencies": ["utils", "text"],
      "dependencies": [
        "@facet-ui/react-variants@^0.1.1",
        "@lattice-ui/react-runtime@^0.8.0"
      ],
      "tokens": ["primary", "primary-foreground", "…"]
    }
  ]
}
```

| Field | |
| --- | --- |
| `version` | Registry format version. `1` today. A CLI that meets a higher one refuses and says to upgrade, rather than guessing at a format it does not know. |
| `generatedBy` | Version of the `facet-rbxts` release that produced the index. Informational. |
| `items` | Every item, with its files as **bare strings**. |

## Item fields

| Field | Type | |
| --- | --- | --- |
| `name` | `string` | Unique across the registry. What you type after `facet add`. |
| `type` | `registry:ui` · `registry:lib` · `registry:hook` · `registry:block` | Decides which `facet.json` alias the files land under |
| `description` | `string?` | Shown by `facet list` |
| `files` | see below | |
| `dependencies` | `string[]?` | npm packages the source imports, as `name` or `name@range` |
| `devDependencies` | `string[]?` | Same, installed as dev |
| `registryDependencies` | `string[]?` | Other **items** — never files. Resolved transitively by `add` |
| `tokens` | `string[]?` | Semantic theme tokens the classes name, so `doctor` can check them against the consumer's Vela theme |
| `providers` | see below | React providers the component needs above the app |

### `providers`

New in 0.4.0, and the reason it exists is that one failure in the registry is not a build failure.

```json
{
  "name": "dialog",
  "providers": [
    {
      "name": "PortalProvider",
      "package": "@lattice-ui/react-layer",
      "props": { "container": "player-gui" },
      "reason": "Lattice reads the portal target from it, and throws when a dialog opens without one"
    }
  ]
}
```

| Field | |
| --- | --- |
| `name` | The component to import and wrap with |
| `package` | Where to import it from |
| `props` | Prop name → a **symbolic** value the tool resolves, not source text |
| `reason` | Printed to the consumer, in both the prompt and `doctor`'s output |

`facet add` [offers to write it into the client entry](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider), and
`facet doctor` reports when it is gone.

> **The container value is a symbol, not a snippet**
>
> The expression for the local player's `PlayerGui` is knowledge about roblox-ts, and it belongs in the
> tool that reads the entry file — which can see what the entry already names — rather than in a string
> the registry hands over to be pasted somewhere it cannot see.

**The registry declares it, not the CLI.** The alternative was for the CLI to know that `dialog`
needs `PortalProvider`. That knowledge would then live in a released binary, and the whole point of
the hosted registry is that a component reaches users by being *published* rather than by a CLI
release — so the next layered component would need a CLI release to be wired correctly.

**`version` did not move for it.** `loadIndex` *rejects* an index whose version it does not
recognise, so bumping to `2` would break every CLI already installed in order to deliver a field
those CLIs would ignore anyway. An optional field is compatible in both directions: an older CLI
does not read `providers`, and a newer one reads `undefined` from an older registry. The number is
for removing a field, renaming one, or changing what an existing one means.

### Item types and where they land

| Type | `facet.json` alias | Default directory |
| --- | --- | --- |
| `registry:ui` | `ui` | `src/shared/ui` |
| `registry:lib` | `lib` | `src/shared/lib` |
| `registry:hook` | `hooks` | `src/shared/hooks` |
| `registry:block` | `ui` | `src/shared/ui` |

## Item payloads

`<name>.json` is the index entry with the file list expanded from strings into objects carrying the
source text:

```json
{
  "name": "utils",
  "type": "registry:lib",
  "files": [
    {
      "path": "lib/utils.ts",
      "type": "registry:lib",
      "content": "export { type ClassValue as ClassName, cn } from \"@facet-ui/react-variants\";\n…"
    }
  ],
  "dependencies": ["@facet-ui/react-variants@^0.1.1"]
}
```

| File field | |
| --- | --- |
| `path` | Path under the registry's `src`, e.g. `ui/button.tsx`. The **first segment names the alias**. |
| `type` | Per-file item type, which can differ from the item's own |
| `target` | Overrides the alias-derived destination entirely. Rare; blocks use it |
| `content` | The source text, inlined |

A path's first segment is the alias and the rest is kept, so a multi-file block stays a directory
rather than collapsing into the alias root.

> **target does not survive into the index**
>
> The index carries files as bare strings, so neither the per-file `type` nor a `target` override is
> visible there — the first segment is read as the type instead, falling back to the item's own.
>
> A file that overrides its destination with `target` therefore cannot be located from the index at
> all. Only `facet doctor` reads files that way, and it reports what it could not find rather than
> pretending it looked.

## What `~/` means in the content

Registry sources address each other with a `~/` prefix, and the first segment is an alias name:

```tsx
import { TextSlot } from "~/lib/text";
import { cn } from "~/lib/utils";
```

`facet add` rewrites these for the consumer's project — textually when the alias has an `import`
specifier, otherwise as a relative path computed from where the importing file lands. An unknown
alias segment is left alone deliberately, so it fails loudly at typecheck rather than being silently
rewritten to something wrong.

**Relative paths in registry source are a bug.** The CLI can rewrite `~/lib/utils`; it cannot
rewrite `../lib/utils`.

## What a component may import

Anything else will not resolve once copied:

- `@rbxts/*`
- `@lattice-ui/*`
- `@facet-ui/react-variants`
- `~/…`

## Validation rules

`validateRegistry()` runs in `registry:check`, so a broken registry fails CI rather than `facet add`:

| Rule | |
| --- | --- |
| Unique `name` | |
| Known `type` | |
| At least one file per item | |
| No file claimed by two items | |
| Every `registryDependencies` entry resolves | |
| **One spec per npm package across the whole registry** | |

That last one is the non-obvious one. `facet add` **unions** dependency strings across the install
set, so `@lattice-ui/react-runtime` in one item and `@lattice-ui/react-runtime@^0.8.0` in another
survive as two entries and both reach the package manager. A dependency carries a floor when the
source needs behavior a specific version introduced, and then the same package carries the *same*
spec in every entry.

What validation **cannot** catch is a missing npm dependency — an import the entry never declared.
That is on the author, and it ships a file that cannot compile in a project that did not happen to
have the package already.

## Specs

Dependency strings are `name` or `name@range`. The CLI splits on the **last** `@` so a scoped
package's leading one survives: `@facet-ui/theme` is a name, `vela-rbxts@^0.9.0` is a name and a
range, `@lattice-ui/react-runtime@^0.8.0` is both at once.

Range comparison is deliberately not a semver implementation. It answers one question — is what is
installed at least the version this needs — for the shapes Facet writes (`^0.9.0`, `>=0.8.0`,
`0.1.1`), and reports anything else as unverifiable rather than guessing.

## Errors

| Message | Cause |
| --- | --- |
| `The registry at … is format version N, which this CLI does not understand` | `version` above `1` |
| `Could not reach the registry at …` | Network, or a wrong base |
| `The registry returned N for …` | Non-404 HTTP failure |
| `The registry returned something that is not JSON at …` | A host serving an HTML error page |
| `Component "x" was not found at …` | An index that disagrees with the files beside it |
| `Circular registry dependency: a -> b -> a` | An authoring bug; throws rather than being silently broken |
