# Writing plugins

> Plugin API v1, its hooks, and typed repository extensions.

Source: https://bettersupabase.com/docs/extending/plugins

```ts
import { column, definePlugin, and } from "better-supabase";

export const onlyPublished = definePlugin({
  name: "onlyPublished",
  transformQuery(op) {
    if (op.kind !== "select" || !("published" in op.table.columns)) return op;
    return { ...op, where: and(op.where, column("published", "eq", true)) };
  },
});
```

`definePlugin` fixes `apiVersion: 1`. `use()` rejects plugins built for
another version, and installing two plugins with the same name.

## Hooks [#hooks]

| Hook                       | Level      | Use it to                                                                   |
| -------------------------- | ---------- | --------------------------------------------------------------------------- |
| `context(context, args)`   | Context    | Derive values from the claims once per `connect()`, such as the tenant      |
| `transformQuery(op, args)` | IR         | Add filters, rewrite selections                                             |
| `beforeMutation(op, args)` | Mutation   | Fill columns, reject writes, change the kind (turn a delete into an update) |
| `afterMutation(event)`     | Mutation   | Audit, cache invalidation, outbox. Cannot change the result                 |
| `wrapExecutor(executor)`   | Execution  | Tracing, retries, caching                                                   |
| `mapError(raw, fallback)`  | Errors     | Map `RAISE` hint codes or custom SQLSTATEs to `DbError`s                    |
| `repository(api)`          | Repository | Add methods to matching tables                                              |
| `describe(api)`            | API model  | Document the parameters the plugin reads and the columns it manages         |

Hooks receive `args` with the table metadata, the request context, the
per-call options (any argument the repository does not know, like
`withDeleted`), the clock and the caller's `signal` when it passed one. A hook
that does I/O (a permission lookup, say) should pass `signal` on, so a
cancelled request stops it. Throw a `DbException` to fail the call with a
typed error; nothing is sent. Any other throw from `transformQuery` or
`beforeMutation` fails the call too: the result is
an `unexpected` error with the thrown message, the `table`, and `details`
naming the plugin and hook (`plugin "audit" beforeMutation threw`), and the
`error` event fires.

`defineSupabase().use()` and `new BetterSupabase(schema, plugins)` both
refuse a plugin with another `apiVersion` and two plugins with the same name.

`context` runs once per `connect()` and `$with()`, in plugin order, and its
result is the `db.$context` that every other hook, `db.$context` readers
(jobs, storage) and `afterMutation` events see. `tenant()` uses it to set
`context.tenant` from its own claim paths, so two definitions with different
paths never share a resolved tenant. A `context` hook that throws is logged
and leaves the context unchanged; `$withoutPlugins()` skips it.

`enforce` sets the hook order: `first`, then `pre`, then plugins without
`enforce`, then `post`; plugins at the same level keep their `use()` order.
Use `first` for a plugin that checks the query as the caller wrote it
(`rules()` does), and `pre` for one whose rewrite others should see
(`softDelete()` turns a delete into an update).

A plugin that rewrites a mutation into another kind sets `intent` on the
operation it returns, and mutation events report it. `softDelete()` returns
`{ kind: 'update', intent: 'softDelete', ... }`, which becomes the
`row.softdeleted` CloudEvent. Without `intent`, the event reports the kind
that ran.

`scopes` lists the table flags whose tables a plugin's `transformQuery`
filters (`tenant()` declares `['tenant']`). [Read sets](/docs/repository/read-sets)
run as SQL functions without plugins, so `defineReadSet` warns when a set
reads a table a plugin filters. Declare `scopes: []` for a plugin that only
inspects queries; a plugin with `transformQuery` and no `scopes` is assumed
to filter every table.

## API descriptions [#api-descriptions]

`describe(api)` tells [`defineApi`](/docs/specs) what the plugin does to the
tables the API serves, so the OpenAPI document matches the behavior. It
runs once, while the model is built. `api.resources` lists the served
tables (their metadata and operation names), and every method ignores
tables and columns the API doesn't serve:

