@specific.dev/spectest 0.113.0 → 0.115.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
@@ -20,6 +20,34 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
20
20
  Use a local service definition or `serviceGroup()` wrapper; the repository’s
21
21
  `examples/google-pubsub` demonstrates this using public SDK APIs.
22
22
 
23
+ ## Interactive entrypoints (0.114.0)
24
+
25
+ Choose the links shown in the interactive dashboard and PR preview comment:
26
+
27
+ ```ts
28
+ const env = defineEnvironment({
29
+ name: "my-app",
30
+ services: { web, api },
31
+ interactive: {
32
+ instructions: "Sign in as demo@example.com and use 000000 as the code",
33
+ entrypoints: [
34
+ { name: "App", url: "https://app.test" },
35
+ { name: "Admin", url: "https://app.test/admin" },
36
+ { name: "API shell", terminal: "api" },
37
+ ],
38
+ },
39
+ });
40
+ ```
41
+
42
+ List order is preserved. URLs must match declared service hostnames and are
43
+ translated to public preview URLs, retaining paths, queries and fragments.
44
+ Terminal targets are typed service names and use dashboard member permissions.
45
+ Entrypoints are display metadata; they do not change exposure or test execution.
46
+ With no entries, the only preview link is **Open environment**. Optional
47
+ `interactive.instructions` is a concise, direct plain-text sign-in description,
48
+ shown in a collapsed **Instructions** section in the dashboard and PR
49
+ comment. Blank instructions are omitted.
50
+
23
51
 
24
52
  ## Microphone fixtures and WebSocket fakes (0.103.0)
25
53
 
@@ -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/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
2
2
  import type { Phone, PhoneOptions } from "./phone.js";
3
3
  import type { Android, AndroidApp } from "./android.js";
4
+ import { type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
5
+ export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
4
6
  export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
5
7
  import { strict as nodeAssert } from "node:assert";
6
8
  export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
@@ -69,6 +71,11 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
69
71
  services: S;
70
72
  /** Sandbox timeout in seconds (default 1h). */
71
73
  timeoutSecs?: number;
74
+ /** Display metadata only; entrypoints retain their author-declared order. */
75
+ interactive?: {
76
+ entrypoints: InteractiveEntrypoint[];
77
+ instructions?: string;
78
+ };
72
79
  }
73
80
  /**
74
81
  * The shape that ends up in the JSON config the control plane and Rust
@@ -1717,6 +1724,15 @@ export interface EnvironmentInput<SI extends InputServicesMap, F extends FakesMa
1717
1724
  /** Services and/or service groups, keyed by name. See
1718
1725
  * {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
1719
1726
  services: SI;
1727
+ /** Links highlighted in the dashboard and PR previews, in list order.
1728
+ * URL entries target declared service hostnames; terminal entries name services. */
1729
+ interactive?: {
1730
+ entrypoints?: InteractiveEntrypoints<keyof NoInfer<ExpandServices<SI>> & string>;
1731
+ /** Concise, direct plain-text sign-in instructions shown in the dashboard
1732
+ * and a collapsed PR comment section. Example: "Sign in as demo@example.com
1733
+ * and use 000000 as the code". */
1734
+ instructions?: string;
1735
+ };
1720
1736
  /** Sandbox timeout in seconds (default 1h). */
1721
1737
  timeoutSecs?: number;
1722
1738
  /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveEntrypoints } from "./interactive.js";
1
2
  // Spectest SDK. The user's single `spectest/index.ts` calls
2
3
  // `defineEnvironment({ name, services })` once, defines test cases via
3
4
  // the returned `env.test(...)`, and default-exports `env.project([...])`.
