Skip to content

Dequeue is broken when the Prisma client is scoped to a non-public schema #15

Description

@Munter

We run review apps on a shared Postgres database, one schema per review app, with the Prisma client scoped via the driver adapter: new PrismaPg(config, { schema }). In that deployment the queue cannot dequeue anything.

Hit on 2.4.0, verified unchanged on master (3.1.0) with @prisma/client / @prisma/adapter-pg 7.8.0 against Postgres 17.

Mechanism

The adapter's schema option qualifies generated queries only. Raw queries are sent as written, and the adapter does not touch the connection's search_path (with { schema: "x" } set, SHOW search_path still returns "$user", public).

The queue's two halves therefore address different tables:

  • enqueue goes through the queueJob delegate, which Prisma qualifies: INSERT INTO "x"."queue_jobs" ...
  • dequeue is raw SQL (FOR UPDATE SKIP LOCKED), naming the bare identifier "queue_jobs", which resolves through search_path

Two failure modes, depending on whether a public.queue_jobs happens to exist:

  • it does not: every dequeue fails with 42P01 relation "queue_jobs" does not exist
  • it does: no error at all; jobs are written to one table and polled from another, so nothing is ever picked up

Either way the failure is total and quiet. Nothing dequeues, every job type hangs, including any cleanup jobs. It also only appears once a worker actually polls, so a test suite that enqueues without running workers passes.

Repro

const adapter = new PrismaPg({ connectionString }, { schema: "review_app_1" });
const prisma = new PrismaClient({ adapter });
const queue = createQueue({ prisma, name: "email" }, worker);

await queue.enqueue({ email: "foo@bar.com" }); // lands in "review_app_1"."queue_jobs"
await queue.start(); // polls "queue_jobs" via search_path: 42P01, or silently the wrong table

tableName is not an escape hatch: it is escaped as a single identifier, so review_app_1"."queue_jobs becomes one identifier literally named that.

Possible fixes

Postgres accepts schema.table.column, and every raw call site interpolates the one precomputed escaped table name, so each option below is a small diff with no SQL changes.

1. A schema option

schema?: string | null on PrismaQueueOptions, defaulting to null (current behaviour, byte-identical SQL). Callers pass the same schema they gave the adapter. Schema and table are escaped separately, so neither can inject the other.

Implemented on Munter:feat/schema-option, full suite green, integration test runs a job through a schema-scoped client.

Merits: explicit and documented, no reliance on client internals, follows the existing null-default option idiom (jobTimeout, staleTimeout). Cost: only helps callers who know the option exists, and the silent failure mode means many will not.

2. Accept a qualified tableName (discarded)

Leaving a . unescaped so callers could pass "myschema"."queue_jobs". Discarded: it overloads tableName to mean something other than a table name, and the consumer has to know to encode the schema there. Confusing on both counts.

3. Read the schema off the client

The adapter instance the caller handed to new PrismaClient({ adapter }) is reachable as client._engineConfig.adapter, and _engineConfig.adapter is declared in Prisma's own runtime EngineConfig type. It is the same object on a plain, extended, and transaction-scoped client, so one structural read in the constructor covers every client shape, failing soft to the current unqualified behaviour when the shape is unrecognised.

Implemented on Munter:fix/adapter-schema-qualification, full suite green, same integration test plus unit tests for the extraction.

Merits: no API change, and every affected deployment is fixed without a code change on their side, which matters given the silent failure mode. Cost: reads client internals. The documented route is unusable: SqlDriverAdapter.getConnectionInfo() returns schemaName, but only the factory is reachable from the client and the factory interface has no schema accessor; calling connect() to ask would force the constructor async.

Options 1 and 3 compose (3 as default, 1 as explicit override). Happy to open a PR for whichever direction you prefer.

Workaround we run today

A Proxy over the client that rewrites raw queries to qualify known table names. Works, but it is a blunt instrument wrapped around library internals.

schema-qualified-client.ts
/**
 * Makes a Prisma client's RAW queries name given tables in a specific schema.
 */

type AnyFn = (...args: unknown[]) => unknown;

