@specific.dev/spectest 0.39.0 → 0.41.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.
Files changed (100) hide show
  1. package/dist/components/supabase.d.ts +87 -27
  2. package/dist/components/supabase.js +352 -69
  3. package/dist/daemon.d.ts +38 -0
  4. package/dist/daemon.js +388 -941
  5. package/dist/harness/build-context.d.ts +82 -0
  6. package/dist/harness/build-context.js +113 -0
  7. package/dist/harness/buildkit-progress.d.ts +37 -0
  8. package/dist/harness/buildkit-progress.js +66 -0
  9. package/dist/harness/container-run.d.ts +89 -0
  10. package/dist/harness/container-run.js +118 -0
  11. package/dist/harness/file-mounts.d.ts +91 -0
  12. package/dist/harness/file-mounts.js +119 -0
  13. package/dist/harness/hostmatch.d.ts +65 -0
  14. package/dist/harness/hostmatch.js +108 -0
  15. package/dist/harness/http-proxy.d.ts +62 -0
  16. package/dist/harness/http-proxy.js +104 -0
  17. package/dist/harness/ingress-table.d.ts +148 -0
  18. package/dist/harness/ingress-table.js +129 -0
  19. package/dist/harness/log-delta.d.ts +54 -0
  20. package/dist/harness/log-delta.js +83 -0
  21. package/dist/harness/main.d.ts +47 -0
  22. package/dist/harness/main.js +164 -0
  23. package/dist/harness/methods.d.ts +54 -0
  24. package/dist/harness/methods.js +65 -0
  25. package/dist/harness/names-registry.d.ts +63 -0
  26. package/dist/harness/names-registry.js +90 -0
  27. package/dist/harness/protocol.d.ts +88 -0
  28. package/dist/harness/protocol.js +96 -0
  29. package/dist/harness/ready-poll.d.ts +47 -0
  30. package/dist/harness/ready-poll.js +67 -0
  31. package/dist/harness/service-graph.d.ts +29 -0
  32. package/dist/harness/service-graph.js +92 -0
  33. package/dist/harness/volume-paths.d.ts +70 -0
  34. package/dist/harness/volume-paths.js +81 -0
  35. package/dist/index.d.ts +3 -3
  36. package/dist/ingress.d.ts +1 -1
  37. package/dist/resolver.js +5 -8
  38. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  39. package/dist/vendor/rrweb-record.min.js +5061 -0
  40. package/package.json +7 -1
  41. package/src/aws-sigv4.ts +218 -0
  42. package/src/browser.ts +2040 -0
  43. package/src/components/aws.ts +554 -0
  44. package/src/components/email.ts +398 -0
  45. package/src/components/expo.ts +167 -0
  46. package/src/components/index.ts +81 -0
  47. package/src/components/k3s.ts +2061 -0
  48. package/src/components/postgres.ts +132 -0
  49. package/src/components/replayFake.ts +1015 -0
  50. package/src/components/s3.ts +132 -0
  51. package/src/components/supabase.ts +1699 -0
  52. package/src/daemon.ts +5489 -0
  53. package/src/harness/build-context.test.ts +0 -0
  54. package/src/harness/build-context.ts +146 -0
  55. package/src/harness/buildkit-progress.test.ts +98 -0
  56. package/src/harness/buildkit-progress.ts +74 -0
  57. package/src/harness/container-run.test.ts +209 -0
  58. package/src/harness/container-run.ts +158 -0
  59. package/src/harness/file-mounts.test.ts +185 -0
  60. package/src/harness/file-mounts.ts +145 -0
  61. package/src/harness/hostmatch.test.ts +148 -0
  62. package/src/harness/hostmatch.ts +109 -0
  63. package/src/harness/http-proxy.test.ts +156 -0
  64. package/src/harness/http-proxy.ts +119 -0
  65. package/src/harness/ingress-rebind.test.ts +125 -0
  66. package/src/harness/ingress-table.test.ts +172 -0
  67. package/src/harness/ingress-table.ts +186 -0
  68. package/src/harness/log-delta.test.ts +125 -0
  69. package/src/harness/log-delta.ts +100 -0
  70. package/src/harness/main.test.ts +211 -0
  71. package/src/harness/main.ts +196 -0
  72. package/src/harness/methods.test.ts +63 -0
  73. package/src/harness/methods.ts +92 -0
  74. package/src/harness/names-registry.test.ts +137 -0
  75. package/src/harness/names-registry.ts +108 -0
  76. package/src/harness/protocol.test.ts +148 -0
  77. package/src/harness/protocol.ts +163 -0
  78. package/src/harness/ready-poll.test.ts +172 -0
  79. package/src/harness/ready-poll.ts +93 -0
  80. package/src/harness/service-graph.test.ts +97 -0
  81. package/src/harness/service-graph.ts +97 -0
  82. package/src/harness/volume-paths.test.ts +102 -0
  83. package/src/harness/volume-paths.ts +112 -0
  84. package/src/ids.ts +89 -0
  85. package/src/index.ts +2725 -0
  86. package/src/ingress.ts +305 -0
  87. package/src/inspect.ts +739 -0
  88. package/src/locator.ts +716 -0
  89. package/src/mobile.ts +133 -0
  90. package/src/record-secrets.ts +41 -0
  91. package/src/recorder.ts +846 -0
  92. package/src/redis.ts +202 -0
  93. package/src/replay-bundle.ts +108 -0
  94. package/src/resolver.ts +348 -0
  95. package/src/s3.ts +333 -0
  96. package/src/sql.ts +243 -0
  97. package/src/terminal.ts +740 -0
  98. package/src/url-match.ts +67 -0
  99. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  100. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -51,7 +51,7 @@
