@nightowlsdev/storage-supabase 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,90 +1,53 @@
1
1
  # @nightowlsdev/storage-supabase
2
2
 
3
3
  A Supabase/Postgres-backed `StorageAdapter` for `@nightowlsdev/core` — implements the
4
- `agents` / `runs` / `events` / `messages` seams, a redacted Realtime broadcast event
5
- bus (`events.subscribe`), and a Postgres-backed Mastra store for cross-process durable
6
- suspend/resume.
4
+ `agents` / `runs` / `events` / `messages` / `scratchpad` seams, a Postgres LISTEN/NOTIFY
5
+ cache-invalidation bus, a cross-instance `ContainerFloor` for serverless mutual exclusion,
6
+ and a Mastra-backed durable suspend/resume store.
7
7
 
8
8
  The server adapter authenticates with the **secret** key (`BYPASSRLS`) and is the real
9
9
  authorization boundary — it scopes every query by `tenantId`/`org_id` in code. The SQL
10
10
  RLS in the packaged `0001_core` migration is defense-in-depth for the browser /
11
11
  Realtime read path only.
12
12
 
13
- The tables live in a dedicated **`owl`** schema with **bare names**
14
- (`nightowls.orgs`, `nightowls.runs`, `nightowls.events`, `nightowls.agents`, …). The adapter pool
15
- sets `search_path=nightowls,public`, so the adapter's own queries stay unqualified. The
13
+ The tables live in a dedicated **`nightowls`** schema. The adapter pool sets
14
+ `search_path=nightowls,public`, so the adapter's own queries stay unqualified. The
16
15
  schema + tables are created by this package's **inlined migrations** (`MIGRATIONS`),
17
16
  which `@nightowlsdev/cli` **installs** into your host's `supabase/migrations/` — you apply them
18
17
  with your own tooling (`supabase db push`). Night Owls never runs DDL itself.
19
18
 
20
- ## Connection rules
19
+ ## Install
21
20
 
22
- ### Use the Session/Direct pooler port (5432) — NEVER the Transaction pooler (6543)
23
-
24
- `pg` and `@mastra/pg` rely on prepared statements, which **break on the Transaction
25
- pooler (port 6543)**. Always pass the **Session/Direct** Postgres URL (port **5432**).
26
- Both `createSupabaseStorage` and `createMastraPgStore` throw if they detect `:6543` in
27
- the `dbUrl`.
28
-
29
- ```
30
- ✅ postgresql://…@db.<ref>.supabase.co:5432/postgres (Session/Direct)
31
- ❌ postgresql://…@…pooler.supabase.com:6543/postgres (Transaction — breaks pg)
21
+ ```bash
22
+ pnpm add @nightowlsdev/storage-supabase
32
23
  ```
33
24
 
34
- ### Two keys, two roles
35
-
36
- | Key | Where it goes | Why |
37
- |---|---|---|
38
- | **secret** (`sb_secret_…` / `service_role`) | the server **adapter** (`secretKey`) | `BYPASSRLS`; the real authz boundary. Never ships to the browser. |
39
- | **publishable** (`sb_publishable_…` / `anon`) | the **browser / Realtime** client | RLS-gated; used by the host app to subscribe to `run:<id>` broadcasts as the end user. |
40
-
41
- The adapter is server-only. The publishable key is for the host app's browser Realtime
42
- subscription, which the defense-in-depth RLS policy (`members_receive_run_events`)
43
- gates by org membership.
44
-
45
- ## Applying the schema: install via `@nightowlsdev/cli`
46
-
47
- This package ships its migrations **inlined** (`MIGRATIONS`: `{ version, name, sql }[]`,
48
- fully-qualified `nightowls.*`) and exports the `nightOwlsPlugin` manifest that `@nightowlsdev/cli`
49
- discovers. You don't apply them from this package — instead the CLI **installs** them into
50
- your host's classic `supabase/migrations/`, and you apply them with your own tooling:
25
+ Peer dependencies (must be installed by the host):
51
26
 
52
27
  ```bash