/** Prisma's raw entry points on PostgreSQL. */
const UNSAFE_RAW = new Set(["$queryRawUnsafe", "$executeRawUnsafe"]);
const TAGGED_RAW = new Set(["$queryRaw", "$executeRaw"]);

/** The `strings` array of a tagged template. Prisma also accepts a `Prisma.sql`
 *  object there, which this cannot read — hence the throw below. */
function isTemplateStrings(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((s) => typeof s === "string");
}

/** The lookbehind for `.` is what makes the rewrite idempotent. */
function matchersFor(
  tables: readonly string[],
): { re: RegExp; quoted: string }[] {
  return tables.map((table) => ({
    // Escaped for safety even though these are identifiers from our own source.
    re: new RegExp(
      `(?<!\\.)"${table.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`,
      "g",
    ),
    quoted: `"${table}"`,
  }));
}

/**
 * A client whose raw queries name each of `tables` as `"schema"."table"`.
 *
 * Rewrites every raw entry point Prisma has, and the client handed to a
 * `$transaction` callback. Anything else on the client is untouched.
 *
 * - Returns `client` itself when `schema` is undefined or `tables` is empty, so a
 *   deployment on `public` pays nothing.
 * - Idempotent: a name already qualified with any schema is left alone.
 * - Matches the quoted identifier form only, which is what Prisma and every
 *   generator emits. A bare `FROM thing` is not rewritten, deliberately — unquoted
 *   words are ambiguous with aliases, columns and keywords.
 * - Throws if a raw query arrives in a form it cannot rewrite, rather than letting
 *   it run against the wrong schema.
 */
export function withSchemaQualifiedTables<T extends object>(
  client: T,
  tables: readonly string[],
  schema: string | undefined,
): T {
  if (!schema || tables.length === 0) return client;

  const matchers = matchersFor(tables);
  const qualify = (sql: string): string =>
    matchers.reduce(
      (acc, { re, quoted }) => acc.replaceAll(re, `"${schema}".${quoted}`),
      sql,
    );

  const named = tables.map((t) => `"${t}"`).join(", ");

  const wrap = <C extends object>(inner: C): C =>
    new Proxy(inner, {
      get(target, prop, receiver) {
        const value = Reflect.get(target, prop, receiver) as unknown;

        if (typeof prop === "string" && UNSAFE_RAW.has(prop)) {
          const raw = value as AnyFn;
          return (sql: string, ...args: unknown[]) =>
            raw.call(target, qualify(sql), ...args);
        }

        if (typeof prop === "string" && TAGGED_RAW.has(prop)) {
          const raw = value as AnyFn;
          return (query: unknown, ...values: unknown[]) => {
            if (!isTemplateStrings(query)) {
              // A `Prisma.sql` object, or some future shape. Rewriting it blind
              // would be guesswork and letting it past would run the query
              // against the wrong schema, failing confusingly two steps later.
              throw new Error(
                `${prop} was called with a form withSchemaQualifiedTables cannot ` +
                  `qualify, so ${named} would resolve outside "${schema}".`,
              );
            }
            // A table name is a literal, so it always sits within one chunk of the
            // template and never straddles an interpolation.
            return raw.call(target, query.map(qualify), ...values);
          };
        }

        if (prop === "$transaction") {
          const tx = value as AnyFn;
          return (arg: unknown, opts?: unknown) => {
            // The batch form takes already-built promises. A raw one among them was
            // built through this proxy and is therefore already qualified, and model
            // calls carry their own schema — so it passes straight through. Only the
            // callback form hands over a fresh client that needs wrapping.
            if (typeof arg !== "function") return tx.call(target, arg, opts);
            const fn = arg as (client: unknown) => unknown;
            return tx.call(
              target,
              (txClient: unknown) => fn(wrap(txClient as object)),
              opts,
            );
          };
        }

        // Everything else passes through untouched, bound to the real client so
        // Prisma's own methods still see themselves as the receiver.
        return typeof value === "function"
          ? (value as AnyFn).bind(target)
          : value;
      },
    });

  return wrap(client);
}

Used as:

export const queuePrisma: typeof prisma = withSchemaQualifiedTables(
  prisma,
  ["queue_jobs"],
  env.DB_SCHEMA,
);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions