@specific.dev/spectest 0.114.0 → 0.115.1

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.
@@ -1,3 +1,4 @@
1
+ import { type SupabasePoolerConfig } from "./supabase-pooler.js";
1
2
  /**
2
3
  * GoTrue's mail template types, as `config.toml` spells them. The container
3
4
  * variable is the id upper-cased (`magic_link` -> `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK`).
@@ -13,6 +14,8 @@ export interface MailTemplateSpec {
13
14
  }
14
15
  /** Everything the component takes from the config file, grouped by the service it lands on. */
15
16
  export interface MappedSupabaseConfig {
17
+ /** Optional Supavisor service, configured by [db.pooler]. */
18
+ pooler: SupabasePoolerConfig;
16
19
  /** GoTrue (`<name>-auth`) environment. */
17
20
  auth: Record<string, string>;
18
21
  /** GoTrue variables whose values come from the component's env option. */
@@ -78,7 +81,8 @@ export interface ExternalProvider {
78
81
  /**
79
82
  * Classify and map every leaf of a parsed config document.
80
83
  *
81
- * Never throws on content: a setting this component does not apply is
84
+ * Unrecognized content is reported; invalid supported pooler settings fail
85
+ * before any service starts. A setting this component does not apply is
82
86
  * *reported* (`unsupported`), not vetoed. The file belongs to the project and
83
87
  * describes more than one environment — refusing to start over a key that is
84
88
  * meaningless in a hermetic VM would make the whole option unadoptable for the
@@ -34,6 +34,7 @@
34
34
  // copy-paste gets silently backwards, which is why they are marked at the def
35
35
  // site.
36
36
  import { readFileSync } from "node:fs";
37
+ import { DEFAULT_POOLER_CONFIG, setPoolerConfig } from "./supabase-pooler.js";
37
38
  /**
38
39
  * GoTrue's mail template types, as `config.toml` spells them. The container
39
40
  * variable is the id upper-cased (`magic_link` -> `GOTRUE_MAILER_TEMPLATES_MAGIC_LINK`).
@@ -119,6 +120,21 @@ const unsupported = (key, reason) => ({ key, bucket: "unsupported", reason });
119
120
  // Order matters: the first matching rule wins, so specific keys precede the
120
121
  // `**` prefixes that sweep up the rest of a section.
121
122
  const RULES = [
123
+ {
124
+ key: "db.pooler.**",
125
+ bucket: "applied",
126
+ apply: (value, out) => {
127
+ if (!isTable(value))
128
+ throw new Error("supabase(): db.pooler must be a table.");
129
+ for (const [key, setting] of Object.entries(value)) {
130
+ if (Object.hasOwn(DEFAULT_POOLER_CONFIG, key))
131
+ setPoolerConfig(out.pooler, key, setting);
132
+ // Do not hide new settings under the broad db.** topology rule.
133
+ else
134
+ out.warnings.push(`db.pooler.${key}`);
135
+ }
136
+ },
137
+ },
122
138
  // ── auth: core (cli gotrue.service.ts:440-448) ──────────────────────────
123
139
  // NEGATED — `enable_signup = true` means DISABLE_SIGNUP = false.
124
140
  auth("auth.enable_signup", "GOTRUE_DISABLE_SIGNUP", not),
@@ -383,7 +399,8 @@ function isOff(v) {
383
399
  /**
384
400
  * Classify and map every leaf of a parsed config document.
385
401
  *
386
- * Never throws on content: a setting this component does not apply is
402
+ * Unrecognized content is reported; invalid supported pooler settings fail
403
+ * before any service starts. A setting this component does not apply is
387
404
  * *reported* (`unsupported`), not vetoed. The file belongs to the project and
388
405
  * describes more than one environment — refusing to start over a key that is
389
406
  * meaningless in a hermetic VM would make the whole option unadoptable for the
@@ -392,6 +409,7 @@ function isOff(v) {
392
409
  */
393
410
  export function mapSupabaseConfig(doc) {
394
411
  const out = {
412
+ pooler: { ...DEFAULT_POOLER_CONFIG },
395
413
  auth: {},
396
414
  deferredAuth: {},
397
415
  rest: {},
@@ -0,0 +1,20 @@
1
+ import type { ServiceDefinition } from "../index.js";
2
+ /** Supabase CLI's [db.pooler] settings. Ports are local to the pooler container. */
3
+ export interface SupabasePoolerConfig {
4
+ enabled: boolean;
5
+ port: number;
6
+ pool_mode: "transaction" | "session";
7
+ default_pool_size: number;
8
+ max_client_conn: number;
9
+ }
10
+ export declare const DEFAULT_POOLER_CONFIG: SupabasePoolerConfig;
11
+ export declare function setPoolerConfig(config: SupabasePoolerConfig, key: string, value: unknown): void;
12
+ export declare function supabasePoolerService(opts: {
13
+ config: SupabasePoolerConfig;
14
+ dbHost: string;
15
+ tenantId: string;
16
+ password: string;
17
+ jwtSecret: string;
18
+ tls: boolean;
19
+ url: string;
20
+ }): ServiceDefinition;
@@ -0,0 +1,118 @@
1
+ export const DEFAULT_POOLER_CONFIG = {
2
+ enabled: false,
3
+ port: 54329,
4
+ pool_mode: "transaction",
5
+ default_pool_size: 20,
6
+ max_client_conn: 100,
7
+ };
8
+ export function setPoolerConfig(config, key, value) {
9
+ const invalid = (expected) => {
10
+ throw new Error(`supabase(): db.pooler.${key} must be ${expected}.`);
11
+ };
12
+ switch (key) {
13
+ case "enabled":
14
+ if (typeof value !== "boolean")
15
+ invalid("a boolean");
16
+ config.enabled = value;
17
+ break;
18
+ case "pool_mode":
19
+ if (value !== "transaction" && value !== "session")
20
+ invalid('"transaction" or "session"');
21
+ config.pool_mode = value;
22
+ break;
23
+ case "port":
24
+ case "default_pool_size":
25
+ case "max_client_conn":
26
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 ||
27
+ (key === "port" && value > 65535))
28
+ invalid(key === "port" ? "an integer from 1 to 65535" : "a positive integer");
29
+ config[key] = value;
30
+ break;
31
+ }
32
+ }
33
+ // Based on supabase/cli's start/templates/pooler.exs and the self-hosted
34
+ // docker/volumes/pooler/pooler.exs. Values come from env, never Elixir source
35
+ // interpolation (passwords may contain quotes, backslashes or #{...}).
36
+ const TENANT_SCRIPT = `{:ok, _} = Application.ensure_all_started(:supavisor)
37
+ {:ok, version} =
38
+ case Supavisor.Repo.query!("select version()") do
39
+ %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
40
+ end
41
+ params = %{
42
+ "external_id" => System.fetch_env!("POOLER_TENANT_ID"),
43
+ "db_host" => System.fetch_env!("POSTGRES_HOST"),
44
+ "db_port" => 5432,
45
+ "db_database" => "postgres",
46
+ "require_user" => false,
47
+ "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
48
+ "default_max_clients" => String.to_integer(System.fetch_env!("POOLER_MAX_CLIENT_CONN")),
49
+ "default_pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
50
+ "default_parameter_status" => %{"server_version" => version},
51
+ "users" => [%{
52
+ "db_user" => "pgbouncer",
53
+ "db_password" => System.fetch_env!("POSTGRES_PASSWORD"),
54
+ "mode_type" => System.fetch_env!("POOLER_POOL_MODE"),
55
+ "pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
56
+ "is_manager" => true
57
+ }]
58
+ }
59
+ if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
60
+ {:ok, _} = Supavisor.Tenants.create_tenant(params)
61
+ end
62
+ `;
63
+ export function supabasePoolerService(opts) {
64
+ const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
65
+ const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
66
+ return {
67
+ image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
68
+ // eval starts the OTP application too. Give that temporary instance
69
+ // ephemeral SQL ports so readiness only sees the final server process.
70
+ command: '/app/bin/migrate && PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval "$(cat /etc/pooler/pooler.exs)" && exec /app/bin/server',
71
+ env: {
72
+ // Bind all auxiliary listeners to ephemeral ports, so any valid pooler
73
+ // port can be used without colliding with its management/internal ports.
74
+ PORT: "0",
75
+ PROXY_PORT: "0",
76
+ SESSION_PROXY_PORTS: "0",
77
+ TRANSACTION_PROXY_PORTS: "0",
78
+ PROXY_PORT_SESSION: config.pool_mode === "session" ? String(config.port) : "0",
79
+ PROXY_PORT_TRANSACTION: config.pool_mode === "transaction" ? String(config.port) : "0",
80
+ POSTGRES_HOST: dbHost,
81
+ POSTGRES_PASSWORD: password,
82
+ DATABASE_URL: `ecto://supabase_admin:${encodeURIComponent(password)}@${dbHost}:5432/_supabase`,
83
+ // Metadata connections are separate from the user pool. Keep this small
84
+ // in a single-node test environment (upstream defaults to 25).
85
+ DB_POOL_SIZE: "2",
86
+ SECRET_KEY_BASE: "spectest-supavisor-local-secret-key-base-not-for-production-use-0000",
87
+ VAULT_ENC_KEY: "spectest".repeat(4),
88
+ API_JWT_SECRET: jwtSecret,
89
+ METRICS_JWT_SECRET: jwtSecret,
90
+ REGION: "local",
91
+ ERL_AFLAGS: "-proto_dist inet_tcp",
92
+ POOLER_TENANT_ID: tenantId,
93
+ POOLER_DEFAULT_POOL_SIZE: String(config.default_pool_size),
94
+ POOLER_MAX_CLIENT_CONN: String(config.max_client_conn),
95
+ POOLER_POOL_MODE: config.pool_mode,
96
+ ...(tls ? {
97
+ GLOBAL_DOWNSTREAM_CERT_PATH: "/tls/pooler.crt",
98
+ GLOBAL_DOWNSTREAM_KEY_PATH: "/tls/pooler.key",
99
+ } : {}),
100
+ },
101
+ files: [{ path: "/etc/pooler/pooler.exs", content: TENANT_SCRIPT }],
102
+ ...(tls ? { certificates: [{
103
+ hostnames: ["{{SPECTEST_SERVICE}}", "{{SPECTEST_SERVICE}}.internal"],
104
+ certPath: "/tls/pooler.crt",
105
+ keyPath: "/tls/pooler.key",
106
+ user: "root",
107
+ }] } : {}),
108
+ ports: [config.port],
109
+ dependsOn: ["db"],
110
+ // The pinned image includes psql. Test real tenant authentication and TLS,
111
+ // with bounded attempts retried by the normal service readiness loop.
112
+ readyCheck: {
113
+ type: "exec",
114
+ command: `PGCONNECT_TIMEOUT=3 PGOPTIONS='-c statement_timeout=3000' psql -X --dbname ${quotedUrl} -v ON_ERROR_STOP=1 -c 'SELECT 1'`,
115
+ timeoutSecs: 120,
116
+ },
117
+ };
118
+ }
@@ -294,8 +294,11 @@ export interface SupabaseStack {
294
294
  anonKey: string;
295
295
  /** `service_role` API key (HS256 JWT). */
296
296
  serviceRoleKey: string;
297
- /** Direct Postgres URL for the app (`postgres` superuser): `postgresql://postgres:<pw>@<name>-db:5432/postgres`. */
297
+ /** Direct Postgres URL as `postgres`. Use for migrations and LISTEN/NOTIFY. */
298
298
  dbUrl: string;
299
+ /** Supavisor URL as `postgres.<name>`, present when [db.pooler].enabled is true.
300
+ * Uses the configured pool_mode and port; dbUrl remains a direct connection. */
301
+ poolerUrl?: string;
299
302
  /** The gateway service key — use in a dependent service's `dependsOn` to wait
300
303
  * for the whole stack (the gateway waits on the services behind it). */
301
304
  ready: string;
@@ -64,6 +64,7 @@ import { google } from "./google.js";
64
64
  import { microsoft } from "./microsoft.js";
65
65
  import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
66
66
  import { postgresTls } from "./postgres-tls.js";
67
+ import { supabasePoolerService } from "./supabase-pooler.js";
67
68
  import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
68
69
  // ──────────────────────────────────────────────────────────────────────────
69
70
  // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
@@ -1384,6 +1385,11 @@ export function supabase(opts = {}) {
1384
1385
  // `auth`/`storage` schemas — regardless of ownership.
1385
1386
  const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
1386
1387
  (dbTls ? "?sslmode=verify-full" : "");
1388
+ const pooler = mapped?.pooler.enabled ? mapped.pooler : undefined;
1389
+ const poolerUrl = pooler
1390
+ ? `postgresql://${encodeURIComponent(`postgres.${name}`)}:${encodeURIComponent(dbPassword)}@${name}-pooler:${pooler.port}/postgres` +
1391
+ (dbTls ? "?sslmode=verify-full" : "")
1392
+ : undefined;
1387
1393
  // ── social sign-in ────────────────────────────────────────────────────
1388
1394
  // Every `[auth.external.<name>]` the config enables: served in-environment
1389
1395
  // where there is an emulator for it, reported where there isn't. The app
@@ -1482,12 +1488,29 @@ export function supabase(opts = {}) {
1482
1488
  // superuser in modern supabase/postgres; `supabase_admin` is.
1483
1489
  "supabase_admin");
1484
1490
  await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
1491
+ if (pooler) {
1492
+ // The image supplies pgbouncer.get_auth, but the self-hosted
1493
+ // compose files normally create the pooler's metadata database.
1494
+ await applySqlFile(ctx, `ALTER USER pgbouncer WITH PASSWORD ${pgLiteral(dbPassword)};
1495
+ SELECT 'CREATE DATABASE _supabase OWNER supabase_admin'
1496
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '_supabase')
1497
+ \\gexec
1498
+ \\connect _supabase supabase_admin
1499
+ CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION supabase_admin;
1500
+ `, "pooler bootstrap", "supabase_admin");
1501
+ }
1485
1502
  const extensions = extensionSql(opts.db?.extensions);
1486
1503
  if (extensions)
1487
1504
  await applySqlFile(ctx, extensions, "configured extensions", "postgres");
1488
1505
  },
1489
1506
  };
1490
1507
  parts.db = dbDef;
1508
+ if (pooler && poolerUrl) {
1509
+ parts.pooler = supabasePoolerService({
1510
+ config: pooler, dbHost: db, tenantId: name, password: dbPassword,
1511
+ jwtSecret, tls: !!dbTls, url: poolerUrl,
1512
+ });
1513
+ }
1491
1514
  // ── rest (PostgREST) ────────────────────────────────────────────
1492
1515
  parts.rest = {
1493
1516
  image: { type: "registry", reference: IMAGES.rest },
@@ -1888,6 +1911,7 @@ export function supabase(opts = {}) {
1888
1911
  anonKey,
1889
1912
  serviceRoleKey,
1890
1913
  dbUrl,
1914
+ ...(poolerUrl ? { poolerUrl } : {}),
1891
1915
  ready: name,
1892
1916
  ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
1893
1917
  };
package/dist/daemon.js CHANGED
@@ -5159,6 +5159,7 @@ export function harnessMethods(state) {
5159
5159
  // new method. Check the loaded project, including after warm restore.
5160
5160
  setupInteractive: typeof loaded?.project.interactive?.seed === "function",
5161
5161
  replayEnvironmentMode: true,
5162
+ inFlightTest: state.inFlightTest !== null,
5162
5163
  }),
5163
5164
  // Live bootstrap progress, polled by the control plane during
5164
5165
  // `bootstrap` and streamed into the test-run row. `{}` before the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.114.0",
3
+ "version": "0.115.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -238,8 +238,7 @@ secret = "encrypted:c2hoaA=="
238
238
  expect(out.warnings).toEqual(["auth.future_option"]);
239
239
  });
240
240
 
241
- // Sections spectest owns outright never reach the user as noise.
242
- test("drops the topology sections in silence", () => {
241
+ test("applies the pooler while dropping unrelated topology sections", () => {
243
242
  const out = mapSupabaseConfig(
244
243
  toml(`
245
244
  project_id = "test"
@@ -247,6 +246,10 @@ project_id = "test"
247
246
  port = 54322
248
247
  [db.pooler]
249
248
  enabled = true
249
+ port = 6544
250
+ pool_mode = "session"
251
+ default_pool_size = 7
252
+ max_client_conn = 150
250
253
  [studio]
251
254
  port = 54323
252
255
  [inbucket]
@@ -257,5 +260,29 @@ site_url = "https://example.com"
257
260
  );
258
261
  expect(out.warnings).toEqual([]);
259
262
  expect(out.auth).toEqual({});
263
+ expect(out.pooler).toEqual({ enabled: true, port: 6544, pool_mode: "session", default_pool_size: 7, max_client_conn: 150 });
264
+ });
265
+
266
+ test("pooler defaults are independent and disabled until requested", () => {
267
+ const defaults = { enabled: false, port: 54329, pool_mode: "transaction", default_pool_size: 20, max_client_conn: 100 };
268
+ expect(mapSupabaseConfig({}).pooler).toEqual(defaults);
269
+ expect(mapSupabaseConfig({ db: { pooler: { enabled: true } } }).pooler).toEqual({ ...defaults, enabled: true });
270
+ expect(mapSupabaseConfig({}).pooler).toEqual(defaults);
271
+ });
272
+
273
+ test.each([
274
+ ["enabled", "true"], ["enabled", {}], ["pool_mode", "statement"], ["pool_mode", false],
275
+ ["default_pool_size", 0], ["default_pool_size", 1.5], ["max_client_conn", -1],
276
+ ["max_client_conn", "100"], ["port", 0], ["port", 65536], ["port", NaN],
277
+ ])("rejects invalid pooler %s = %p", (key, value) => {
278
+ expect(() => mapSupabaseConfig({ db: { pooler: { [key as string]: value } } })).toThrow(`db.pooler.${key}`);
279
+ });
280
+
281
+ test("reports unknown pooler settings instead of silently ignoring them", () => {
282
+ expect(mapSupabaseConfig({ db: { pooler: { future_setting: 1 } } }).warnings).toEqual(["db.pooler.future_setting"]);
283
+ });
284
+
285
+ test("rejects a malformed pooler section", () => {
286
+ expect(() => mapSupabaseConfig({ db: { pooler: true } })).toThrow("db.pooler must be a table");
260
287
  });
261
288
  });
@@ -35,6 +35,7 @@
35
35
  // site.
36
36
 
37
37
  import { readFileSync } from "node:fs";
38
+ import { DEFAULT_POOLER_CONFIG, setPoolerConfig, type SupabasePoolerConfig } from "./supabase-pooler.js";
38
39
 
39
40
  /**
40
41
  * GoTrue's mail template types, as `config.toml` spells them. The container
@@ -61,6 +62,8 @@ export interface MailTemplateSpec {
61
62
 
62
63
  /** Everything the component takes from the config file, grouped by the service it lands on. */
63
64
  export interface MappedSupabaseConfig {
65
+ /** Optional Supavisor service, configured by [db.pooler]. */
66
+ pooler: SupabasePoolerConfig;
64
67
  /** GoTrue (`<name>-auth`) environment. */
65
68
  auth: Record<string, string>;
66
69
  /** GoTrue variables whose values come from the component's env option. */
@@ -211,6 +214,18 @@ const unsupported = (key: string, reason: string): Rule => ({ key, bucket: "unsu
211
214
  // `**` prefixes that sweep up the rest of a section.
212
215
 
213
216
  const RULES: Rule[] = [
217
+ {
218
+ key: "db.pooler.**",
219
+ bucket: "applied",
220
+ apply: (value, out) => {
221
+ if (!isTable(value)) throw new Error("supabase(): db.pooler must be a table.");
222
+ for (const [key, setting] of Object.entries(value)) {
223
+ if (Object.hasOwn(DEFAULT_POOLER_CONFIG, key)) setPoolerConfig(out.pooler, key, setting);
224
+ // Do not hide new settings under the broad db.** topology rule.
225
+ else out.warnings.push(`db.pooler.${key}`);
226
+ }
227
+ },
228
+ },
214
229
  // ── auth: core (cli gotrue.service.ts:440-448) ──────────────────────────
215
230
  // NEGATED — `enable_signup = true` means DISABLE_SIGNUP = false.
216
231
  auth("auth.enable_signup", "GOTRUE_DISABLE_SIGNUP", not),
@@ -478,7 +493,8 @@ function isOff(v: unknown): boolean {
478
493
  /**
479
494
  * Classify and map every leaf of a parsed config document.
480
495
  *
481
- * Never throws on content: a setting this component does not apply is
496
+ * Unrecognized content is reported; invalid supported pooler settings fail
497
+ * before any service starts. A setting this component does not apply is
482
498
  * *reported* (`unsupported`), not vetoed. The file belongs to the project and
483
499
  * describes more than one environment — refusing to start over a key that is
484
500
  * meaningless in a hermetic VM would make the whole option unadoptable for the
@@ -487,6 +503,7 @@ function isOff(v: unknown): boolean {
487
503
  */
488
504
  export function mapSupabaseConfig(doc: unknown): MappedSupabaseConfig {
489
505
  const out: MappedSupabaseConfig = {
506
+ pooler: { ...DEFAULT_POOLER_CONFIG },
490
507
  auth: {},
491
508
  deferredAuth: {},
492
509
  rest: {},
@@ -0,0 +1,129 @@
1
+ // Opt-in real-image regression: creates and removes its own containers/network.
2
+ // SPECTEST_TEST_SUPABASE_POOLER_DOCKER=1 bun test src/components/supabase-pooler-docker.test.ts
3
+ import { expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { supabase } from "./supabase.js";
8
+ import type { ServiceDefinition, ServiceSetupContext, ExecOpts } from "../index.js";
9
+
10
+ async function run(args: string[], stdin = "") {
11
+ const child = Bun.spawn(args, { stdin: new Blob([stdin]), stdout: "pipe", stderr: "pipe" });
12
+ const [exitCode, stdout, stderr] = await Promise.all([
13
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
14
+ ]);
15
+ return { exitCode, stdout, stderr };
16
+ }
17
+ async function checked(args: string[], stdin = "") {
18
+ const result = await run(args, stdin);
19
+ if (result.exitCode) throw new Error(`${args[0]} failed: ${result.stderr}\n${result.stdout}`);
20
+ return result.stdout;
21
+ }
22
+ async function until(check: () => Promise<boolean>) {
23
+ const deadline = Date.now() + 60_000;
24
+ while (Date.now() < deadline) {
25
+ if (await check()) return;
26
+ await Bun.sleep(250);
27
+ }
28
+ throw new Error("Container did not become ready within 60s");
29
+ }
30
+
31
+ test.skipIf(!process.env.SPECTEST_TEST_SUPABASE_POOLER_DOCKER)("Supavisor boots, verifies TLS, pools transactions and preserves sessions", async () => {
32
+ const root = mkdtempSync(join(tmpdir(), "spectest-pooler-docker-"));
33
+ const network = `spectest-pooler-${process.pid}-${Date.now()}`;
34
+ const dbContainer = `${network}-db`;
35
+ const containers: string[] = [];
36
+ const password = "p@ss/word:#%";
37
+ const stack = (name: string, mode: "transaction" | "session") => supabase({
38
+ name, dbPassword: password,
39
+ configOverrides: { db: { pooler: { enabled: true, pool_mode: mode,
40
+ port: mode === "transaction" ? 54329 : 6544, default_pool_size: 2, max_client_conn: 12 } } },
41
+ });
42
+ const sb = stack("tx", "transaction");
43
+ const db = sb.group.services({ name: "tx", key: (p) => `tx-${p}` }).db as ServiceDefinition;
44
+ const exec = (_service: string, args: string[], opts: ExecOpts = {}) =>
45
+ run(["docker", "exec", "-i", dbContainer, ...args], typeof opts.stdin === "string" ? opts.stdin : "");
46
+ const ctx = { name: "tx-db", exec } as unknown as ServiceSetupContext<any>;
47
+ try {
48
+ await checked(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
49
+ "-subj", "/CN=Spectest pooler test CA", "-keyout", `${root}/ca.key`, "-out", `${root}/ca.crt`]);
50
+ for (const host of ["tx-db", "tx-pooler", "session-pooler"]) {
51
+ await checked(["openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", `/CN=${host}`,
52
+ "-keyout", `${root}/${host}.key`, "-out", `${root}/${host}.csr`]);
53
+ writeFileSync(`${root}/${host}.ext`, `subjectAltName=DNS:${host}\nextendedKeyUsage=serverAuth\n`);
54
+ await checked(["openssl", "x509", "-req", "-days", "1", "-in", `${root}/${host}.csr`,
55
+ "-CA", `${root}/ca.crt`, "-CAkey", `${root}/ca.key`, "-CAcreateserial",
56
+ "-extfile", `${root}/${host}.ext`, "-out", `${root}/${host}.crt`]);
57
+ }
58
+ await checked(["docker", "network", "create", network]);
59
+ containers.push(dbContainer);
60
+ await checked(["docker", "run", "-d", "--name", dbContainer, "--network", network,
61
+ "--network-alias", "tx-db", "--network-alias", "session-db", "-v", `${root}:/fixtures:ro`,
62
+ "-e", `POSTGRES_PASSWORD=${password}`, "-e", "PGSSLROOTCERT=/fixtures/ca.crt",
63
+ "--entrypoint", "sh", (db.image as { reference: string }).reference, "-c",
64
+ 'mkdir /tls; cp /fixtures/tx-db.crt /tls/postgres.crt; cp /fixtures/tx-db.key /tls/postgres.key; chown postgres:postgres /tls/*; chmod 600 /tls/postgres.key; exec docker-entrypoint.sh "$@"',
65
+ "sh", ...db.args!]);
66
+ await until(async () => (await exec("", ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"])).exitCode === 0);
67
+ await db.setup!(ctx);
68
+ await db.setup!(ctx); // Metadata bootstrap must be repeatable.
69
+
70
+ for (const [name, mode] of [["tx", "transaction"], ["session", "session"]] as const) {
71
+ const current = stack(name, mode);
72
+ const pooler = current.group.services({ name, key: (p) => `${name}-${p}` }).pooler as ServiceDefinition;
73
+ const container = `${network}-${name}`;
74
+ containers.push(container);
75
+ writeFileSync(`${root}/${name}.exs`, pooler.files![0]!.content);
76
+ const env = Object.entries(pooler.env!).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
77
+ await checked(["docker", "run", "-d", "--name", container, "--network", network,
78
+ "--network-alias", `${name}-pooler`, ...env,
79
+ "-e", "PGSSLROOTCERT=/tls/ca.crt", "-v", `${root}/ca.crt:/tls/ca.crt:ro`,
80
+ "-v", `${root}/${name}.exs:/etc/pooler/pooler.exs:ro`,
81
+ "-v", `${root}/${name}-pooler.crt:/tls/pooler.crt:ro`,
82
+ "-v", `${root}/${name}-pooler.key:/tls/pooler.key:ro`,
83
+ "--entrypoint", "sh", (pooler.image as { reference: string }).reference, "-c", pooler.command!]);
84
+ const query = (sql: string, url = current.poolerUrl!) => exec("", ["psql", "-XAt", "-v", "ON_ERROR_STOP=1", url, "-f", "-"], { stdin: sql });
85
+ if (pooler.readyCheck!.type !== "exec") throw new Error("Expected SQL readiness probe");
86
+ const probeCommand = pooler.readyCheck!.command;
87
+ const probe = () => run(["docker", "exec", container, "sh", "-c", probeCommand]);
88
+ await until(async () => (await probe()).exitCode === 0);
89
+ const auth = await query("SELECT current_user, current_database();");
90
+ expect(auth.stdout.trim()).toBe("postgres|postgres");
91
+ const badPassword = new URL(current.poolerUrl!); badPassword.password = "wrong";
92
+ expect((await query("SELECT 1;", badPassword.toString())).exitCode).not.toBe(0);
93
+ // A CA-trusted certificate for a different name must still fail verification.
94
+ const wrongHost = new URL(current.poolerUrl!); wrongHost.hostname = container;
95
+ expect((await query("SELECT 1;", wrongHost.toString())).exitCode).not.toBe(0);
96
+ if (mode === "transaction") {
97
+ const results = await Promise.all(Array.from({ length: 8 }, () => query("SELECT pg_backend_pid(), pg_sleep(0.15);")));
98
+ for (const result of results) expect(result.exitCode).toBe(0);
99
+ const backends = new Set(results.map((result) => result.stdout.trim().split("|")[0]));
100
+ // Supavisor 2.9.12 keeps one warm backend in addition to pool_size
101
+ // overflow slots (tenant_supervisor.ex). Eight clients share <= 3.
102
+ expect(backends.size).toBeLessThanOrEqual(3);
103
+ expect(backends.size).toBeGreaterThan(0);
104
+ } else {
105
+ const result = await query("CREATE TEMP TABLE session_probe (n int);\nINSERT INTO session_probe VALUES (42);\nSELECT n FROM session_probe;\n");
106
+ expect(result.exitCode).toBe(0);
107
+ expect(result.stdout).toContain("42");
108
+ }
109
+ // Tenant settings survive restart, and provisioning remains idempotent.
110
+ await checked(["docker", "restart", container]);
111
+ await until(async () => (await probe()).exitCode === 0);
112
+ await checked(["docker", "rm", "-f", container]);
113
+ containers.splice(containers.indexOf(container), 1);
114
+ }
115
+ const settings = await exec("", ["psql", "-U", "supabase_admin", "-d", "_supabase", "-Atc",
116
+ "SELECT external_id, default_pool_size, default_max_clients FROM _supavisor.tenants ORDER BY external_id"]);
117
+ expect(settings.stdout.trim()).toBe("session|2|12\ntx|2|12");
118
+ } catch (error) {
119
+ for (const container of containers) {
120
+ const logs = await run(["docker", "logs", "--tail", "30", container]);
121
+ console.error(container, logs.stdout, logs.stderr);
122
+ }
123
+ throw error;
124
+ } finally {
125
+ for (const container of containers.reverse()) await run(["docker", "rm", "-f", "-v", container]);
126
+ await run(["docker", "network", "rm", network]);
127
+ rmSync(root, { recursive: true, force: true });
128
+ }
129
+ }, 180_000);
@@ -0,0 +1,83 @@
1
+ import { afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { supabase } from "./supabase.js";
6
+ import { defineEnvironment, type ServiceDefinition } from "../index.js";
7
+
8
+ const root = mkdtempSync(join(tmpdir(), "supabase-pooler-"));
9
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
10
+ mkdirSync(join(root, "migrations"));
11
+ writeFileSync(join(root, "config.toml"), `[db.pooler]
12
+ enabled = true
13
+ port = 6544
14
+ pool_mode = "session"
15
+ default_pool_size = 3
16
+ max_client_conn = 40
17
+ `);
18
+
19
+ test("pooling is opt-in and does not change direct URLs", () => {
20
+ const direct = supabase();
21
+ const disabled = supabase({ configOverrides: { db: { pooler: { enabled: false } } } });
22
+ expect(disabled.dbUrl).toBe(direct.dbUrl);
23
+ expect(disabled.poolerUrl).toBeUndefined();
24
+ expect(disabled.group.services({ name: "supabase", key: (p) => `supabase-${p}` })).not.toHaveProperty("pooler");
25
+ });
26
+
27
+ test("reads the project config and overrides it without redirecting internal clients", () => {
28
+ const password = "a@b/c'\\#{raise} $()";
29
+ const sb = supabase({ name: "other", dir: root, dbPassword: password,
30
+ configOverrides: { db: { pooler: { pool_mode: "transaction", max_client_conn: 80 } } } });
31
+ const parts = sb.group.services({ name: "other", key: (p) => `other-${p}` });
32
+ const pooler = parts.pooler as ServiceDefinition;
33
+ const url = new URL(sb.poolerUrl!);
34
+ expect(url.hostname).toBe("other-pooler");
35
+ expect(url.port).toBe("6544");
36
+ expect(decodeURIComponent(url.username)).toBe("postgres.other");
37
+ expect(decodeURIComponent(url.password)).toBe(password);
38
+ expect(url.searchParams.get("sslmode")).toBe("verify-full");
39
+ expect(new URL(sb.dbUrl).hostname).toBe("other-db");
40
+ expect(pooler.env).toMatchObject({ POOLER_DEFAULT_POOL_SIZE: "3", POOLER_MAX_CLIENT_CONN: "80",
41
+ POOLER_POOL_MODE: "transaction", PROXY_PORT_TRANSACTION: "6544", PROXY_PORT_SESSION: "0", POSTGRES_HOST: "other-db" });
42
+ expect(new URL(pooler.env!.DATABASE_URL!).password).toBe(encodeURIComponent(password));
43
+ expect(pooler.files![0]!.content).not.toContain(password);
44
+ expect(pooler.certificates![0]!.user).toBe("root");
45
+ expect((parts.rest as ServiceDefinition).env!.PGRST_DB_URI).toContain("@other-db:5432");
46
+ expect((parts.auth as ServiceDefinition).env!.GOTRUE_DB_DATABASE_URL).toContain("@other-db:5432");
47
+ expect((parts.storage as ServiceDefinition).env!.DATABASE_URL).toContain("@other-db:5432");
48
+ expect((parts.realtime as ServiceDefinition).env!.DB_HOST).toBe("other-db");
49
+ expect((parts.migrations as ServiceDefinition).dependsOn).not.toContain("pooler");
50
+ });
51
+
52
+ test("session mode and disabling TLS apply to the selected endpoint", () => {
53
+ const sb = supabase({ dir: root, db: { tls: false } });
54
+ const pooler = sb.group.services({ name: "supabase", key: (p) => `supabase-${p}` }).pooler as ServiceDefinition;
55
+ expect(pooler.env).toMatchObject({ POOLER_POOL_MODE: "session", PROXY_PORT_SESSION: "6544", PROXY_PORT_TRANSACTION: "0" });
56
+ expect(pooler.certificates).toBeUndefined();
57
+ expect(sb.poolerUrl).not.toContain("sslmode");
58
+ });
59
+
60
+ test("group readiness waits for authenticated pooled SQL, after tenant provisioning", () => {
61
+ const sb = supabase({ configOverrides: { db: { pooler: { enabled: true } } } });
62
+ const env = defineEnvironment({ name: "pooler-test", services: { supabase: sb.group } });
63
+ expect(env.config.services.supabase.dependsOn).toContain("supabase-pooler");
64
+ const pooler = sb.group.services({ name: "supabase", key: (p) => `supabase-${p}` }).pooler as ServiceDefinition;
65
+ expect(pooler.readyCheck!.type).toBe("exec");
66
+ if (pooler.readyCheck!.type !== "exec") throw new Error("Expected SQL probe");
67
+ expect(pooler.readyCheck!.command).toContain(sb.poolerUrl!);
68
+ expect(pooler.readyCheck!.command).toContain("ON_ERROR_STOP=1");
69
+ expect(pooler.command).toContain("PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval");
70
+ });
71
+
72
+ test("SQL readiness passes special credentials literally through the shell", async () => {
73
+ const sb = supabase({ dbPassword: "p@ss/word:'#{safe}%$()\\tail",
74
+ configOverrides: { db: { pooler: { enabled: true } } } });
75
+ const pooler = sb.group.services({ name: "supabase", key: (p) => `supabase-${p}` }).pooler as ServiceDefinition;
76
+ if (pooler.readyCheck!.type !== "exec") throw new Error("Expected SQL probe");
77
+ const child = Bun.spawn(["sh", "-c", `psql() { printf '%s\\n' "$@"; }; ${pooler.readyCheck!.command}`], {
78
+ stdout: "pipe", stderr: "pipe",
79
+ });
80
+ expect(await child.exited).toBe(0);
81
+ const args = (await new Response(child.stdout).text()).trim().split("\n");
82
+ expect(args[args.indexOf("--dbname") + 1]).toBe(sb.poolerUrl!);
83
+ });
@@ -0,0 +1,137 @@
1
+ import type { ServiceDefinition } from "../index.js";
2
+
3
+ /** Supabase CLI's [db.pooler] settings. Ports are local to the pooler container. */
4
+ export interface SupabasePoolerConfig {
5
+ enabled: boolean;
6
+ port: number;
7
+ pool_mode: "transaction" | "session";
8
+ default_pool_size: number;
9
+ max_client_conn: number;
10
+ }
11
+
12
+ export const DEFAULT_POOLER_CONFIG: SupabasePoolerConfig = {
13
+ enabled: false,
14
+ port: 54329,
15
+ pool_mode: "transaction",
16
+ default_pool_size: 20,
17
+ max_client_conn: 100,
18
+ };
19
+
20
+ export function setPoolerConfig(config: SupabasePoolerConfig, key: string, value: unknown): void {
21
+ const invalid = (expected: string): never => {
22
+ throw new Error(`supabase(): db.pooler.${key} must be ${expected}.`);
23
+ };
24
+ switch (key) {
25
+ case "enabled":
26
+ if (typeof value !== "boolean") invalid("a boolean");
27
+ config.enabled = value as boolean;
28
+ break;
29
+ case "pool_mode":
30
+ if (value !== "transaction" && value !== "session") invalid('"transaction" or "session"');
31
+ config.pool_mode = value as SupabasePoolerConfig["pool_mode"];
32
+ break;
33
+ case "port":
34
+ case "default_pool_size":
35
+ case "max_client_conn":
36
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 ||
37
+ (key === "port" && value > 65535)) invalid(key === "port" ? "an integer from 1 to 65535" : "a positive integer");
38
+ config[key] = value as number;
39
+ break;
40
+ }
41
+ }
42
+
43
+ // Based on supabase/cli's start/templates/pooler.exs and the self-hosted
44
+ // docker/volumes/pooler/pooler.exs. Values come from env, never Elixir source
45
+ // interpolation (passwords may contain quotes, backslashes or #{...}).
46
+ const TENANT_SCRIPT = `{:ok, _} = Application.ensure_all_started(:supavisor)
47
+ {:ok, version} =
48
+ case Supavisor.Repo.query!("select version()") do
49
+ %{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
50
+ end
51
+ params = %{
52
+ "external_id" => System.fetch_env!("POOLER_TENANT_ID"),
53
+ "db_host" => System.fetch_env!("POSTGRES_HOST"),
54
+ "db_port" => 5432,
55
+ "db_database" => "postgres",
56
+ "require_user" => false,
57
+ "auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
58
+ "default_max_clients" => String.to_integer(System.fetch_env!("POOLER_MAX_CLIENT_CONN")),
59
+ "default_pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
60
+ "default_parameter_status" => %{"server_version" => version},
61
+ "users" => [%{
62
+ "db_user" => "pgbouncer",
63
+ "db_password" => System.fetch_env!("POSTGRES_PASSWORD"),
64
+ "mode_type" => System.fetch_env!("POOLER_POOL_MODE"),
65
+ "pool_size" => String.to_integer(System.fetch_env!("POOLER_DEFAULT_POOL_SIZE")),
66
+ "is_manager" => true
67
+ }]
68
+ }
69
+ if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
70
+ {:ok, _} = Supavisor.Tenants.create_tenant(params)
71
+ end
72
+ `;
73
+
74
+ export function supabasePoolerService(opts: {
75
+ config: SupabasePoolerConfig;
76
+ dbHost: string;
77
+ tenantId: string;
78
+ password: string;
79
+ jwtSecret: string;
80
+ tls: boolean;
81
+ url: string;
82
+ }): ServiceDefinition {
83
+ const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
84
+ const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
85
+ return {
86
+ image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
87
+ // eval starts the OTP application too. Give that temporary instance
88
+ // ephemeral SQL ports so readiness only sees the final server process.
89
+ command: '/app/bin/migrate && PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval "$(cat /etc/pooler/pooler.exs)" && exec /app/bin/server',
90
+ env: {
91
+ // Bind all auxiliary listeners to ephemeral ports, so any valid pooler
92
+ // port can be used without colliding with its management/internal ports.
93
+ PORT: "0",
94
+ PROXY_PORT: "0",
95
+ SESSION_PROXY_PORTS: "0",
96
+ TRANSACTION_PROXY_PORTS: "0",
97
+ PROXY_PORT_SESSION: config.pool_mode === "session" ? String(config.port) : "0",
98
+ PROXY_PORT_TRANSACTION: config.pool_mode === "transaction" ? String(config.port) : "0",
99
+ POSTGRES_HOST: dbHost,
100
+ POSTGRES_PASSWORD: password,
101
+ DATABASE_URL: `ecto://supabase_admin:${encodeURIComponent(password)}@${dbHost}:5432/_supabase`,
102
+ // Metadata connections are separate from the user pool. Keep this small
103
+ // in a single-node test environment (upstream defaults to 25).
104
+ DB_POOL_SIZE: "2",
105
+ SECRET_KEY_BASE: "spectest-supavisor-local-secret-key-base-not-for-production-use-0000",
106
+ VAULT_ENC_KEY: "spectest".repeat(4),
107
+ API_JWT_SECRET: jwtSecret,
108
+ METRICS_JWT_SECRET: jwtSecret,
109
+ REGION: "local",
110
+ ERL_AFLAGS: "-proto_dist inet_tcp",
111
+ POOLER_TENANT_ID: tenantId,
112
+ POOLER_DEFAULT_POOL_SIZE: String(config.default_pool_size),
113
+ POOLER_MAX_CLIENT_CONN: String(config.max_client_conn),
114
+ POOLER_POOL_MODE: config.pool_mode,
115
+ ...(tls ? {
116
+ GLOBAL_DOWNSTREAM_CERT_PATH: "/tls/pooler.crt",
117
+ GLOBAL_DOWNSTREAM_KEY_PATH: "/tls/pooler.key",
118
+ } : {}),
119
+ },
120
+ files: [{ path: "/etc/pooler/pooler.exs", content: TENANT_SCRIPT }],
121
+ ...(tls ? { certificates: [{
122
+ hostnames: ["{{SPECTEST_SERVICE}}", "{{SPECTEST_SERVICE}}.internal"],
123
+ certPath: "/tls/pooler.crt",
124
+ keyPath: "/tls/pooler.key",
125
+ user: "root",
126
+ }] } : {}),
127
+ ports: [config.port],
128
+ dependsOn: ["db"],
129
+ // The pinned image includes psql. Test real tenant authentication and TLS,
130
+ // with bounded attempts retried by the normal service readiness loop.
131
+ readyCheck: {
132
+ type: "exec",
133
+ command: `PGCONNECT_TIMEOUT=3 PGOPTIONS='-c statement_timeout=3000' psql -X --dbname ${quotedUrl} -v ON_ERROR_STOP=1 -c 'SELECT 1'`,
134
+ timeoutSecs: 120,
135
+ },
136
+ };
137
+ }
@@ -74,6 +74,7 @@ import { google } from "./google.js";
74
74
  import { microsoft } from "./microsoft.js";
75
75
  import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
76
76
  import { postgresTls } from "./postgres-tls.js";
77
+ import { supabasePoolerService } from "./supabase-pooler.js";
77
78
  import {
78
79
  mapSupabaseConfig,
79
80
  parseSupabaseConfigFile,
@@ -519,8 +520,11 @@ export interface SupabaseStack {
519
520
  anonKey: string;
520
521
  /** `service_role` API key (HS256 JWT). */
521
522
  serviceRoleKey: string;
522
- /** Direct Postgres URL for the app (`postgres` superuser): `postgresql://postgres:<pw>@<name>-db:5432/postgres`. */
523
+ /** Direct Postgres URL as `postgres`. Use for migrations and LISTEN/NOTIFY. */
523
524
  dbUrl: string;
525
+ /** Supavisor URL as `postgres.<name>`, present when [db.pooler].enabled is true.
526
+ * Uses the configured pool_mode and port; dbUrl remains a direct connection. */
527
+ poolerUrl?: string;
524
528
  /** The gateway service key — use in a dependent service's `dependsOn` to wait
525
529
  * for the whole stack (the gateway waits on the services behind it). */
526
530
  ready: string;
@@ -1855,6 +1859,11 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1855
1859
  // `auth`/`storage` schemas — regardless of ownership.
1856
1860
  const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
1857
1861
  (dbTls ? "?sslmode=verify-full" : "");
1862
+ const pooler = mapped?.pooler.enabled ? mapped.pooler : undefined;
1863
+ const poolerUrl = pooler
1864
+ ? `postgresql://${encodeURIComponent(`postgres.${name}`)}:${encodeURIComponent(dbPassword)}@${name}-pooler:${pooler.port}/postgres` +
1865
+ (dbTls ? "?sslmode=verify-full" : "")
1866
+ : undefined;
1858
1867
 
1859
1868
  // ── social sign-in ────────────────────────────────────────────────────
1860
1869
  // Every `[auth.external.<name>]` the config enables: served in-environment
@@ -1966,12 +1975,30 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1966
1975
  "supabase_admin",
1967
1976
  );
1968
1977
  await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
1978
+ if (pooler) {
1979
+ // The image supplies pgbouncer.get_auth, but the self-hosted
1980
+ // compose files normally create the pooler's metadata database.
1981
+ await applySqlFile(ctx, `ALTER USER pgbouncer WITH PASSWORD ${pgLiteral(dbPassword)};
1982
+ SELECT 'CREATE DATABASE _supabase OWNER supabase_admin'
1983
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '_supabase')
1984
+ \\gexec
1985
+ \\connect _supabase supabase_admin
1986
+ CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION supabase_admin;
1987
+ `, "pooler bootstrap", "supabase_admin");
1988
+ }
1969
1989
  const extensions = extensionSql(opts.db?.extensions);
1970
1990
  if (extensions) await applySqlFile(ctx, extensions, "configured extensions", "postgres");
1971
1991
  },
1972
1992
  };
1973
1993
  parts.db = dbDef;
1974
1994
 
1995
+ if (pooler && poolerUrl) {
1996
+ parts.pooler = supabasePoolerService({
1997
+ config: pooler, dbHost: db, tenantId: name, password: dbPassword,
1998
+ jwtSecret, tls: !!dbTls, url: poolerUrl,
1999
+ });
2000
+ }
2001
+
1975
2002
  // ── rest (PostgREST) ────────────────────────────────────────────
1976
2003
  parts.rest = {
1977
2004
  image: { type: "registry", reference: IMAGES.rest },
@@ -2395,6 +2422,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2395
2422
  anonKey,
2396
2423
  serviceRoleKey,
2397
2424
  dbUrl,
2425
+ ...(poolerUrl ? { poolerUrl } : {}),
2398
2426
  ready: name,
2399
2427
  ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
2400
2428
  };
package/src/daemon.ts CHANGED
@@ -6177,6 +6177,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6177
6177
  // new method. Check the loaded project, including after warm restore.
6178
6178
  setupInteractive: typeof loaded?.project.interactive?.seed === "function",
6179
6179
  replayEnvironmentMode: true,
6180
+ inFlightTest: state.inFlightTest !== null,
6180
6181
  }),
6181
6182
 
6182
6183
  // Live bootstrap progress, polled by the control plane during
@@ -0,0 +1,20 @@
1
+ import { expect, test } from "bun:test";
2
+ import { harnessMethods } from "../daemon";
3
+
4
+ test("health reports pending work without acquiring its execution slot", async () => {
5
+ const state = {
6
+ inFlightTest: null as Promise<unknown> | null,
7
+ inFlightBootstrap: null,
8
+ inFlightProjectSetup: null,
9
+ };
10
+ const methods = harnessMethods(state);
11
+ expect((await methods.health({}) as any).inFlightTest).toBe(false);
12
+ let finish!: () => void;
13
+ state.inFlightTest = new Promise<void>((resolve) => { finish = resolve; });
14
+ expect((await methods.health({}) as any).inFlightTest).toBe(true);
15
+ finish();
16
+ await state.inFlightTest;
17
+ // Completion is acknowledged by the execution-slot owner, not just a request timeout.
18
+ state.inFlightTest = null;
19
+ expect((await methods.health({}) as any).inFlightTest).toBe(false);
20
+ });