51
51
  import { createHmac } from "node:crypto";
52
52
  import { existsSync } from "node:fs";
53
53
  import { readdir, stat } from "node:fs/promises";
54
- import { join } from "node:path";
54
+ import { dirname, join } from "node:path";
55
55
  import { serviceGroup, } from "../index.js";
56
56
  import { SQL } from "../sql.js";
57
57
  import { email, emailHelpers } from "./email.js";
@@ -70,7 +70,17 @@ const IMAGES = {
70
70
  imgproxy: "darthsim/imgproxy:v3.30.1",
71
71
  meta: "supabase/postgres-meta:v0.96.6",
72
72
  studio: "supabase/studio:2026.07.07-sha-a6a04f2",
73
+ functions: "supabase/edge-runtime:v1.74.0",
73
74
  };
75
+ /**
76
+ * The project root inside the VM. A service's `volumes[].source` is a static
77
+ * string in the wire config, so the edge runtime's bind mount cannot go
78
+ * through `ctx.projectRoot` (which only exists once a hook runs) — this is
79
+ * the same value, read the same way the harness reads it.
80
+ */
81
+ function projectRootPath() {
82
+ return process.env.SPECTEST_WORKSPACE ?? "/workspace";
83
+ }
74
84
  // Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
75
85
  const DEFAULT_JWT_SECRET = "your-super-secret-jwt-token-with-at-least-32-characters-long";
76
86
  // Fixed issued-at / expiry for the derived API keys. Constant (not `Date.now`)
