ctx
ctx is how a plugin reaches anything outside its sandbox. Every function you implement receives it, and components get it from useFlare().
Each call leaves the sandbox as a message. Flare checks it against the plugin's permissions and runs it on the user's behalf.
definePlugin({
commands: {
orders: async (_, ctx) => {
const recent = await ctx.data.read({ container: 'orders', limit: 10 });
return { message: `${recent.length} recent orders` };
},
},
});ctx.target
The database open in the window, or null when there isn't one.
| Field | Type | What it is |
|---|---|---|
engine | PluginEngine | firebase, postgres, mysql, sqlite, supabase, mongodb, cassandra, redis or s3. |
name | string | The connection's name, as the user set it, such as "Orders production". |
database | string or null | A database, schema or Firestore database inside the connection, where the engine has them. |
production | boolean | Whether the user tagged it as production. |
It never includes the host or credentials. In a component, target is reactive and updates when the user opens another database.
ctx.granted
What the user allowed: the plugin's scopes minus any optional ones they unticked, and whether it may work on production databases.
| Field | Type | What it is |
|---|---|---|
scopes | Scope[] | Such as data:read, data:write, network:api.stripe.com, clipboard:write. |
production | boolean | Whether the plugin may read and change databases marked production. |
It updates when the user changes their choices in Settings, Plugins. In a component it's reactive.
ctx.can(scope)
Whether one scope was granted. Check it before you offer something the user turned off:
if (ctx.can('network:api.stripe.com')) {
const response = await ctx.fetch('https://api.stripe.com/v1/balance');
}ctx.data
The open database. Reads need data:read and writes need data:write. On a production database, the user must also have allowed the plugin there, or calls reject with PRODUCTION_DENIED.
containers(parent?)
Lists the tables, collections or key namespaces. Pass parent on engines with levels, such as a Firestore document with subcollections.
const tables = await ctx.data.containers();
Each Container has a path (what the other calls take), a name and a kind: collection, table, view or keyspace.
read(request)
Reads records from one container. Each DataRecord has an id, a path and its values by field.
const unpaid = await ctx.data.read({
container: 'orders',
filters: [{ field: 'status', operator: '==', value: 'unpaid' }],
orderBy: [{ field: 'created_at', direction: 'desc' }],
select: ['id', 'total', 'created_at'],
limit: 50,
});| Field | What it is |
|---|---|
container | The container's path. Required. |
filters | Conditions, each { field, operator, value }. All must match. |
orderBy | Sort order, each { field, direction } with asc or desc. |
select | The fields to read. Empty or omitted reads every field. |
limit | The most records to return, up to 500. |
offset | How many to skip, for paging. |
The operators are ==, !=, <, <=, >, >=, in, not-in, array-contains, array-contains-any and contains, where the engine supports them. Unsupported ones reject with NOT_SUPPORTED.
aggregate(request)
Runs a count, sum or average in the database, so the result is exact on any size of container. Resolves with a number, or null when there's nothing to average.
const revenue = await ctx.data.aggregate({
container: 'orders',
aggregate: 'sum',
field: 'total',
filters: [{ field: 'status', operator: '==', value: 'paid' }],
});query(text)
Runs a query in the database's own language: SQL, CQL, MongoDB shell, Redis commands or a Firestore chain. Only reads run. Flare refuses anything that would change data. Resolves with a QueryResult: columns, rows as arrays of values, and truncated, which is true when Flare hit its row limit.
const { columns, rows } = await ctx.data.query(
'select status, count(*) from orders group by status'
);update(container, id, values)
Updates the given fields on one record and leaves the rest alone.
insert(container, values)
Adds a record. Resolves with the new DataRecord, including the id the database gave it.
remove(container, ids)
Deletes records by id. Resolves with the number deleted.
await ctx.data.update('orders', 'ord_1042', { status: 'refunded' });
const note = await ctx.data.insert('notes', {
order: 'ord_1042',
text: 'Refunded by phone',
});
const removed = await ctx.data.remove('drafts', ['d_1', 'd_2']);update, insert and remove go through Flare's guarded write, so production asks first and Activity records the change. See Scopes and permissions. If the user declines, the call rejects with a PluginError whose code is WRITE_CANCELLED.
ctx.storage
A small key value store for this plugin only, kept on this computer, up to 5MB. Values can be anything structuredClone copies. Uninstalling the plugin deletes it.
| Method | What it does |
|---|---|
get(key) | The value under key, or undefined. |
set(key, value) | Stores value under key. |
remove(key) | Deletes key. |
keys() | Every stored key. |
const seen = (await ctx.storage.get<string[]>('seen')) ?? [];
await ctx.storage.set('seen', [...seen, 'ord_1042']);ctx.preferences
The values the user set for the plugin's preferences, keyed by name. Each is a string, number, boolean or null. password preferences are stored encrypted and only given to this plugin. Reactive in a component.
ctx.fetch(url, init?)
The only way a plugin reaches the network. Flare makes the request for you, over https, to the hosts in permissions.network. Anything else rejects with HOST_NOT_ALLOWED. Responses are capped at 5MB, and requests time out after 30 seconds unless you set timeout.
const response = await ctx.fetch('https://api.stripe.com/v1/customers/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${ctx.preferences.key}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ query: "email:'customer@example.com'" }).toString(),
timeout: 10_000,
});
if (response.ok) {
const { data } = await response.json<{ data: { id: string }[] }>();
}init field | What it is |
|---|---|
method | GET, POST, PUT, PATCH, DELETE or HEAD. Defaults to GET. |
headers | Request headers, by name. |
body | A string. For JSON, use JSON.stringify and set a content type. |
timeout | Milliseconds before giving up. Defaults to 30 seconds. |
The response has status, ok and headers, plus text() and json() to read the body.
ctx.copy(text)
Copies text to the clipboard. Needs the clipboard permission. Nothing can read the clipboard.
ctx.toast(message, options?)
Shows a short message at the bottom of Flare's window. Options are a tone (neutral, positive, warning, danger or info) and a detail line. Returns nothing.
ctx.toast('Refund sent', { tone: 'positive', detail: 'ord_1042, 49.00 EUR' });ctx.openUrl(url)
Opens an https link in the user's browser, after they confirm leaving Flare. Plugin frames can't navigate, so links clicked in a plugin's view open this way too.
ctx.log(...values)
Writes to the plugin's log in Settings, Plugins. In development it also prints in the terminal running flare-plugin dev. Anything written to console goes to the same place.
Errors
A refused or failed call rejects with a PluginError from @flare/plugin. Its code says why:
| Code | Why |
|---|---|
PERMISSION_DENIED | It needs a scope the plugin wasn't granted, or that the user unticked. |
HOST_NOT_ALLOWED | ctx.fetch to a host the plugin didn't declare, or not over https. |
NOT_SUPPORTED | The open database can't do this, such as a query language it lacks. |
NO_DATABASE | The call needs a database and none is open. |
PRODUCTION_DENIED | The open database is production, and the user hasn't allowed the plugin there. |
WRITE_CANCELLED | The user declined the change. The error's name is WriteCancelled. |
TEAM_DENIED | The user's team role doesn't allow it on this database. |
TIMEOUT | It took too long. |
FAILED | Flare couldn't do it. The message says why. |
try {
await ctx.data.remove('sessions', ['s_1']);
} catch (error) {
if (error instanceof PluginError && error.name === WRITE_CANCELLED) {
// The user said no. Nothing changed.
} else {
throw error;
}
}