Skip to docs content
Browse documentation
On this page

Package storage and SQL

Saved packages keep durable state with packageStorage() (JSON key/value per package) and sql() (a SQLite database per package). Limits, isolation, moving from Postgres, and how storage counts against your plan.

Saved packages have two places to keep state between runs:

  • packageStorage(): JSON values by key, private to one package. Good for cursors, checkpoints, settings and small caches.
  • sql(): a SQLite database that belongs to one package. Good for tables you query, join and index.

Both only work in saved package code (exports, jobs, event handlers and apps; public package apps get packageStorage() but not sql()). Ad hoc execute code has no package storage.

packageStorage()

import { packageStorage } from 'minion:runtime'

const store = packageStorage()

const cursor = (await store.get('cursor')) ?? 0
// ... fetch items newer than cursor ...
await store.set('cursor', latestId)

const { keys, cursor: next } = await store.list({ prefix: 'cache:', limit: 100 })
await store.delete('cache:old')

The methods:

  • get(key): the value, or null if the key isn't set.
  • set(key, value): stores any JSON value. Returns { key, bytes }.
  • list({ prefix?, limit?, cursor? }): keys with their size and last update, 100 per page by default and up to 1,000. Pass the returned cursor to get the next page.
  • delete(key): returns true if the key existed.
  • clear(): deletes every key in this package's storage.
  • export({ cursor? }): every key and value as JSON, in pages of up to 512 KB.

Limits: keys up to 256 characters, values up to 256 KB of JSON.

Each package only ever sees its own keys. The package id is filled in for you, and the host checks it against the run, so one package can't read another's storage by changing it.

sql()

import { sql } from 'minion:runtime'

await sql(`create table if not exists issues (
  id integer primary key,
  title text not null,
  seen_at text default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))`)
await sql('insert into issues (id, title) values ($1, $2) on conflict (id) do nothing', [4812, 'Login fails on Safari'])
const { rows } = await sql("select id, title from issues where seen_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day') order by id")

sql(query, params?) runs one SQLite statement and returns { rows, rowCount }. Use $1, $2 (or ?, ?1) for values. Params can be strings, numbers, booleans (stored as 1 and 0) or null.

Each saved package has its own database. Sibling packages, Minion's own tables and other organizations are separate databases it can't reach. To share data between packages, have one package own the table and expose it through an export, or use events. Deleting a package deletes its database.

Outside package code, the same call is minion.storage.sql({ packageId, query, params }).

Limits per call:

  • One statement per call. Several statements separated by ; are rejected.
  • 5 second limit.
  • Up to 1,000 rows and 5 MB of results returned. Add a LIMIT or narrow the query if you hit it.
  • Queries up to 64 KB, with up to 100 params.

Each call runs in its own transaction, and a statement that fails or hits a limit is rolled back. BEGIN/COMMIT and ATTACH aren't available.

Results to know about:

  • BLOB values come back as base64 strings.
  • Integers beyond 2^53 lose precision in JavaScript. Select them with cast(x as text).

A server operator can turn sql() off. Calls then fail with "sql() is not enabled on this server", and packageStorage() is the fallback.

Coming from Postgres

Until October 2026, sql() ran Postgres. Existing tables were copied over for you. If a package still uses Postgres syntax, these are the usual changes:

  • int, bigint, serial → integer (integer primary key autoincrement for ids)
  • boolean → integer holding 0 or 1
  • timestamptz, date → text with an ISO time in UTC
  • now() → strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
  • now() - interval '1 day' → strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')
  • jsonb → JSON text, read with json_extract(col, '$.a') or col ->> '$.a'
  • arrays → a JSON array, expanded with json_each
  • bytea → blob
  • gen_random_uuid() → crypto.randomUUID() in JavaScript
  • string_agg → group_concat
  • x::int → cast(x as integer)

on conflict ... do update and returning work as they are. Views, functions, triggers and expression indexes weren't copied; recreate any you need.

Storage limits

Key/value storage, your packages' sql() databases and stored inbox email all count against one storage limit for the organization:

  • Free: 16 MiB
  • Standard: 1 GiB
  • Pro: 100 GiB
  • Max (assigned by the operator, not sold): 100 GiB

For sql(), what counts is the size of each package's database. A statement that would take you over the limit is rolled back and fails with a message saying so. A set() that would go over fails the same way. See current usage on the Usage page in the app (/app/usage).