| Method                                        | What it changes                                                                                                                      |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `addParameter(table, parameter, operations?)` | Adds a `query`, `header` or `cookie` parameter (with a JSON Schema 2020-12 `schema`) to the table's operations, or to the named ones |
| `markReadOnly(table, column, description?)`   | Marks the column `readOnly` and drops it from the required fields of request bodies; `description` is used when the column has none  |
| `describeColumn(table, column, patch)`        | Deep-merges JSON Schema fields into the column's schemas                                                                             |

`api.extensionPrefix` is the prefix of generated `x-` fields, and
`api.schema` the whole schema metadata.

```ts title="src/plugins/region.ts"
import { definePlugin } from "better-supabase";

export const region = definePlugin({
  name: "region",
  describe(api) {
    for (const { table } of api.resources) {
      if (!("region" in table.columns)) continue;
      api.markReadOnly(table.key, "region", "Set from the caller's region.");
      api.addParameter(table.key, {
        name: "X-Region",
        in: "header",
        description: "Overrides the caller's region.",
        schema: { type: "string" },
      });
    }
  },
});
```

A `describe` that throws doesn't stop the build: `defineApi` reports a
`plugin-describe-failed` warning (see [Diagnostics](/docs/specs/diagnostics))
and builds the rest of the model. The built-in plugins describe themselves:
[`timestamps()`](/docs/plugins/timestamps), [`softDelete()`](/docs/plugins/soft-delete)
and [`tenant()`](/docs/plugins/tenant).

## Scoping helpers [#scoping-helpers]

Filters that should apply to related rows too (tenancy, soft delete,
visibility) should go through `scopeOperation`. It applies a condition to the
root table, every include and every relation filter, and keeps `every()`
correct:

```ts
import { column, scopeOperation } from 'better-supabase';

transformQuery: (op) =>
  scopeOperation(op, (table) =>
    table.columns.visibility ? column('visibility', 'eq', 'public') : undefined,
  ),
```

Column names in the IR are database names; `table.columns[app].db` maps
app names.

## Typed extensions [#typed-extensions]

A plugin can add methods and call options to tables whose generated flags
match. Declare the types as an interface that reads `this['M']` (the models)
and `this['T']` (the table):

```ts
import type { AsyncResult, HasFlag, Plugin, RepositoryExtension } from 'better-supabase';

interface ArchiveExtension extends RepositoryExtension {
  readonly methods: HasFlag<this['M'], this['T'], 'softDelete'> extends true
    ? { readonly archiveAll: () => AsyncResult<{ count: number }> }
    : unknown;
  readonly findArgs: unknown;
  readonly deleteArgs: unknown;
}

export function archive(): Plugin<'archive', ArchiveExtension> {
  return definePlugin<'archive', ArchiveExtension>({
    name: 'archive',
    repository: ({ table, base }) => (table.flags.softDelete ? { archiveAll: () => /* ... */ } : undefined),
  });
}
```

`findArgs` extends the read arguments and `deleteArgs` the delete arguments.
Each `use()` intersects the extension into the Db type, so several plugins
compose.

## Invariants [#invariants]

* Hooks never see or return app-cased rows in the IR; convert with the table
  metadata.
* `afterMutation` and event handlers cannot change results: they get a copy
  of the rows, and `testPlugin` checks it. Errors they throw go to the
  [`Logger`](/docs/extending/interfaces#logger), not the caller.
* Hook order depends only on `enforce` and `use()` order, never on a
  plugin's name.
* Prove a plugin with [`testPlugin`](/docs/extending/conformance). It also
  installs the plugin next to `timestamps()`, `softDelete()`, `tenant()` and
  `actor()` in both orders, and fails a `repository` hook that replaces a
  base method.
* A plugin must leave tables without its flag untouched.