@@ -147,6 +157,32 @@ function buildBootstrapSql(dbPassword, jwtSecret, withRealtime) {
147
157
  // as `apikey` header, `Authorization: Bearer <key>`, or the websocket
148
158
  // `?apikey=` query param) is swapped for the corresponding *derived JWT*
149
159
  // before proxying, so the upstream services still see a validly-signed token.
160
+ //
161
+ // SPECTEST DEVIATION — the `apikey` **query parameter** is a first-class
162
+ // credential here, not just a websocket one. Kong's key-auth already accepts
163
+ // it (`key_in_query` defaults on), but upstream saw only the header, so a
164
+ // query-param request reached PostgREST as the anon role. A browser is the
165
+ // reason this matters: `browser.goto(url)` sends no headers, so a top-level
166
+ // navigation to a gated URL can carry a key ONLY in the query string (and a
167
+ // 401 there surfaces as Chromium's opaque `ERR_INVALID_AUTH_CREDENTIALS`,
168
+ // not a status code).
169
+ //
170
+ // The rest/graphql routes additionally `remove.querystring: [apikey]` in
171
+ // their request-transformer, because PostgREST reads every unknown query
172
+ // parameter as a column filter and answers `400 PGRST100` on the leftover
173
+ // key. `hide_credentials: true` on key-auth would strip it (and the header)
174
+ // far earlier — before this request-transformer runs — which would break
175
+ // both the header swap above and the realtime websocket route, whose own
176
+ // expression reads `query_params.apikey`. So the strip belongs here, on the
177
+ // one upstream that cannot tolerate the parameter.
178
+ //
179
+ // MEASURED CONSEQUENCE — request-transformer removes the query parameter
180
+ // BEFORE it renders the Authorization template, so on `/rest/v1` the JWT
181
+ // never reaches PostgREST and the request runs as **anon**, whichever key
182
+ // was sent. Verified against an RLS-protected table: the service-role key
183
+ // as a header returned the row, the same key in the query returned `[]`.
184
+ // Anon is the right privilege for the browser navigation this exists for;
185
+ // anything needing service-role must send the header.
150
186
  function buildLuaExprs(extra) {
151
187
  // Keep a client's `Authorization: Bearer <extraKey>` from winning the
152
188
  // header-passthrough clause with the raw (non-JWT) key.
@@ -154,9 +190,9 @@ function buildLuaExprs(extra) {
154
190
  .map((m) => ` and headers.authorization ~= 'Bearer ${m.key}'`)
155
191
  .join("");
156
192
  const mappings = extra
157
- .map((m) => ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}') and 'Bearer ${m.jwt}')`)
193
+ .map((m) => ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}' or query_params.apikey == '${m.key}') and 'Bearer ${m.jwt}')`)
158
194
  .join("");
159
- const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey)`;
195
+ const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey or query_params.apikey)`;
160
196
  const wsMappings = extra
161
197
  .map((m) => `(query_params.apikey == '${m.key}' and '${m.jwt}') or `)
162
198
  .join("");
@@ -287,6 +323,9 @@ services:
287
323
  replace:
288
324
  headers:
289
325
  - "Authorization: $LUA_AUTH_EXPR"
326
+ remove:
327
+ querystring:
328
+ - apikey
290
329
  - name: acl
291
330
  config:
292
331
  hide_groups_header: true
@@ -312,6 +351,9 @@ services:
312
351
  replace:
313
352
  headers:
314
353
  - "Authorization: $LUA_AUTH_EXPR"
354
+ remove:
355
+ querystring:
356
+ - apikey
315
357
  - name: acl
316
358
  config:
317
359
  hide_groups_header: true
@@ -339,6 +381,9 @@ services:
339
381
  replace:
340
382
  headers:
341
383
  - "Authorization: $LUA_AUTH_EXPR"
384
+ remove:
385
+ querystring:
386
+ - apikey
342
387
  - name: acl
343
388
  config:
344
389
  hide_groups_header: true
@@ -450,6 +495,7 @@ services:
450
495
  if auth == nil or auth == "" or auth:find("^%s*$") then
451
496
  kong.service.request.clear_header("authorization")
452
497
  end
498
+ # >>> functions (dropped when the edge runtime is off)
453
499
  - name: functions-v1
454
500
  url: http://functions:9000/
455
501
  read_timeout: 150000
@@ -460,6 +506,7 @@ services:
460
506
  - /functions/v1/
461
507
  plugins:
462
508
  - name: cors
509
+ # <<< functions
463
510
  - name: well-known-oauth
464
511
  url: http://auth:9999/.well-known/oauth-authorization-server
465
512
  routes:
@@ -498,11 +545,125 @@ services:
498
545
  config:
499
546
  hide_credentials: true
500
547
  `;
548
+ // ──────────────────────────────────────────────────────────────────────────
549
+ // Edge functions — the runtime's "main service": the request router that
550
+ // spawns one user worker per function directory.
551
+ //
552
+ // Upstream ships an equivalent file in the repo the user is expected to copy
553
+ // (`docker/volumes/functions/main/index.ts`). We inject our own instead, for
554
+ // two reasons: the project's `supabase/functions/` holds the user's
555
+ // functions and should not need a `main/` of ours in it; and upstream's
556
+ // router imports `jsr:@panva/jose` at boot, which is a network fetch inside
557
+ // an otherwise hermetic VM. HS256 is the only algorithm this stack signs
558
+ // with, and Web Crypto verifies it in a few lines with no dependency.
559
+ // ──────────────────────────────────────────────────────────────────────────
560
+ const FUNCTIONS_MAIN_TS = String.raw `// spectest-generated edge-functions router. Do not edit.
561
+ const FUNCTIONS_DIR = "/home/deno/functions";
562
+ const JWT_SECRET = Deno.env.get("JWT_SECRET") ?? "";
563
+ const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
564
+
565
+ function json(body: unknown, status: number): Response {
566
+ return new Response(JSON.stringify(body), {
567
+ status,
568
+ headers: { "content-type": "application/json" },
569
+ });
570
+ }
571
+
572
+ function base64UrlToBytes(s: string): Uint8Array {
573
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/");
574
+ const bin = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
575
+ const out = new Uint8Array(bin.length);
576
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
577
+ return out;
578
+ }
579
+
580
+ // HS256 only — every token this stack mints (the anon/service-role API keys
581
+ // and every GoTrue access token) is signed with the shared JWT secret.
582
+ async function validJwt(token: string): Promise<boolean> {
583
+ const parts = token.split(".");
584
+ if (parts.length !== 3 || !JWT_SECRET) return false;
585
+ try {
586
+ const key = await crypto.subtle.importKey(
587
+ "raw",
588
+ new TextEncoder().encode(JWT_SECRET),
589
+ { name: "HMAC", hash: "SHA-256" },
590
+ false,
591
+ ["verify"],
592
+ );
593
+ const signed = new TextEncoder().encode(parts[0] + "." + parts[1]);
594
+ if (!(await crypto.subtle.verify("HMAC", key, base64UrlToBytes(parts[2]), signed))) {
595
+ return false;
596
+ }
597
+ const claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[1])));
598
+ if (typeof claims.exp === "number" && claims.exp * 1000 < Date.now()) return false;
599
+ return true;
600
+ } catch {
601
+ return false;
602
+ }
603
+ }
604
+
605
+ Deno.serve(async (req: Request) => {
606
+ const { pathname } = new URL(req.url);
607
+
608
+ // Ready probe — answered before any auth check, so bring-up never needs a
609
+ // token, and never treated as a function name.
610
+ if (pathname === "/_spectest/health") return new Response("ok");
611
+
612
+ const name = pathname.split("/")[1] ?? "";
613
+ if (name === "") return json({ msg: "missing function name in request" }, 400);
614
+
615
+ if (VERIFY_JWT && req.method !== "OPTIONS") {
616
+ const header = req.headers.get("authorization") ?? "";
617
+ const [scheme, token] = header.split(" ");
618
+ if (scheme !== "Bearer" || !token || !(await validJwt(token))) {
619
+ return json({ msg: "Invalid JWT" }, 401);
620
+ }
621
+ }
622
+
623
+ const servicePath = FUNCTIONS_DIR + "/" + name;
624
+ try {
625
+ const worker = await EdgeRuntime.userWorkers.create({
626
+ servicePath,
627
+ memoryLimitMb: 256,
628
+ workerTimeoutMs: 5 * 60 * 1000,
629
+ noModuleCache: false,
630
+ importMapPath: null,
631
+ envVars: Object.entries(Deno.env.toObject()),
632
+ });
633
+ return await worker.fetch(req);
634
+ } catch (e) {
635
+ // A missing directory lands here too, so say which function was asked
636
+ // for — the runtime's own message names only the path.
637
+ return json({ msg: String(e), function: name, servicePath }, 500);
638
+ }
639
+ });
640
+ `;
641
+ /** Keep or drop a `# >>> <tag>` … `# <<< <tag>` block, and always remove the
642
+ * marker lines themselves. */
643
+ function stripMarkedBlock(yaml, tag, keep) {
644
+ const out = [];
645
+ let inside = false;
646
+ for (const line of yaml.split("\n")) {
647
+ if (line.trim().startsWith(`# >>> ${tag}`)) {
648
+ inside = true;
649
+ continue;
650
+ }
651
+ if (line.trim().startsWith(`# <<< ${tag}`)) {
652
+ inside = false;
653
+ continue;
654
+ }
655
+ if (inside && !keep)
656
+ continue;
657
+ out.push(line);
658
+ }
659
+ return out.join("\n");
660
+ }
501
661
  /** Produce the final Kong declarative config: rewrite upstream bare hostnames
502
662
  * to the group's final service DNS names, substitute the API keys / dashboard
503
663
  * credentials / Lua expressions, and drop the empty opaque-key lines. */