@@ -422,10 +423,9 @@ export function defineFake(opts) {
422
423
  * produces the file's default export.
423
424
  */
424
425
  export function defineEnvironment(input) {
425
- // Split fakes off the wire config and expand service groups; only
426
- // { name, services, timeoutSecs } — with plain, fully-expanded services —
427
- // is stored as `config` and shipped to the control plane.
428
- const { fakes, services: rawServices, ...rest } = input;
426
+ // Split fakes off the wire config, expand service groups, and validate
427
+ // interactive destinations against the final service names.
428
+ const { fakes, interactive, services: rawServices, ...rest } = input;
429
429
  const expanded = expandServiceGroups(rawServices);
430
430
  for (const [key, svc] of Object.entries(expanded)) {
431
431
  for (const build of svc.imageBuilds ?? [])
@@ -440,6 +440,15 @@ export function defineEnvironment(input) {
440
440
  services: expanded,
441
441
  };
442
442
  validateEnvironmentConfig(config);
443
+ if (interactive?.instructions !== undefined && typeof interactive.instructions !== "string") {
444
+ throw new Error("interactive.instructions must be a string");
445
+ }
446
+ if (interactive?.entrypoints !== undefined || interactive?.instructions !== undefined) {
447
+ config.interactive = {
448
+ entrypoints: interactive.entrypoints === undefined ? [] : resolveEntrypoints(interactive.entrypoints, config.services),
449
+ ...(interactive.instructions === undefined ? {} : { instructions: interactive.instructions }),
450
+ };
451
+ }
443
452
  if (fakes)
444
453
  validateFakes(config, fakes);
445
454
  // Every test created via this env's `.test(...)` is registered here. A
@@ -0,0 +1,18 @@
1
+ /** One destination highlighted in interactive environments and PR previews. */
2
+ export type InteractiveEntrypoint<Service extends string = string> = {
3
+ name: string;
4
+ } & ({
5
+ url: string;
6
+ terminal?: never;
7
+ } | {
8
+ terminal: Service;
9
+ url?: never;
10
+ });
11
+ /** Display order is exactly the order of this list. */
12
+ export type InteractiveEntrypoints<Service extends string = string> = readonly InteractiveEntrypoint<Service>[];
13
+ export declare function resolveEntrypoints(entries: InteractiveEntrypoints, services: Record<string, {
14
+ hostnames?: readonly string[];
15
+ tls?: readonly {
16
+ hostname: string;
17
+ }[];
18
+ }>): InteractiveEntrypoint[];
@@ -0,0 +1,39 @@
1
+ export function resolveEntrypoints(entries, services) {
2
+ if (!Array.isArray(entries))
3
+ throw new Error("interactive.entrypoints must be a list");
4
+ const names = new Set();
5
+ return entries.map((entry, index) => {
6
+ const fail = (reason) => { throw new Error(`interactive.entrypoints[${index}]: ${reason}`); };
7
+ if (!entry || typeof entry.name !== "string" || !entry.name.trim())
8
+ fail("name must be a non-empty string");
9
+ const { name } = entry;
10
+ if (names.has(name))
11
+ fail(`duplicate name ${JSON.stringify(name)}`);
12
+ names.add(name);
13
+ if ((entry.url !== undefined) === (entry.terminal !== undefined))
14
+ fail("specify exactly one of url or terminal");
15
+ if (entry.terminal !== undefined) {
16
+ if (typeof entry.terminal !== "string" || !Object.hasOwn(services, entry.terminal)) {
17
+ fail("terminal must name a declared service");
18
+ }
19
+ return { name, terminal: entry.terminal };
20
+ }
21
+ if (typeof entry.url !== "string")
22
+ return fail("expected an absolute HTTP or HTTPS URL");
23
+ let url;
24
+ try {
25
+ url = new URL(entry.url);
26
+ }
27
+ catch {
28
+ return fail("expected an absolute HTTP or HTTPS URL");
29
+ }
30
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
31
+ fail("use an HTTP or HTTPS service URL without credentials or a non-default port");
32
+ }
33
+ const declared = Object.values(services).some((service) => [...(service.hostnames ?? []), ...(service.tls ?? []).map((tls) => tls.hostname)]
34
+ .some((host) => !host.startsWith("*.") && host.toLowerCase() === url.hostname));
35
+ if (!declared)
36
+ fail(`hostname ${JSON.stringify(url.hostname)} is not a declared service endpoint`);
37
+ return { name, url: url.href };
38
+ });
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.113.0",
3
+ "version": "0.115.0",
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/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
2
2
  import type { Phone, PhoneOptions } from "./phone.js";
3
3
  import type { Android, AndroidApp } from "./android.js";
4
+ import { resolveEntrypoints, type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
5
+ export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
4
6
  export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
5
7
  // Spectest SDK. The user's single `spectest/index.ts` calls
6
8
  // `defineEnvironment({ name, services })` once, defines test cases via
@@ -245,6 +247,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
245
247
  services: S;
246
248
  /** Sandbox timeout in seconds (default 1h). */
247
249
  timeoutSecs?: number;
250
+ /** Display metadata only; entrypoints retain their author-declared order. */
251
+ interactive?: { entrypoints: InteractiveEntrypoint[]; instructions?: string };
248
252
  }
249
253
 
250
254
  /**
@@ -2481,6 +2485,15 @@ export interface EnvironmentInput<
2481
2485
  /** Services and/or service groups, keyed by name. See
2482
2486
  * {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
2483
2487
  services: SI;
2488
+ /** Links highlighted in the dashboard and PR previews, in list order.
2489
+ * URL entries target declared service hostnames; terminal entries name services. */
2490
+ interactive?: {
2491
+ entrypoints?: InteractiveEntrypoints<keyof NoInfer<ExpandServices<SI>> & string>;
2492
+ /** Concise, direct plain-text sign-in instructions shown in the dashboard
2493
+ * and a collapsed PR comment section. Example: "Sign in as demo@example.com
2494
+ * and use 000000 as the code". */
2495
+ instructions?: string;
2496
+ };
2484
2497
  /** Sandbox timeout in seconds (default 1h). */
2485
2498
  timeoutSecs?: number;
2486
2499
  /** Fake servers — see {@link defineFake}. Keyed by stable name; the key
@@ -2591,10 +2604,9 @@ export function defineEnvironment<
2591
2604
  F extends FakesMap = FakesMap,
2592
2605
  >(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F> {
2593
2606
  type S = ExpandServices<SI>;
2594
- // Split fakes off the wire config and expand service groups; only
2595
- // { name, services, timeoutSecs } — with plain, fully-expanded services —
2596
- // is stored as `config` and shipped to the control plane.
2597
- const { fakes, services: rawServices, ...rest } = input;
2607
+ // Split fakes off the wire config, expand service groups, and validate
2608
+ // interactive destinations against the final service names.
2609
+ const { fakes, interactive, services: rawServices, ...rest } = input;
2598
2610
  const expanded = expandServiceGroups(rawServices);
2599
2611
  for (const [key, svc] of Object.entries(expanded)) {
2600
2612
  for (const build of svc.imageBuilds ?? []) validateServiceImage(`${key} image ${build.reference}`, build.image);
@@ -2608,6 +2620,15 @@ export function defineEnvironment<
2608
2620
  services: expanded as S,
2609
2621
  };
2610
2622
  validateEnvironmentConfig(config);
2623
+ if (interactive?.instructions !== undefined && typeof interactive.instructions !== "string") {
2624
+ throw new Error("interactive.instructions must be a string");
2625
+ }
2626
+ if (interactive?.entrypoints !== undefined || interactive?.instructions !== undefined) {
2627
+ config.interactive = {
2628
+ entrypoints: interactive.entrypoints === undefined ? [] : resolveEntrypoints(interactive.entrypoints, config.services),
2629
+ ...(interactive.instructions === undefined ? {} : { instructions: interactive.instructions }),
2630
+ };
2631
+ }
2611
2632
  if (fakes) validateFakes(config, fakes);
2612
2633
 
2613
2634
  // Every test created via this env's `.test(...)` is registered here. A
@@ -16,7 +16,10 @@ for (const withHook of [false, true]) {
16
16
  await writeFile(entry, `
17
17
  import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
18
18
  export const calls = [];
19
- const env = defineEnvironment({ name: "seed", services: {
19
+ const env = defineEnvironment({ name: "seed", interactive: {
20
+ entrypoints: [{ name: "App shell", terminal: "app" }],
21
+ instructions: "Sign in as demo@example.com and use 000000 as the code",
22
+ }, services: {
20
23
  app: { image: { type: "registry", reference: "busybox" },
21
24
  helpers: () => ({ label: "typed helper" }) },
22
25
  }});
@@ -43,7 +46,9 @@ for (const withHook of [false, true]) {
43
46
  import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
44
47
  import { calls } from ${JSON.stringify(entry)};
45
48
  const nativeFetch = globalThis.fetch;
46
- await METHODS.load({});
49
+ const loaded = await METHODS.load({});
50
+ assert.deepEqual(loaded.environment.interactive.entrypoints, [{ name: "App shell", terminal: "app" }]);
51
+ assert.equal(loaded.environment.interactive.instructions, "Sign in as demo@example.com and use 000000 as the code");
47
52
  assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
48
53
  await METHODS.projectSetup({});
49
54
  assert.deepEqual(calls, ["baseline"]);
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineEnvironment, serviceGroup, type InteractiveEntrypoints } from "./index.js";
3
+
4
+ const services = {
5
+ web: { image: { type: "registry" as const, reference: "nginx" }, tls: [{ hostname: "app.test", port: 80 }] },
6
+ api: { image: { type: "registry" as const, reference: "nginx" }, hostnames: ["api.test"] },
7
+ };
8
+
9
+ describe("interactive entrypoints", () => {
10
+ test("instructions survive serialization without any entrypoints", () => {
11
+ const instructions = "Sign in as demo@example.com and use 000000 as the code";
12
+ const env = defineEnvironment({ name: "app", services, interactive: { instructions } });
13
+ expect(JSON.parse(JSON.stringify(env.project().environment)).interactive).toEqual({ entrypoints: [], instructions });
14
+ });
15
+
16
+ test("instructions must be a string", () => {
17
+ const invalid = () => defineEnvironment({ name: "app", services, interactive: {
18
+ // @ts-expect-error instructions are plain text
19
+ instructions: 123,
20
+ } });
21
+ expect(invalid).toThrow("interactive.instructions must be a string");
22
+ });
23
+ test("keeps mixed destinations in list order on the project wire config", () => {
24
+ const env = defineEnvironment({ name: "app", services, interactive: { entrypoints: [
25
+ { name: "Website", url: "https://app.test/admin?tab=users#invite" },
26
+ { name: "2", terminal: "api" },
27
+ { name: "1", url: "http://api.test/docs" },
28
+ ] } });
29
+ const config = JSON.parse(JSON.stringify(env.project().environment));
30
+ expect(config.interactive.entrypoints).toEqual([
31
+ { name: "Website", url: "https://app.test/admin?tab=users#invite" },
32
+ { name: "2", terminal: "api" },
33
+ { name: "1", url: "http://api.test/docs" },
34
+ ]);
35
+ expect(env.project().tests).toBeUndefined();
36
+ });
37
+
38
+ test("omitting entrypoints leaves existing projects unchanged", () => {
39
+ expect(defineEnvironment({ name: "app", services }).config.interactive).toBeUndefined();
40
+ });
41
+
42
+ test("terminal destinations can name expanded service-group members", () => {
43
+ const stack = serviceGroup({ primary: "api", services: () => ({ api: services.api, worker: services.web }) });
44
+ const env = defineEnvironment({ name: "app", services: { stack }, interactive: {
45
+ entrypoints: [{ name: "Worker shell", terminal: "stack-worker" }],
46
+ } });
47
+ expect(env.config.interactive?.entrypoints).toEqual([{ name: "Worker shell", terminal: "stack-worker" }]);
48
+ });
49
+
50
+ test.each([null, { App: "https://app.test" }])("requires a list: %j", (entries) => {
51
+ expect(() => defineEnvironment({ name: "app", services, interactive: {
52
+ entrypoints: entries as unknown as InteractiveEntrypoints<"web" | "api">,
53
+ } })).toThrow("must be a list");
54
+ });
55
+
56
+ test("terminal service names are checked statically and at runtime", () => {
57
+ const invalid = () => defineEnvironment({ name: "app", services, interactive: { entrypoints: [
58
+ // @ts-expect-error terminal targets must be declared services
59
+ { name: "Shell", terminal: "missing" },
60
+ ] } });
61
+ expect(invalid).toThrow("terminal must name a declared service");
62
+ });
63
+
64
+ test.each([
65
+ [{ name: "App", url: "https://external.test" }],
66
+ [{ name: "App", url: "/relative" }],
67
+ [{ name: "App", url: "javascript:alert(1)" }],
68
+ [{ name: "App", url: "https://user:pass@app.test" }],
69
+ [{ name: "App", url: "https://app.test:8443" }],
70
+ [{ name: "App", url: "https://app.test", terminal: "web" }],
71
+ [{ name: "App" }],
72
+ [{ name: "", terminal: "web" }],
73
+ [{ name: "Same", terminal: "web" }, { name: "Same", terminal: "api" }],
74
+ ])("rejects invalid destinations %j", (...entries) => {
75
+ expect(() => defineEnvironment({ name: "app", services, interactive: {
76
+ entrypoints: entries as unknown as InteractiveEntrypoints<"web" | "api">,
77
+ } })).toThrow("interactive.entrypoints");
78
+ });
79
+ });
@@ -0,0 +1,41 @@
1
+ /** One destination highlighted in interactive environments and PR previews. */
2
+ export type InteractiveEntrypoint<Service extends string = string> = { name: string } & (
3
+ | { url: string; terminal?: never }
4
+ | { terminal: Service; url?: never }
5
+ );
6
+
7
+ /** Display order is exactly the order of this list. */
8
+ export type InteractiveEntrypoints<Service extends string = string> = readonly InteractiveEntrypoint<Service>[];
9
+
10
+ export function resolveEntrypoints(
11
+ entries: InteractiveEntrypoints,
12
+ services: Record<string, { hostnames?: readonly string[]; tls?: readonly { hostname: string }[] }>,
13
+ ): InteractiveEntrypoint[] {
14
+ if (!Array.isArray(entries)) throw new Error("interactive.entrypoints must be a list");
15
+ const names = new Set<string>();
16
+ return entries.map((entry, index) => {
17
+ const fail = (reason: string): never => { throw new Error(`interactive.entrypoints[${index}]: ${reason}`); };
18
+ if (!entry || typeof entry.name !== "string" || !entry.name.trim()) fail("name must be a non-empty string");
19
+ const { name } = entry;
20
+ if (names.has(name)) fail(`duplicate name ${JSON.stringify(name)}`);
21
+ names.add(name);
22
+ if ((entry.url !== undefined) === (entry.terminal !== undefined)) fail("specify exactly one of url or terminal");
23
+ if (entry.terminal !== undefined) {
24
+ if (typeof entry.terminal !== "string" || !Object.hasOwn(services, entry.terminal)) {
25
+ fail("terminal must name a declared service");
26
+ }
27
+ return { name, terminal: entry.terminal };
28
+ }
29
+ if (typeof entry.url !== "string") return fail("expected an absolute HTTP or HTTPS URL");
30
+ let url: URL;
31
+ try { url = new URL(entry.url); } catch { return fail("expected an absolute HTTP or HTTPS URL"); }
32
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
33
+ fail("use an HTTP or HTTPS service URL without credentials or a non-default port");
34
+ }
35
+ const declared = Object.values(services).some((service) =>
36
+ [...(service.hostnames ?? []), ...(service.tls ?? []).map((tls) => tls.hostname)]
37
+ .some((host) => !host.startsWith("*.") && host.toLowerCase() === url.hostname));
38
+ if (!declared) fail(`hostname ${JSON.stringify(url.hostname)} is not a declared service endpoint`);
39
+ return { name, url: url.href };
40
+ });
41
+ }