zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Databases

HTTP ProxySupported

Because a Driver has one required method over a CompiledQuery containing text, parameters and execution effects, you can put anything between your application and the database — including your own HTTP endpoint. This is how you reach a database from a runtime with no TCP.

The client driver#

import { type Driver } from '@zmdb/orm';
import type { SqlDialect } from '@zmdb/sql';

export function httpDriver(url: string, token: string, dialect: SqlDialect): Driver {
  return {
    dialect,
    async execute(query) {
      const res = await fetch(url, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
        body: JSON.stringify({ effects: query.effects, text: query.text, parameters: query.parameters }),
      });
      if (!res.ok) throw new Error(`proxy ${res.status}: ${await res.text()}`);
      return (await res.json()) as Record<string, unknown>[];
    },
  };
}

Runs anywhere fetch exists — a browser, a Worker, an edge function, React Native.

The server#

A @zmdb/web controller over your real driver:

import type { CompiledQuery } from '@zmdb/sql';

@Controller('/sql')
export class SqlProxyController {
  @Inject(DRIVER) private readonly driver!: Driver;

  @Post('/')
  async execute(ctx: Ctx<Record<never, string>, unknown>) {
    const body = assert<CompiledQuery>(ctx.body);
    return this.driver.execute(body);
  }
}

Read this before you deploy it#

That endpoint executes arbitrary SQL as your database user. A proxy accepting text from a client is a remote SQL console. If it is reachable from a browser, so is your entire database — a DROP TABLE, a SELECT over another tenant's rows, a pg_read_file. Authentication does not fix it, because an authenticated user is exactly who would abuse it.

Only deploy the shape above server to server, inside a trust boundary, with a token that is not shipped to any client.

The safe version: named queries#

For anything a client can reach, do not accept SQL. Accept a name and typed arguments, and compile server-side:

import { trustedTable } from '@zmdb/sql';

import { postgres } from '@zmdb/postgres';

const QUERIES = {
  activeUsers: (args: { limit: number }) => createQueryCompiler(postgres).selectFrom(trustedTable('users')).where('active', '=', true).limit(args.limit).compile(),

  userById: (args: { id: number }) => createQueryCompiler(postgres).selectFrom(trustedTable('users')).where('id', '=', args.id).compile(),
} as const;

@Controller('/query')
export class QueryController {
  @Inject(DRIVER) private readonly driver!: Driver;

  @Post('/:name')
  async run(ctx: Ctx<{ name: string }, unknown>) {
    const fn = QUERIES[ctx.params.name as keyof typeof QUERIES];
    if (fn === undefined) throw new ValidationError(`no query named ${ctx.params.name}`, []);
    const args = assert<{ limit?: number; id?: number }>(ctx.body);
    return this.driver.execute(fn(args as never));
  }
}

Now the client chooses from a fixed set, the arguments are validated, and the parameters are bound. The blast radius is the queries you wrote.

Better still: skip the generic layer and write ordinary endpoints. @Controller('/users') with @Get('/') is the same amount of code and produces an OpenAPI document — a "query proxy" is usually a REST API with the types removed.

What it costs#

A round trip per statement. populate is two. A loop of findById is a loop of HTTP calls. The batching guidance applies with more force.

No transactions, unless you add a session concept — which means server-side state keyed by a token, and a leak if a client never commits.

JSON type erosion. Date becomes a string, bigint will not serialize at all. Handle it in the proxy and validate on the client:

const rows = (await res.json()).map(r => assert<Entity<User>>(revive(r)));

Where it is genuinely the right answer#

A trusted internal service consolidating database access; a local-first client syncing through your API; a runtime with no TCP that must reach Postgres. In all three the proxy is server-controlled — which is the property that makes it acceptable.

---

See also: Writing a Driver · React Native · Security