504
664
  function buildKongYaml(key, vals) {
505
665
  let y = KONG_YML_RAW;
666
+ y = stripMarkedBlock(y, "functions", vals.functions);
506
667
  // Extra API keys land where kong-entrypoint.sh's opaque keys would: as
507
668
  // additional keyauth credentials on the matching consumer. The template's
508
669
  // placeholder lines are either expanded here or dropped by the filter below.
@@ -542,73 +703,126 @@ function buildKongYaml(key, vals) {
542
703
  .join("\n");
543
704
  return y;
544
705
  }
545
- // ──────────────────────────────────────────────────────────────────────────
546
- // Migration application (runs in the db service's `setup`, after the DB is
547
- // ready and before any dependent service starts). Uses the setup context's
548
- // `exec` (docker exec with piped stdin) and `projectRoot` — no hand-rolled
549
- // child_process, no hard-coded workspace path.
550
- // ──────────────────────────────────────────────────────────────────────────
551
706
  /** Pipe one SQL file's contents into `psql` inside the db container. Uses
552
707
  * `psql -f -` (stdin) so multi-statement files and non-transactional DDL
553
708
  * (e.g. `CREATE INDEX CONCURRENTLY`) behave exactly as `supabase db reset`
554
709
  * applies them — not wrapped in a single transaction. `ON_ERROR_STOP=1` fails
555
710
  * fast on the first bad statement. */
556
- async function applySqlFile(ctx, sql, label, user) {
557
- const res = await ctx.exec(ctx.name, ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"], { stdin: sql, timeoutMs: 300_000 });
711
+ async function applySqlFile(ctx, sql, label, user,
712
+ /** The db service to run `psql` in. Defaults to the hook's own service,
713
+ * which is the db only for the bootstrap; the project's migrations run
714
+ * from the group hook, where `ctx.name` is the gateway. */
715
+ service = ctx.name) {
716
+ const res = await ctx.exec(service, ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"], { stdin: sql, timeoutMs: 300_000 });
558
717
  if (res.exitCode !== 0) {
559
718
  throw new Error(`supabase: applying ${label} failed (psql rc=${res.exitCode}):\n${res.stderr.trim() || res.stdout.trim()}`);
560
719
  }
561
720
  }
562
- /** Resolve, sort, and apply the project's Supabase migrations + seed against
563
- * the running db container as the `postgres` role (matching `supabase db
564
- * reset` — tables end up owned by postgres). No-op when nothing is
565
- * configured/present. */
566
- async function applyMigrations(ctx, cfg) {
567
- // Migrations directory. An *explicitly configured* path that doesn't
568
- // resolve is a hard error — a silent no-op here surfaces much later as a
569
- // baffling `relation "public.…" does not exist`. Only the `true` default
570
- // (conventional `supabase/migrations/`) is allowed to be absent.
571
- if (cfg.migrations !== false) {
572
- const explicit = typeof cfg.migrations === "string";
573
- const dir = explicit
574
- ? cfg.migrations.startsWith("/")
575
- ? cfg.migrations
576
- : join(ctx.projectRoot, cfg.migrations)
577
- : join(ctx.projectRoot, "supabase", "migrations");
578
- if (existsSync(dir) && (await stat(dir)).isDirectory()) {
579
- const files = (await readdir(dir))
580
- .filter((f) => f.endsWith(".sql"))
581
- .sort();
582
- for (const f of files) {
583
- const sql = await ctx.readProjectFile(join(dir, f));
584
- await applySqlFile(ctx, sql, `migration ${f}`, "postgres");
585
- }
586
- }
587
- else if (explicit) {
588
- throw new Error(`supabase: configured migrations path ${JSON.stringify(cfg.migrations)} does not resolve to a directory (looked at ${dir}). ` +
589
- `Fix the \`migrations\` option, or remove it to use the default ` +
590
- `supabase/migrations/. Note the path is resolved inside the uploaded ` +
591
- `project — check it isn't ignored (e.g. via .spectestignore).`);
592
- }
721
+ /** Resolve, sort, and apply the project's Supabase migrations — and the
722
+ * `seed.sql` the standard layout puts beside them — against the running db
723
+ * container as the `postgres` role (matching `supabase db reset`: tables end
724
+ * up owned by postgres).
725
+ *
726
+ * Only ever called with a path the user gave: there is no assumed location,
727
+ * so this never applies SQL nobody asked for. */
728
+ async function applyMigrations(ctx, migrations,
729
+ /** Where `psql` runs — the db part, named from the group hook. */
730
+ db) {
731
+ const dir = migrations.startsWith("/")
732
+ ? migrations
733
+ : join(ctx.projectRoot, migrations);
734
+ // A path that doesn't resolve is a hard error: a silent no-op here
735
+ // surfaces much later as a baffling `relation "public.…" does not exist`.
736
+ if (!existsSync(dir) || !(await stat(dir)).isDirectory()) {
737
+ throw new Error(`supabase: migrations path ${JSON.stringify(migrations)} does not ` +
738
+ `resolve to a directory (looked at ${dir}). The path is resolved ` +
739
+ `inside the uploaded project — check it isn't ignored (e.g. via ` +
740
+ `.spectestignore).`);
741
+ }
742
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
743
+ for (const f of files) {
744
+ const sql = await ctx.readProjectFile(join(dir, f));
745
+ await applySqlFile(ctx, sql, `migration ${f}`, "postgres", db);
746
+ }
747
+ // `seed.sql` sits next to the migrations directory in the layout the
748
+ // Supabase CLI creates, so it needs no option of its own — pointing at
749
+ // the migrations is enough to say where the project's SQL lives. Absent
750
+ // is fine: plenty of projects have migrations and no seed.
751
+ const seedPath = join(dirname(dir), "seed.sql");
752
+ if (existsSync(seedPath)) {
753
+ const sql = await ctx.readProjectFile(seedPath);
754
+ await applySqlFile(ctx, sql, "seed.sql", "postgres", db);
593
755
  }
594
- // Seed file — same contract: only the conventional default may be absent.
595
- if (cfg.seed !== false) {
596
- const explicit = typeof cfg.seed === "string";
597
- const seedPath = explicit
598
- ? cfg.seed.startsWith("/")
599
- ? cfg.seed
600
- : join(ctx.projectRoot, cfg.seed)
601
- : join(ctx.projectRoot, "supabase", "seed.sql");
602
- if (existsSync(seedPath)) {
603
- const sql = await ctx.readProjectFile(seedPath);
604
- await applySqlFile(ctx, sql, "seed.sql", "postgres");
756
+ }
757
+ /**
758
+ * Make PostgREST serve the schema the migrations just created, and do not
759
+ * return until it does so **stably**.
760
+ *
761
+ * PostgREST reads the schema once, at boot, and now boots before the
762
+ * project's migrations run. Without this, the first REST call for a new
763
+ * table answers `PGRST205 Could not find the table` — the failure the user
764
+ * would otherwise have to diagnose and work around themselves.
765
+ *
766
+ * Two things make this more than one request:
767
+ *
768
+ * - **A rebuild is not atomic from the outside.** While PostgREST reloads,
769
+ * a request can still miss the table. So a single successful check is not
770
+ * proof; the cache has to answer correctly several times in a row.
771
+ * - **Reloads are already in flight.** The `supabase/postgres` image ships
772
+ * `pgrst_ddl_watch`, an event trigger that notifies on every DDL, so a
773
+ * migration run queues several reloads of its own. Ours goes *first* and
774
+ * the settle loop then waits them all out — checking before sending the
775
+ * notify was a real bug: the poll passed, our own notify landed after it,
776
+ * and a test raced the rebuild it caused.
777
+ *
778
+ * The expectation comes from the database itself: every base table in the
779
+ * exposed `public` schema should be listed. A stack with no public tables
780
+ * has nothing to wait for.
781
+ */
782
+ async function reloadPostgrestSchema(ctx, db, rest) {
783
+ // Ours first, so every reload — ours and the DDL trigger's — is already
784
+ // queued before we start watching for the result.
785
+ await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
786
+ const listed = await ctx.exec(db, [
787
+ "psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
788
+ "select table_name from information_schema.tables " +
789
+ "where table_schema = 'public' and table_type = 'BASE TABLE' order by 1",
790
+ ], { timeoutMs: 60_000 });
791
+ const tables = String(listed.stdout)
792
+ .split("\n")
793
+ .map((t) => t.trim())
794
+ .filter(Boolean);
795
+ if (tables.length === 0)
796
+ return;
797
+ /** One check: are all the tables served right now? */
798
+ const served = async () => {
799
+ const res = await fetch(`http://${rest}:3000/`);
800
+ if (!res.ok)
801
+ return tables;
802
+ const spec = (await res.json());
803
+ const paths = Object.keys(spec.paths ?? {});
804
+ return tables.filter((t) => !paths.includes(`/${t}`));
805
+ };
806
+ const SETTLE = 3;
807
+ const deadline = Date.now() + 30_000;
808
+ let streak = 0;
809
+ let missing = tables;
810
+ while (Date.now() < deadline) {
811
+ try {
812
+ missing = await served();
813
+ streak = missing.length === 0 ? streak + 1 : 0;
814
+ if (streak >= SETTLE)
815
+ return;
605
816
  }
606
- else if (explicit) {
607
- throw new Error(`supabase: configured seed path ${JSON.stringify(cfg.seed)} not found ` +
608
- `(looked at ${seedPath}). Fix the \`seed\` option or remove it to ` +
609
- `use the default supabase/seed.sql.`);
817
+ catch {
818
+ // Mid-reload PostgREST can refuse the connection outright.
819
+ streak = 0;
610
820
  }
821
+ await new Promise((r) => setTimeout(r, 150));
611
822
  }
823
+ throw new Error(`supabase: PostgREST did not settle on the migrated schema within 30s ` +
824
+ `(still missing: ${missing.join(", ") || "nothing, but not stably"}). ` +
825
+ `The migrations applied — this is the REST schema cache, not your SQL.`);
612
826
  }
613
827
  /**
614
828
  * A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
@@ -642,6 +856,26 @@ export function supabase(opts = {}) {
642
856
  // `mailKey` is the SMTP host GoTrue mails through — the in-group part
643
857
  // (`<name>-mail`) or a user-declared `email()` service shared with the app
644
858
  // under test.
859
+ // Edge functions — like `migrations`, a path the caller gives or nothing
860
+ // at all. A directory that doesn't resolve is an error rather than an
861
+ // empty runtime whose every call 500s much later.
862
+ const fnOpts = opts.functions === undefined
863
+ ? null
864
+ : typeof opts.functions === "string"
865
+ ? { dir: opts.functions }
866
+ : opts.functions;
867
+ const withFunctions = fnOpts !== null;
868
+ const fnDir = fnOpts === null
869
+ ? ""
870
+ : fnOpts.dir.startsWith("/")
871
+ ? fnOpts.dir
872
+ : join(projectRootPath(), fnOpts.dir);
873
+ if (withFunctions && !existsSync(fnDir)) {
874
+ throw new Error(`supabase(): functions path ${JSON.stringify(fnOpts.dir)} does not ` +
875
+ `resolve to a directory (looked at ${fnDir}). It should hold one ` +
876
+ `subdirectory per function. The path is resolved inside the uploaded ` +
877
+ `project — check it isn't ignored (e.g. via .spectestignore).`);
878
+ }
645
879
  const withMail = opts.mail !== false;
646
880
  const mailOpts = typeof opts.mail === "object" ? opts.mail : {};
647
881
  const mailExternal = mailOpts.service !== undefined;
@@ -710,18 +944,16 @@ export function supabase(opts = {}) {
710
944
  // baked init-scripts and starts serving for real.
711
945
  readyCheck: { type: "exec", command: "pg_isready -U postgres -h 127.0.0.1 -p 5432", timeoutSecs: 180 },
712
946
  setup: async (ctx) => {
713
- // Bootstrap first (role passwords / JWT GUC / _realtime schema),
714
- // then the project's own migrations + seed — both before any
715
- // dependent service.
947
+ // Only the database's own bootstrap (role passwords / JWT GUC /
948
+ // `_realtime` schema) runs here: the other services need it before
949
+ // they can start. The project's migrations run later, from the
950
+ // group hook, once the whole stack is up — see the migration
951
+ // section above for why.
716
952
  await applySqlFile(ctx, buildBootstrapSql(dbPassword, jwtSecret, withRealtime), "supabase bootstrap",
717
953
  // The bootstrap alters reserved roles (authenticator, …) and the
718
954
  // database — superuser-only. `postgres` is deliberately NOT a
719
955
  // superuser in modern supabase/postgres; `supabase_admin` is.
720
956
  "supabase_admin");
721
- await applyMigrations(ctx, {
722
- migrations: opts.migrations ?? true,
723
- seed: opts.seed ?? true,
724
- });
725
957
  },
726
958
  };
727
959
  parts.db = dbDef;
@@ -772,11 +1004,16 @@ export function supabase(opts = {}) {
772
1004
  GOTRUE_JWT_ISSUER: apiExternalUrl,
773
1005
  GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
774
1006
  GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
775
- // Autoconfirm defaults ON (one-step signups, no confirmation
776
- // mail) so the always-on mailbox is purely additive; the mail
777
- // option's `autoconfirm: false` opts into real confirmation
778
- // flows.
779
- GOTRUE_MAILER_AUTOCONFIRM: withMail && !mailAutoconfirm ? "false" : "true",
1007
+ // Autoconfirm is ON by default: a signup completes in one step
1008
+ // and sends no confirmation mail, so the always-on mailbox is
1009
+ // purely additive and a suite that only needs accounts to exist
1010
+ // writes nothing. `autoconfirm: false` opts into the real
1011
+ // confirmation flow, which the mailbox is there to drive.
1012
+ //
1013
+ // Always ON when there is no mailbox (`mail: false`), whatever
1014
+ // was asked for: the confirmation would be sent nowhere and no
1015
+ // account could ever be used.
1016
+ GOTRUE_MAILER_AUTOCONFIRM: !withMail || mailAutoconfirm ? "true" : "false",
780
1017
  GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
781
1018
  GOTRUE_SMS_AUTOCONFIRM: "true",
782
1019
  ...(withMail
@@ -892,6 +1129,41 @@ export function supabase(opts = {}) {
892
1129
  },
893
1130
  };
894
1131
  }
1132
+ // ── functions (edge runtime) ────────────────────────────────────
1133
+ if (withFunctions) {
1134
+ parts.functions = {
1135
+ image: { type: "registry", reference: IMAGES.functions },
1136
+ // The repo's own function directory, mounted where the runtime
1137
+ // looks. Read-only: the runtime only ever reads the sources, and
1138
+ // a function that writes into its own directory would be writing
1139
+ // into the user's checkout.
1140
+ volumes: [{ source: fnDir, target: "/home/deno/functions", readOnly: true }],
1141
+ // Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
1142
+ // the project never has to carry a `main/` function of ours.
1143
+ files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
1144
+ args: ["start", "--main-service", "/home/deno/main", "--port", "9000"],
1145
+ env: {
1146
+ JWT_SECRET: jwtSecret,
1147
+ VERIFY_JWT: (fnOpts.verifyJwt ?? true) ? "true" : "false",
1148
+ // A function calls back into its own project through the
1149
+ // gateway, exactly as it does when hosted.
1150
+ SUPABASE_URL: `http://${g.name}:8000`,
1151
+ SUPABASE_PUBLIC_URL: url,
1152
+ SUPABASE_ANON_KEY: anonKey,
1153
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
1154
+ SUPABASE_DB_URL: `postgresql://postgres:${dbPassword}@${db}:5432/postgres`,
1155
+ ...(fnOpts.env ?? {}),
1156
+ },
1157
+ ports: [9000],
1158
+ dependsOn: ["db"],
1159
+ readyCheck: {
1160
+ type: "http",
1161
+ port: 9000,
1162
+ path: "/_spectest/health",
1163
+ timeoutSecs: 120,
1164
+ },
1165
+ };
1166
+ }
895
1167
  // ── meta (postgres-meta) ────────────────────────────────────────
896
1168
  if (withMeta) {
897
1169
  parts.meta = {
@@ -966,6 +1238,7 @@ export function supabase(opts = {}) {
966
1238
  dashboardPassword,
967
1239
  extraAnonKeys,
968
1240
  extraServiceRoleKeys,
1241
+ functions: withFunctions,
969
1242
  }),
970
1243
  },
971
1244
  ],
@@ -975,6 +1248,16 @@ export function supabase(opts = {}) {
975
1248
  };
976
1249
  return parts;
977
1250
  },
1251
+ // The project's own SQL, applied with the whole stack up (the group
1252
+ // hook chains after the primary, and the primary is the group's
1253
+ // dependency sink). Still inside bring-up, so the result is captured
1254
+ // into the cached environment like everything else.
1255
+ setup: async (ctx) => {
1256
+ if (opts.migrations === undefined)
1257
+ return;
1258
+ await applyMigrations(ctx, opts.migrations, dbKey);
1259
+ await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
1260
+ },
978
1261
  // The group's consolidated handle at `ctx.svc.<name>`.
979
1262
  helpers: () => ({
980
1263
  sql: new SQL(adminDbUrl, { label: name }),
package/dist/daemon.d.ts CHANGED
@@ -1 +1,39 @@
1
+ import { type MethodTable } from "./harness/methods.js";
2
+ /**
3
+ * Mutual exclusion between the operations that cannot overlap.
4
+ *
5
+ * One harness process holds one environment, so a second test running
6
+ * beside the first would share its containers, its fake state and its
7
+ * recorder. These flags are what make "already running" a refusal rather
8
+ * than a corruption.
9
+ */
10
+ interface RouteState {
11
+ inFlightTest: Promise<unknown> | null;
12
+ inFlightBootstrap: Promise<unknown> | null;
13
+ inFlightProjectSetup: Promise<unknown> | null;
14
+ }
15
+ /**
16
+ * Everything the harness can be asked to do.
17
+ *
18
+ * Transport-neutral by construction: params in, plain result out, failures
19
+ * as {@link HarnessError}. The HTTP router below and the supervisor's frame
20
+ * dispatcher both drive this same table, so a change reaches both — which
21
+ * is the property that makes it safe to run the two side by side during the
22
+ * cutover.
23
+ */
24
+ export declare function harnessMethods(state: RouteState): MethodTable;
25
+ /**
26
+ * The method table for this process.
27
+ *
28
+ * Built once, so the mutual-exclusion slots are process-wide: `run` refuses
29
+ * a second case while one is in flight, and that guarantee has to hold for
30
+ * the process, not per caller. Two tables would each enforce the rule and
31
+ * together break it — which is why this is a value and not a factory the
32
+ * caller invokes.
33
+ *
34
+ * The harness (`sdk/src/harness/main.ts`) serves this over frames. It used
35
+ * to be served over HTTP as well, by a listener in this file; that was the
36
+ * in-VM daemon, and it is gone.
37
+ */
38
+ export declare const METHODS: MethodTable;
1
39
  export {};