# Typed plugins and local development

Generate collection and field names for plugin code from your database, and reload a plugin without restarting the connector.

Source: https://docs.getaviato.com/integrations/plugin-development

Plugin code names collections and fields as strings: `plugin.action("orders", …)`. Nothing checks them while you write the code, so a typo shows up only at runtime, as an action missing from the dashboard or a hook that never runs. `aviato-connector typegen` reads the same schema the connector introspects and writes it out for your language, so the compiler catches the typo instead.

## Generate types [#generate-types]

Run `typegen` with the database settings the connector uses. It needs only `AVIATO_DATABASE_URL` (and any `AVIATO_DATASOURCE_<NAME>_URL`), not a project token, so it also runs in CI against a migrated test database.

```bash
aviato-connector typegen --out src/aviato.ts               # TypeScript
aviato-connector typegen --out internal/aviato/schema.go   # Go
aviato-connector typegen --out app/Aviato                  # PHP, one class per collection
```

The output path selects the language: a `.ts` or `.go` file, or a directory for PHP. Pass `--lang` to override it, `--package` to name the Go package (the directory name by default), and `--namespace` for PHP (`App\Aviato` by default). Run it again after each migration, and commit the result. `--check` writes nothing and exits with status 1 when the files are out of date. Use it in CI to catch a migration whose types were not regenerated.

TypeScript

The generated `AviatoSchema` type lists each collection with its fields, typed as the SDK receives them in JSON: dates, UUIDs and binary values are strings, and nullable columns accept `null`.

```ts title="aviato.ts"
// Generated by `aviato-connector typegen` from your database schema. Do not edit: run it again after a migration.
// Pass it to the SDK so collection and field names are checked: createAviatoPlugin<AviatoSchema>({ ... }).

export type AviatoSchema = {
    /** The `customers` collection. */
    customers: {
        fields: {
            id: number;
            email: string;
            plan: "free" | "pro";
            stripe_customer_id: string | null;
        };
    };
    /** The `orders` collection. */
    orders: {
        fields: {
            id: number;
            customer_id: number;
            total: number;
            refunded_at: string | null;
        };
    };
};

export type AviatoCollection = keyof AviatoSchema;

/** A record of `C` as the SDK receives it. */
export type AviatoRecordOf<C extends AviatoCollection> = AviatoSchema[C]["fields"];

```

Pass it to `createAviatoPlugin`. Collection names in actions, computed fields, hooks, segments, search, charts, summaries and write overrides must then exist, and computed-field dependencies and write-override fields must belong to their collection:

```ts title="plugin.ts"
import { createAviatoPlugin } from "@getaviato/sdk";
import type { AviatoRecordOf, AviatoSchema } from "./aviato";

export function createTypedPlugin(secret: string) {
    return createAviatoPlugin<AviatoSchema>({ secret, basePath: "/aviato" })
        .action({
            collection: "orders",
            name: "Refund",
            scope: "single",
            execute: async (context) => {
                const [order] = (await context.getRecords()) as AviatoRecordOf<"orders">[];
                if (!order || order.refunded_at !== null) {
                    return context.error("Select one order that is not refunded yet");
                }
                return context.success(`Refunded ${order.total}`);
            },
        })
        .computedField({
            collection: "customers",
            name: "email_domain",
            type: "string",
            // A field the customers table does not have fails to compile.
            dependencies: ["email"],
            compute: (records) => records.map((record) => String(record.email).split("@")[1] ?? null),
        })
        .layout("customers", {
            label: "Clients",
            icon: "users",
            // Field names are checked against the schema too.
            fieldOrder: ["email", "plan"],
            fields: { plan: { widget: "badge" }, stripe_customer_id: { label: "Stripe customer", readOnly: true } },
            searchFields: ["email"],
            defaultSort: "-id",
        });
}

```

Without the type parameter, the SDK accepts any string, as before. Collections served by a [custom datasource](/integrations/custom-datasources) are declared by your plugin, not the database. Add them to the type yourself: `createAviatoPlugin<AviatoSchema & { tickets: { fields: { id: string; subject: string } } }>(…)`.

Go

The generated file declares a constant per collection and per field. Go identifiers follow Go's initialisms (`customer_id` becomes `OrdersCustomerID`), and two names that map to the same identifier are numbered.

```go
plugin.Action(aviatoschema.Orders, "Refund", aviato.ScopeSingle, refund)
plugin.WriteOverride(aviatoschema.Customers, aviatoschema.CustomersPlan, writePlan)
```

The file starts with the standard `// Code generated … DO NOT EDIT.` header and is already `gofmt`-formatted.

Laravel

Each collection becomes a final class with a `COLLECTION` constant and one constant per field. Its docblock declares a PHPStan array shape for the record. A field named `collection` or `class` gets a numbered constant (`COLLECTION2`, `CLASS2`).

```bash
php artisan aviato:types                  # app/Aviato, namespace App\Aviato
php artisan aviato:types --check          # in CI
```

```php
use App\Aviato\Orders;

Aviato::action(Orders::COLLECTION, 'Refund')->single()->execute(/* … */);
Aviato::writeOverride(Orders::COLLECTION, Orders::STATUS)->write(/* … */);
```

`aviato:types` runs the installed connector with your Laravel database connection. Classes for collections that no longer exist are removed; other files in the directory are left alone.

## Develop a plugin locally [#develop-a-plugin-locally]

`aviato-connector dev` runs the connector for local development. It differs from `start` in three ways:

* **The manifest reloads every second.** Restart your plugin (or let `bun --watch`, `air` or `php artisan serve` restart it) and the new actions, hooks and fields appear in the dashboard without restarting the connector. While the plugin restarts, the connector keeps the last manifest it loaded.
* **It waits for the plugin.** `start` refuses to run when the plugin does not answer, so it never serves data without your hooks and write overrides. `dev` waits until the plugin answers instead, and serves nothing until then.
* **The database is re-introspected every ten seconds**, so a migration shows up as you run it. With `--types`, the generated files are rewritten when the schema changes.

```bash
aviato-connector dev --types src/aviato.ts
```

`dev` loads `aviato.env` from the current directory when it exists (`--env-file` picks another); variables already set in your shell take precedence. Separate several `--types` outputs with commas. With Laravel, run `php artisan aviato:serve --dev --types=app/Aviato`.

Use `aviato-connector start` in production. To reload a running production connector after a plugin deployment, send it `SIGHUP` (`kill -HUP <pid>`). It fetches the manifest and re-introspects the database at once, instead of waiting for the next minute's refresh.

## Check Aviato changes on pull requests [#check-aviato-changes-on-pull-requests]

A migration that drops a column, or a refactor that renames an action, changes what your team sees in the back-office. `aviato-connector snapshot` records what Aviato sees at one commit: the introspected database schema and, when `AVIATO_PLUGIN_URL` and `AVIATO_PLUGIN_SECRET` are set, the manifest of the running plugin. `aviato-connector diff` compares two snapshots and lists what was added, changed and removed.

```bash
aviato-connector snapshot --output base.json              # on the target branch
aviato-connector snapshot --output head.json              # on the pull request
aviato-connector diff --base base.json --head head.json   # without --head: compares with a fresh snapshot
```

Like `typegen`, both commands need only the database variables, not a project token. `--schema` reads the schema from a JSON file instead of a database, and `--manifest` reads the manifest from a file (the protocol JSON that `GetManifest` returns) instead of calling the plugin. Without a plugin, the snapshot's manifest is `null`.

`diff --format` prints `text` (the default), `markdown` for a pull request comment, or `json`. With `--fail-on-breaking`, it exits with status 1 when a change is breaking. A change is breaking when it can break a bookmark, a saved view, an API call or plugin code:

* a removed collection, field, relation, action, segment, chart or computed field;
* a changed field type (computed fields included), or a primary key change;
* a field that becomes non-nullable without a default, so creating a record without it fails;
* an action whose scope changes (single, bulk or global);
* a dangling reference: a layout, summary, widget, computed-field dependency, relation or other plugin definition of the pull request that names a collection or field its schema no longer has. Collections served by a [custom datasource](/integrations/custom-datasources) are not checked.

Additions, hooks, search, write overrides, and changes to layouts, summaries and widgets are reported without failing the check.

The Markdown output starts with an `<!-- aviato-diff -->` marker and lists breaking changes first. The workflow below migrates a test database to the target branch, takes a snapshot with the plugin of that branch running, then does the same for the pull request and posts the comparison as a single comment that each push updates:

```yaml title=".github/workflows/aviato-diff.yml"
name: Aviato changes
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  diff:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: --health-cmd pg_isready --health-interval 5s --health-retries 10
    env:
      CONNECTOR_IMAGE: getaviato/connector:0.3.0
      AVIATO_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
      AVIATO_PLUGIN_URL: http://localhost:3000/aviato
      AVIATO_PLUGIN_SECRET: ${{ secrets.AVIATO_CI_PLUGIN_SECRET }} # any whsec_ secret, shared with the test plugin
    steps:
      - name: Snapshot the target branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.base_ref }}
      - run: |
          npm ci && npm run db:migrate
          npm run start:plugin & plugin=$!
          npx wait-on tcp:localhost:3000
          docker run --rm --network host -e AVIATO_DATABASE_URL -e AVIATO_PLUGIN_URL -e AVIATO_PLUGIN_SECRET \
            "$CONNECTOR_IMAGE" snapshot > "$RUNNER_TEMP/base.json"
          kill $plugin

      - name: Snapshot the pull request
        uses: actions/checkout@v4
      - run: |
          npm ci && npm run db:migrate
          npm run start:plugin &
          npx wait-on tcp:localhost:3000
          docker run --rm --network host -e AVIATO_DATABASE_URL -e AVIATO_PLUGIN_URL -e AVIATO_PLUGIN_SECRET \
            "$CONNECTOR_IMAGE" snapshot > "$RUNNER_TEMP/head.json"

      - name: Compare
        run: |
          docker run --rm -v "$RUNNER_TEMP:/snapshots:ro" "$CONNECTOR_IMAGE" \
            diff --base /snapshots/base.json --head /snapshots/head.json --format markdown > aviato-diff.md
      - uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: aviato-diff
          path: aviato-diff.md
      - name: Fail on breaking changes
        run: |
          docker run --rm -v "$RUNNER_TEMP:/snapshots:ro" "$CONNECTOR_IMAGE" \
            diff --base /snapshots/base.json --head /snapshots/head.json --fail-on-breaking
```

Replace `db:migrate` and `start:plugin` with your own commands. The pull request's migrations run on top of the target branch's, as they will in production. Without a plugin, leave out the plugin variables and steps. To keep a known-good snapshot instead of rebuilding the target branch each time, commit `aviato-snapshot.json` and run `aviato-connector diff --base aviato-snapshot.json` against the migrated database.