53
- owl install storage-supabase # installs the migrations into supabase/migrations/
54
- supabase db push # apply them (your tooling — Night Owls never runs DDL)
28
+ pnpm add @nightowlsdev/core @mastra/core @mastra/pg
55
29
  ```
56
30
 
57
- The install writes one `<timestamp>_corale_<version>.sql` per migration and is idempotent
58
- (already-installed versions are skipped). `owl db install` re-installs after an upgrade.
59
-
60
- ## Mastra durable resume: `disableInit:true` + the `0002_mastra` migration
61
-
62
- On an RLS-enabled Supabase project you do **not** want `PostgresStore` to run its own
63
- DDL/init at runtime. `createMastraPgStore` defaults to `disableInit: true` and passes
64
- `schemaName: 'nightowls'`, so the `nightowls.mastra_*` tables **must pre-exist**. They are
65
- created by the packaged `0002_mastra` migration (the real `@mastra/pg` DDL via
66
- `exportSchemas('nightowls')`), including the resume-critical
67
- `nightowls.mastra_workflow_snapshot`. The installed migrations (above) create them.
68
-
69
31
  ## Usage
70
32
 
71
33
  ```ts
72
34
  import {
73
35
  createSupabaseStorage,
74
36
  createMastraPgStore,
37
+ createMastraVectorStore,
38
+ createPostgresFloor,
75
39
  publishAgentVersion,
76
40
  } from "@nightowlsdev/storage-supabase";
77
41
  import { SwarmEngine, allowListModelProvider } from "@nightowlsdev/core";
78
42
 
79
43
  const storage = createSupabaseStorage({
80
- url: process.env.SUPABASE_URL!, // API_URL — for supabase-js Realtime
81
- secretKey: process.env.SUPABASE_SECRET_KEY!, // sb_secret_… / service_role (BYPASSRLS)
82
- dbUrl: process.env.SUPABASE_DB_URL!, // Session/Direct Postgres URL (port 5432)
83
- // ssl defaults to `true` (verify cert) for remote dbUrl; localhost is unencrypted.
44
+ url: process.env.SUPABASE_URL!, // API URL — for supabase-js Realtime
45
+ secretKey: process.env.SUPABASE_SECRET_KEY!, // service_role / sb_secret_… (BYPASSRLS)
46
+ dbUrl: process.env.DATABASE_URL!, // Session/Direct Postgres URL (port 5432)
47
+ // ssl defaults to `true` (verify cert) for remote dbUrl; localhost stays unencrypted.
84
48
  });
85
49
 
86
50
  // Seed/publish an agent version (management helper, not part of the core interface).
