@nightowlsdev/storage-supabase
Adapter/StorageMastraA Supabase/Postgres-backed StorageAdapter for Night Owls, agents, runs, events, messages, a Realtime event bus, a versioned agent repo, and a durable Mastra store.
What it does
`createSupabaseStorage(opts)` returns a `StorageAdapter` implementing the core `agents` / `runs` / `events` / `messages` / `scratchpad` seams plus suspend/resume followup tracking, a redacted Realtime broadcast event bus (`events.subscribe`), cross-process cache invalidation via Postgres LISTEN/NOTIFY (`subscribeInvalidations`, so a `publishAgentVersion` on one instance evicts every other instance's RowCache), and a writable versioned agent repo (`agentsWritable`, surfaced as `agents`). It authenticates with the secret (BYPASSRLS) key and is the real authz boundary, scoping every query by tenant in code. Use the Session/Direct Postgres port 5432 (never the 6543 transaction pooler, it throws). `createMastraPgStore` provides a Postgres-backed Mastra store (defaults `disableInit:true`, schema `nightowls`) for durable suspend/resume, injected via `defineSwarm`/`SwarmEngine({ mastraStore })`; `createMastraVectorStore` + `createPostgresFloor` cover vector memory and the multi-lane turn-serialization floor. Ships inlined `MIGRATIONS` and a `nightOwlsPlugin` manifest the CLI ejects into `supabase/migrations/` (you apply them with your own tooling; Night Owls never runs DDL). Management helpers like `publishAgentVersion` reuse the adapter's typed `ctx` handle. There is no credit/billing ledger here, credit metering is host-owned in the platform app, not in this OSS package.
Install
pnpm add @nightowlsdev/storage-supabaseKey exports
- createSupabaseStorage
- SupabaseStorage
- createMastraPgStore
- createMastraVectorStore
- createPostgresFloor
- publishAgentVersion
- rollbackAgentVersion
- listAgentVersions
- makeVersionedRepo
- MIGRATIONS
- nightOwlsPlugin
Usage
import { createSupabaseStorage, createMastraPgStore } from "@nightowlsdev/storage-supabase";
import { defineSwarm } from "@nightowlsdev/core";
// Use the Session/Direct Postgres port 5432 (never the 6543 pooler).
const storage = createSupabaseStorage({ url: process.env.SUPABASE_URL!, secretKey: process.env.SUPABASE_SECRET_KEY! });
const mastraStore = createMastraPgStore({ connectionString: process.env.DATABASE_URL! });
const swarm = defineSwarm({ agents, mastraStore });
// Also exports applyBundle / upgradeBundle, publish a capability bundle version, then apply it into
// a tenant's live agents and upgrade downstream. Full guide → /docs/capability-bundlesWhat it provides
storage-supabase is the durable, multi-tenant StorageAdapter every self-hosted Night Owls deployment runs on. createSupabaseStorage(opts) implements the core agents / runs / events / messages / scratchpad seams over Postgres, plus a redacted Realtime event bus, cross-process cache invalidation over LISTEN/NOTIFY, an append-only versioned agent repo, and a cross-instance container floor for serverless mutual exclusion. createMastraPgStore backs durable suspend/resume so a run parked on an approval survives a process restart or a cold start. It also ships the opt-in FR-072 message-search plane — createMessageSearch({ pool, embedder, embedderTag, dimensions }) over a pgvector sidecar — so conversation history becomes semantically searchable without touching the Mastra-owned message table. It ships its own inlined MIGRATIONS that the CLI ejects — Night Owls never connects to your DB or runs DDL.
When to use it
- Any real deployment — production, staging, or a shared dev stack; anything past a single machine.
- You need durable suspend/resume that survives a restart or a serverless cold start (pair it with a durable runner).
- Multi-tenant hosting: every query is org-scoped in code and the adapter is the real authorization boundary.
- You want an append-only agent version catalog with rollback, a live redacted event stream, and cross-instance cache invalidation.
- You want semantic search over conversation history — the opt-in message-search plane indexes nightowls.mastra_messages into a pgvector sidecar and searches it org-scoped, participation-gated at the route.
When not to
- A CLI bootstrap or local demo on just a model key — reach for @nightowlsdev/storage-local (or InMemoryStorage from core).
- You have no Postgres and do not want to apply migrations.
- You can only reach the database through the transaction pooler (port 6543) — pg and @mastra/pg prepared statements break there, and the factory throws if it detects :6543.
Alternatives
- @nightowlsdev/storage-localThe CLI builder bootstrap or a single-machine demo — a LibSQL snapshot store you run before Supabase, on just a model key.
- InMemoryStorage (from @nightowlsdev/core)Tests or a single session where durability and multi-instance behavior do not matter — a zero-dependency floor.
- A hand-rolled StorageAdapterYour durable backend is not Postgres. This package is the reference implementation of the core StorageAdapter seam; implement the same interface against your store.
Strengths
- A complete StorageAdapter in one package: agents / runs / events / messages / scratchpad plus suspend/resume followup tracking.
- Durable across restarts AND instances: the @mastra/pg snapshot store for resume, a Postgres container floor for serverless mutual exclusion, and LISTEN/NOTIFY so a publish on one instance evicts every other instance's RowCache.
- The real authorization boundary — it authenticates with the BYPASSRLS secret key and scopes every query by tenant in code; the packaged RLS is defence-in-depth for the browser / Realtime read path.
- Append-only agent versioning: publishAgentVersion / rollbackAgentVersion behave like git revert (a new head, never a reset), sealed against four immutability bypasses by migrations 0023 and 0026.
- FR-055 sub-org scoping (orgScope) is inert until used — every legacy row reads back NULL and behaves exactly as before.
- Migrations ship inlined and the CLI ejects them into supabase/migrations/; Night Owls never runs DDL itself.
- Message search runs entirely off the write path: no embedder outage, dimension mismatch, schema gap or contended lock can fail a turn, a resume or a drive — searchMessages reports { ok:false, reason } instead of throwing, and the indexer records a poison row rather than raising.
- Search authorization is LIVE, not copied: the sidecar's org_id/container/thread/role columns only accelerate the ANN scan, and every match is re-qualified against the live threads/messages rows (org, Rule A on org_scope, canonical container) plus a content_hash re-check, so a Mastra in-place rewrite or a moved thread excludes the stale row instead of disclosing it.
Limits & trade-offs
- Requires Postgres and applying ~35 migrations with your own tooling (supabase db push).
- You MUST use the Session/Direct port 5432 — the 6543 transaction pooler breaks pg / @mastra/pg prepared statements, so createSupabaseStorage and the Mastra stores throw if they see it.
- It imports @mastra/pg at runtime (a peripheral peer): engine-wall clean in the public .d.ts, but a real Mastra runtime dependency.
- No credit or billing ledger — metering is host-owned in the platform app, deliberately not in this OSS package.
- v1 simplifications: runner is hardcoded 'nextjs' (NewRun has no runner field) and events.subscribe shares the adapter's single secret-role client.
- Message search is extra wiring and a SECOND, ejectable migration: you name the vector width and supply the embedder, messageSearchMigration({ dimensions }) is deliberately NOT in MIGRATIONS, and changing embedder dimensions is a REBUILD (drop, re-eject, re-index) — pgvector's ANN indexes are dimension-bound, so there is no ALTER path.
- createMessageSearch is documented LOW-LEVEL and NOT participation-complete: it enforces tenancy, Rule A and container equality, and knows nothing about who is asking. Calling searchMessages straight from an authenticated handler hands one org member another member's private conversation — the participation gate belongs at the route (runner-nextjs's searchRoute() runs it).
How it works
createSupabaseStorage opens a pg Pool (search_path nightowls,public) plus a supabase-js client and returns a SupabaseStorage — a StorageAdapter with a typed ctx handle and a close() teardown. Because the pool uses the BYPASSRLS secret key, every store method scopes by tenant in code; the SQL RLS is only defence-in-depth for the browser and Realtime paths. Durable suspend/resume comes from createMastraPgStore (a @mastra/pg PostgresStore in the nightowls schema, disableInit:true), injected via defineSwarm / SwarmEngine({ mastraStore }); createPostgresFloor gives serverless mutual exclusion; subscribeInvalidations registers a LISTEN subscription so a publishAgentVersion NOTIFY on one instance evicts every other instance's cache. The tables come from the inlined MIGRATIONS the CLI ejects into supabase/migrations/, which you apply with your own tooling.
Message search (FR-072)
An opt-in plane over the same pool that makes conversation history semantically searchable. nightowls.mastra_messages stays untouched — Mastra owns it — so the plane writes a sidecar instead. It is wired in three moves: eject a second migration, construct the plane, let a runner drive the indexing.
messageSearchMigration({ dimensions }) is EJECTABLE and deliberately NOT part of MIGRATIONS: the embedding column is vector(n) and only the host knows n, so this follows knowledge's dimension-parameterized precedent — call it, append the result to the set your CLI ejects into supabase/migrations/, and apply it with your own tooling. It creates two service-only tables (RLS on with no policy plus an explicit revoke of the schema's blanket grants — 0034's lesson applied at birth): nightowls.mastra_message_embeddings, a sidecar keyed (message_id, model) whose foreign keys cascade from mastra_messages and orgs, and nightowls.message_embed_state, the per-(org, container, model) manifest holding the cursors and the repair-cycle state.
createMessageSearch({ pool, embedder, embedderTag, dimensions }) returns the four operations. The embedder is a STRUCTURAL host-injected function — (texts: string[]) => Promise<number[][]>, batch in and batch out, same length and same order — so no provider is hard-pinned: core's createEmbeddingFactory behind a small embedMany adapter, a raw Ollama call, or a local model all satisfy it. embedderTag is required and is stored on every row: an unlabelled corpus cannot be filtered at search time, re-embedded, or retired. dimensions must equal the migration's vector(n); the plane validates that against the live column once and caches it, and assertMessageSearchSchema({ pool, dimensions }) is exported separately for hosts that want the check to fail at boot instead of on first use.
indexContainer(tenantId, container) is the incremental indexer. It runs two passes under ONE per-(org, container, model) advisory lock held on a single checked-out client, spending a budget counted in EXAMINED CANDIDATES (default 200) split 75/25 creation/repair, an unspent share rolling to the other pass — unchanged, failed and delete-candidate rows all cost a slot, so the call is bounded regardless of outcome. The creation pass keysets forward over newly qualifying messages. The repair pass rotates between a live-side walk (rediscovering rows that are missing, moved in, or drifted on any copied field) and an orphan-side walk (deleting embeddings whose message no longer qualifies for that org and container); when a cycle opens it stamps a high-water boundary, which is what gives both walks a fixed end and makes them wrap in bounded work even under sustained writes. The returned done is true only when the creation probe found nothing, the last CLOSED cycle was clean, and no cycle is mid-flight — advisory under concurrent writes, never a proof.
No transaction is ever held across the embedder: a batch embeds, then ONE transaction re-guards each row against its LIVE state, upserts, and advances the cursor. A failing batch is bisected to isolate the actual poison, which is recorded in failed and stepped past (the repair pass rediscovers it later); three isolated failures in one call abort it as a presumed embedder outage, with no further cursor advance, so a resume retries from where it stopped. backfillTenant(tenantId) spends a GLOBAL budget across the union of containers found in live threads, the manifest and the embeddings table — an orphan-only container is never stranded — and its returned afterContainer echoes the last FULLY COMPLETED container, so feeding the result back verbatim resumes inside an unfinished container instead of skipping it. pruneModel(tenantId, model) retires one embedder tag container by container under the same locks; prune only once every host has stopped configuring that tag, since a still-configured host would lawfully re-index.
AUTHORIZATION IS LIVE, and this is the invariant the whole design turns on. The sidecar's org_id / org_scope / container / thread_id / role / created_at columns are a denormalized COPY that accelerates the ANN candidate scan and authorizes nothing: no match survives without joining the live message row and its thread, matching the tenant's org, passing Rule A on threads.org_scope, and resolving to the same canonical container. Every projected field — role, thread, container, timestamp, preview — is read from the live rows, never from the copy.
DRIFT is handled by a second, separate hash domain, because Mastra rewrites mastra_messages IN PLACE (content, role, thread and resourceId can all change without the timestamps moving). source_hash is md5 of the RAW message content, stored at index time and re-checked against md5 of the live content at search time; a mismatch excludes the row, so a rewritten message is never returned under its old text and a moved thread never discloses under its old scope. The repair pass then re-embeds it and refreshes every copied field. The embedded text is derived and capped separately from that hash, so truncating a long message never makes it look stale — otherwise it would be re-embedded on every cycle forever.
searchMessages(tenantId, opts) never throws for a bad query or an unusable plane. It returns a discriminated { ok: true, matches } | { ok: false, reason: "unavailable" | "bad-input", detail }, which runner-nextjs's searchRoute() maps to 503 and 400. Each match carries a preview from the LIVE content plus anchorSeq — the latest events.seq at or before the message, as a decimal string — which is what lets a client page the conversation around the hit. Nothing on this plane runs on the write path: no embedder outage, dimension mismatch, missing table or contended lock can fail a turn, a resume or a drive.
Examples
Create the adapter and wire durable resume
The full StorageAdapter, a durable Mastra store for resume, and a Postgres floor for serverless mutual exclusion.
import {
createSupabaseStorage,
createMastraPgStore,
createPostgresFloor,
} from "@nightowlsdev/storage-supabase";
import { defineSwarm, SwarmEngine } from "@nightowlsdev/core";
const storage = createSupabaseStorage({
url: process.env.SUPABASE_URL!, // API URL, for supabase-js Realtime
secretKey: process.env.SUPABASE_SECRET_KEY!, // sb_secret_… / service_role (BYPASSRLS)
dbUrl: process.env.DATABASE_URL!, // Session/Direct port 5432 — never 6543
});
const swarm = defineSwarm({
agents,
storage,
mastraStore: createMastraPgStore({ dbUrl: process.env.DATABASE_URL! }),
// createPostgresFloor is a SwarmEngine option (serverless mutual exclusion), so inject it via the engine factory.
engine: (opts) => new SwarmEngine({ ...opts, floor: createPostgresFloor(storage.ctx.pool) }),
});Publish and roll back an agent version
Append-only: publish inserts a new version and flips the head; rollback republishes a prior one as a NEW head, like git revert, never a reset.
import {
publishAgentVersion,
rollbackAgentVersion,
listAgentVersions,
} from "@nightowlsdev/storage-supabase";
await publishAgentVersion(storage.ctx, {
tenantId: orgId,
slug: "support",
role: "specialist",
personality: "warm",
capabilities: [],
skillNames: [],
delegateSlugs: [],
modelId: "openai/gpt-4o",
});
const versions = await listAgentVersions(storage.ctx, orgId, "support");
// Restore v1's content as a new head — audited as action='rollback', NOTIFYs cache invalidation.
await rollbackAgentVersion(storage.ctx, { tenantId: orgId, slug: "support", toVersion: 1 });Live event stream + cross-instance cache invalidation
Subscribe to redacted events with authoritative seq; LISTEN/NOTIFY keeps every instance's agent cache coherent.
// Redacted live event stream (server-side, BYPASSRLS) — secrets stripped, seq authoritative.
for await (const event of storage.events.subscribe(runId)) {
render(event);
}
// A publishAgentVersion on one instance evicts every OTHER instance's RowCache.
// Call once before any run; close() also tears it down. key = "tenantId:slug".
const stop = storage.subscribeInvalidations((key) => cache.evict(key));Message search: eject the migration, build the plane, search
messageSearchMigration is NOT in MIGRATIONS — you name the vector width. The embedder is any (texts) => Promise<number[][]>; a provider factory plus one embedMany call is the whole adapter.
import {
MIGRATIONS,
messageSearchMigration,
createMessageSearch,
} from "@nightowlsdev/storage-supabase";
import { openaiEmbeddings } from "@nightowlsdev/provider-openai";
import { embedMany, type EmbeddingModel } from "ai";
// 1. Eject BOTH sets — the packaged migrations, then the dimension-parameterized one.
export const migrations = [...MIGRATIONS, messageSearchMigration({ dimensions: 1536 })];
// 2. The embedder is STRUCTURAL: batch in, batch out, same length and order. Nothing pins a provider —
// core's createEmbeddingFactory (per-key routing) returns the same AI-SDK object and adapts identically,
// and a raw fetch to a local Ollama /api/embed returning number[][] is just as valid.
const model = openaiEmbeddings({ model: "text-embedding-3-small", dimensions: 1536 }) as EmbeddingModel;
const search = createMessageSearch({
pool: storage.ctx.pool,
embedder: async (texts) => (await embedMany({ model, values: texts })).embeddings,
embedderTag: "openai/text-embedding-3-small", // stored per row; search filters on it
dimensions: 1536, // MUST equal the migration's vector(n)
});
// 3. Search. It never throws for a bad query or an unusable plane — it says which.
const res = await search.searchMessages(orgId, { query: "what did we decide about refunds?", container });
if (res.ok) {
for (const m of res.matches) console.log(m.role, m.contentPreview, m.anchorSeq);
}Backfill history that predates the plane
Feed afterContainer back VERBATIM: it echoes the last FULLY COMPLETED container, so a budget-exhausted container is resumed rather than skipped.
let afterContainer: string | null = null;
for (;;) {
const page = await search.backfillTenant(orgId, {
maxMessages: 1000, // GLOBAL cap on examined candidates for this call
maxContainers: 20,
...(afterContainer ? { afterContainer } : {}),
});
for (const c of page.containers) {
// { container, indexed, repaired, deleted, skipped, failed, examined, done, cursor }
if (c.failed.length) log.warn("poison rows", c.container, c.failed);
}
afterContainer = page.afterContainer;
if (page.done) break;
}Doing the parts it doesn't support
- Credit / billing meteringThere is no billing ledger here — metering is host-owned in the platform app. Meter on the swarm.usage / swarm.turn_usage events the engine already emits and persists.
- Running on just a model key, before PostgresUse @nightowlsdev/storage-local for the CLI builder bootstrap runtime, then graduate to this adapter once Supabase is wired.
- A non-Postgres durable backendImplement the core StorageAdapter seam yourself against your store. This package is the reference implementation to model it on.
- Applying the schemaNight Owls never connects to your DB. Run owl install storage-supabase to eject MIGRATIONS into supabase/migrations/, then apply them with supabase db push (or your own tooling). messageSearchMigration({ dimensions }) is NOT in that set — call it yourself and append it, because only you know the embedder's vector width.
- Search that knows who is askingcreateMessageSearch is low-level: it enforces org, Rule A and container, and nothing about participation. Mount runner-nextjs's searchRoute(), which runs the same fail-closed resolveContainerAccess every container route runs and denies BEFORE the plane is invoked — and gates scope:"tenant" behind an explicit allowTenantSearch authorizer (missing, false, or throwing all deny).
- Changing or retiring an embedderRows are keyed (message_id, model) and every manifest cursor is per-model, so a new embedderTag backfills independently alongside the old corpus; pruneModel(tenantId, model) then deletes the retired tag's embeddings and manifest rows container by container. Prune only after every host has stopped configuring that tag — a still-configured host would lawfully re-index it. A change in DIMENSIONS is different: pgvector's ANN index is dimension-bound, so it is a rebuild (drop the table, re-eject messageSearchMigration at the new width, re-index), never an ALTER.
Related
- core — The StorageAdapter seam this implements, plus defineSwarm({ mastraStore }) for durable resume and SwarmEngine's floor option for serverless mutual exclusion.
- storage-local — The single-process LibSQL bootstrap store you run before Supabase.
- runner-background — Durable background runs that park on a question and resume from this store's snapshot across process death.
- capability-bundles — applyBundle / upgradeBundle (exported here) publish a crew's version and apply it into a tenant's live agents.
- org-scope — The FR-055 sub-org partition (orgScope) this adapter implements — inert until a caller resolves a scope, and the Rule A the message-search joins re-apply live.
- knowledge — The pgvector template the message-search plane follows: a dimension-parameterized ejectable migration, an injected embedder, and the widening ladder that keeps a filtered ANN scan honest.
- runner-nextjs — Mounts searchRoute() over the plane — the participation gate the store deliberately does not carry.