Typed plugins and local development
Generate collection and field names for plugin code from your database, and reload a plugin without restarting the connector.
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
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.
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 collectionThe 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.
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.
// 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:
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 are declared by your plugin, not the database. Add them to the type yourself: createAviatoPlugin<AviatoSchema & { tickets: { fields: { id: string; subject: string } } }>(…).
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,airorphp artisan serverestart 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.
startrefuses to run when the plugin does not answer, so it never serves data without your hooks and write overrides.devwaits 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.
aviato-connector dev --types src/aviato.tsdev 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
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.
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 snapshotLike 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 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:
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-breakingReplace 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.