87
- // Pass the adapter's typed internal handle `storage.ctx` (no `as any` coupling).
88
51
  await publishAgentVersion(storage.ctx, {
89
52
  tenantId: orgId,
90
53
  slug: "support",
@@ -93,23 +56,29 @@ await publishAgentVersion(storage.ctx, {
93
56
  capabilities: [],
94
57
  skillNames: [],
95
58
  delegateSlugs: [],
96
- modelId: "openai/gpt-5.5",
59
+ modelId: "openai/gpt-4o",
97
60
  });
98
61
 
99
62
  // Cross-process durable suspend/resume: inject the Postgres-backed Mastra store.
63
+ // Cross-instance mutual exclusion (serverless): inject the Postgres floor.
100
64
  const engine = new SwarmEngine({
101
65
  storage,
102
- model: allowListModelProvider({ allow: ["openai/gpt-5.5"] }),
66
+ model: allowListModelProvider({ allow: ["openai/gpt-4o"] }),
103
67
  modelFactory: (modelId) => /* your LanguageModelV3 for modelId */ undefined as never,
104
68
  cost: { maxSteps: 10, maxCostUsd: 1 },
105
- mastraStore: createMastraPgStore({ dbUrl: process.env.SUPABASE_DB_URL! }), // disableInit:true
69
+ mastraStore: createMastraPgStore({ dbUrl: process.env.DATABASE_URL! }),
70
+ floor: createPostgresFloor(storage.ctx.pool),
106
71
  });
107
72
 
73
+ // Wire cross-process agent-cache invalidation (LISTEN/NOTIFY).
74
+ // Call once, before any run; storage.close() tears it down.
75
+ storage.subscribeInvalidations((key) => { /* key = "tenantId:slug" */ });
76
+
108
77
  for await (const e of engine.run({ message: "hi" }, ctx)) {
109
78
  // SwarmEvents are persisted to nightowls.events and broadcast on run:<id>
110
79
  }
111
80
 
112
- await storage.close(); // tears down Realtime channels, then ends the pg pool
81
+ await storage.close(); // tears down the LISTEN connection, Realtime channels, then ends the pg pool
113
82
  ```
114
83
 
115
84
  Subscribe to the live event stream (server-side, BYPASSRLS):
@@ -120,6 +89,186 @@ for await (const event of storage.events.subscribe(runId)) {
120
89
  }
121
90
  ```
122
91
 
92
+ ## API
93
+
94
+ ### `createSupabaseStorage(opts: SupabaseStorageOpts): SupabaseStorage`
95
+
96
+ Creates the Supabase `StorageAdapter`. Returns a `SupabaseStorage` which extends
97
+ `StorageAdapter` with a typed internal handle (`ctx`) and a `close()` teardown.
98
+
99
+ **`SupabaseStorageOpts`**
100
+
101
+ | Field | Type | Purpose |
102
+ |---|---|---|
103
+ | `url` | `string` | Supabase project API URL — for supabase-js Realtime |
104
+ | `secretKey` | `string` | `service_role` / `sb_secret_…` key — BYPASSRLS; never ships to the browser |
105
+ | `dbUrl` | `string` | Session/Direct Postgres URL, **port 5432** (not 6543 — throws if detected) |
106
+ | `max?` | `number` | pg pool size (default 10) |
107
+ | `ssl?` | `boolean \| { rejectUnauthorized: boolean }` | TLS override; defaults to `true` (verify cert) for remote URLs |
108
+
109
+ **`SupabaseStorage`** (returned value)
110
+
111
+ Implements `StorageAdapter` plus:
112
+
113
+ - `ctx: Ctx` — typed internal handle (`{ pool: Pool; sb: SupabaseClient }`); pass to
114
+ management helpers (`publishAgentVersion`, `createPostgresFloor`, etc.) without `as any`.
115
+ - `subscribeInvalidations(onInvalidate: (key: string) => void): () => void` — registers a
116
+ Postgres LISTEN subscription (one per storage, enforced). `onInvalidate` is called with
117
+ `"tenantId:slug"` whenever `publishAgentVersion` NOTIFYs after a commit. Returns an
118
+ unsubscribe function; `close()` also tears it down.
119
+ - `close(): Promise<void>` — releases the LISTEN connection, removes Realtime channels,
120
+ then ends the pg pool.
121
+
122
+ ---
123
+
124
+ ### `createPostgresFloor(pool: Pool, opts?: PostgresFloorOpts): ContainerFloor`
125
+
126
+ A cross-instance `ContainerFloor` backed by Postgres (FIFO, heartbeat-liveness, stale-lock
127
+ reclaim). Wire it into `SwarmEngine` for serverless mutual exclusion:
128
+
129
+ ```ts
130
+ floor: createPostgresFloor(storage.ctx.pool)
131
+ ```
132
+
133
+ **`PostgresFloorOpts`**
134
+
135
+ | Field | Default | Purpose |
136
+ |---|---|---|
137
+ | `maxHoldMs?` | `180_000` | Stale-lock reclaim window (a held lock older than this is stealable) |
138
+ | `pollMs?` | `250` | Poll interval while queued for the floor |
139
+
140
+ ---
141
+
142
+ ### `publishAgentVersion(ctx: Ctx, def: PublishAgentDef): Promise<{ version: number }>`
143
+
144
+ Inserts a new agent version, flips the head pointer, audits, and NOTIFYs the R12
145
+ cache-invalidation channel. Enforces the non-bypassable agent-bar (SP6): an `agent`
146
+ principal can never publish. Returns the derived `(max+1)` version number.
147
+
148
+ **`PublishAgentDef`** — `AgentVersionContent & { tenantId: string; actor?: SwarmActor; version?: number }`
149
+
150
+ ---
151
+
152
+ ### `rollbackAgentVersion(ctx: Ctx, args): Promise<{ version: number; restoredFrom: number }>`
153
+
154
+ Append-only rollback: republishes a prior version's content as a NEW head (like `git revert`,
155
+ not `reset`). Audited as `action='rollback'`. NOTIFYs R12 cache invalidation. Throws if
156
+ `toVersion` doesn't exist for this tenant/slug.
157
+
158
+ **`args`:** `{ tenantId: string; slug: string; toVersion: number; actor?: SwarmActor }`
159
+
160
+ ---
161
+
162
+ ### `listAgentVersions(ctx: Ctx, tenantId: string, slug: string): Promise<AgentVersionInfo[]>`
163
+
164
+ Returns all versions for an agent (oldest → newest), flagging the current head. Use to
165
+ pick a `toVersion` for `rollbackAgentVersion`. Returns `[]` for an unknown agent.
166
+
167
+ ---
168
+
169
+ ### `makeVersionedRepo(ctx: Ctx): VersionedRepo`
170
+
171
+ The writable agent definition repo (read + publish + rollback + listVersions) backed by
172
+ Postgres. `createSupabaseStorage` wires this as both `storage.agents` and
173
+ `storage.agentsWritable`.
174
+
175
+ ---
176
+
177
+ ### `createMastraPgStore(opts: { dbUrl: string; disableInit?: boolean }): PostgresStore`
178
+
179
+ A `@mastra/pg` `PostgresStore` scoped to the `nightowls` schema with `disableInit: true`
180
+ (the `nightowls.mastra_*` tables are created by the packaged `0002_mastra` migration — not
181
+ at runtime). Backs durable suspend/resume (`nightowls.mastra_workflow_snapshot`). Throws
182
+ if `dbUrl` is port 6543.
183
+
184
+ ---
185
+
186
+ ### `createMastraVectorStore(opts: { dbUrl: string }): PgVector`
187
+
188
+ A `@mastra/pg` `PgVector` scoped to the `nightowls` schema — backs semantic recall. The
189
+ `pgvector` extension is enabled by the packaged `0004_memory_vector` migration; `PgVector`
190
+ auto-creates its index table on first use. Throws if `dbUrl` is port 6543.
191
+
192
+ ---
193
+
194
+ ### `MIGRATIONS: Migration[]`
195
+
196
+ The packaged SQL migration set (`{ version, name, sql }[]`, fully-qualified
197
+ `nightowls.*`), consumed by `nightOwlsPlugin`. Covers 15 migrations: core schema,
198
+ Mastra tables, followups, pgvector memory, thread text IDs, scratchpad, floor locks,
199
+ Realtime presence/broadcast, thread-scoped resource IDs, schema rename, agent version
200
+ immutability, and dropping the SaaS-only `tenant_policies`/`eval_runs` tables (host-owned,
201
+ never used by the engine).
202
+
203
+ ---
204
+
205
+ ### `nightOwlsPlugin`
206
+
207
+ The plugin manifest that `@nightowlsdev/cli` discovers. Declares:
208
+
209
+ - `migrations` — ejected into the host's `supabase/migrations/` at install
210
+ - `env` — `SUPABASE_URL`, `SUPABASE_SECRET_KEY`, `DATABASE_URL`, `SUPABASE_ANON_KEY`
211
+ merged into `.env.example`
212
+ - `config` — import + wiring snippet inserted at the `// nightowls:storage` marker
213
+ - `commands` — `owl storage-supabase info` (pure, no DB/network)
214
+
215
+ ---
216
+
217
+ ### Key exported types
218
+
219
+ - `SupabaseStorageOpts` — constructor options
220
+ - `SupabaseStorage` — the returned adapter (`StorageAdapter & { ctx; close; subscribeInvalidations }`)
221
+ - `Ctx` — internal pool + supabase-js client handle (`{ pool: Pool; sb: SupabaseClient }`)
222
+ - `PublishAgentDef` — argument to `publishAgentVersion`
223
+ - `PostgresFloorOpts` — options for `createPostgresFloor`
224
+ - `Migration` — one migration entry (`{ version, name, sql }`)
225
+ - `AgentVersionInfo` — re-exported from `@nightowlsdev/core`
226
+
227
+ ## Configuration / Environment
228
+
229
+ The env vars this package reads at runtime (via `SupabaseStorageOpts` / plugin manifest):
230
+
231
+ | Variable | Purpose |
232
+ |---|---|
233
+ | `SUPABASE_URL` | Supabase project API URL (for supabase-js Realtime) |
234
+ | `SUPABASE_SECRET_KEY` | `service_role` / `sb_secret_…` key — BYPASSRLS; server-only |
235
+ | `DATABASE_URL` | Session/Direct Postgres URL — **port 5432** (not 6543) |
236
+ | `SUPABASE_ANON_KEY` | publishable/anon key — browser auth + Realtime only (not read by the adapter itself) |
237
+
238
+ ### Use the Session/Direct pooler port (5432) — NEVER the Transaction pooler (6543)
239
+
240
+ `pg` and `@mastra/pg` rely on prepared statements, which **break on the Transaction
241
+ pooler (port 6543)**. Both `createSupabaseStorage` and `createMastraPgStore` and
242
+ `createMastraVectorStore` throw if they detect `:6543` in the URL.
243
+
244
+ ```
245
+ ✅ postgresql://…@db.<ref>.supabase.co:5432/postgres (Session/Direct)
246
+ ❌ postgresql://…@…pooler.supabase.com:6543/postgres (Transaction — breaks pg)
247
+ ```
248
+
249
+ ## Applying the schema: install via `@nightowlsdev/cli`
250
+
251
+ This package ships its migrations **inlined** and exports the `nightOwlsPlugin` manifest
252
+ that `@nightowlsdev/cli` discovers. Install and apply:
253
+
254
+ ```bash
255
+ owl install storage-supabase # installs migrations into supabase/migrations/
256
+ supabase db push # apply them (your tooling — Night Owls never runs DDL)
257
+ ```
258
+
259
+ The install writes one `<timestamp>_nightowls_<version>.sql` per migration and is
260
+ idempotent (already-installed versions are skipped). `owl db install` re-installs after an
261
+ upgrade.
262
+
263
+ ## Plugin manifest
264
+
265
+ `nightOwlsPlugin` is the declarative manifest consumed by `@nightowlsdev/cli` (`owl install`
266
+ and `owl storage-supabase info`). It declares the migration set, the env vars to merge into
267
+ `.env.example`, the import + wiring snippet for the `// nightowls:storage` marker, and a
268
+ pure `info` subcommand that prints the installed migrations and required env vars. Night
269
+ Owls never applies DDL itself — all schema changes go through `supabase db push` (or your
270
+ equivalent tooling).
271
+
123
272
  ## Known limitations
124
273
 
125
274
  - **Multi-tenant read-scoping (C1) deferred.** `events.list`, `runs.loadSnapshot`, and
@@ -128,11 +277,6 @@ for await (const event of storage.events.subscribe(runId)) {
128
277
  pass it. For single-tenant v1 the caller (engine/runner) authorizes the `runId` /
129
278
  `threadId` before reading. Multi-tenant hardening = widening those core signatures to
130
279
  take `tenantId`, tracked for the runner/auth plan (Plan 3).
131
- - **Cross-instance row-cache bust deferred.** The adapter's per-instance `RowCache`
132
- (LRU+TTL) for run→org lookups, and the engine's `AgentVersion` cache, are not busted
133
- across instances. A `postgres_changes` subscription on `nightowls.agents.current_version_id`
134
- to invalidate caches cluster-wide is deferred to the runner/multi-instance plan; the
135
- Plan-1 TTL is sufficient until then.
136
280
  - **Other v1 simplifications:** `RunStore.setStatus` ignores its optional `patch`
137
281
  (status-only persistence); `runner` is hardcoded `'nextjs'` (`NewRun` has no runner
138
282
  field); `events.subscribe` shares the adapter's single secret-role `SupabaseClient`