Skip to content

Postgres ​

@rayfold/postgres turns a Postgres table into Rayfold resolvers: batch loads, one SQL query per nesting level, keyset pages, and read policies pushed into the WHERE clause.

sh
npm install @rayfold/postgres pg
ts
import pg from "pg";
import { createPgStore } from "@rayfold/postgres";

const store = createPgStore(new pg.Pool(), {
  ir: server.ir,
  naming: "snake",
  tables: { Book: { table: "books", columns: { authorId: "author_id" } }, Author: { table: "authors" }, Order: { table: "orders" } },
});

const resolvers = {
  Query: {
    books: (args, ctx) => store.page("Book", args.page, {}, ctx),
    myOrders: (_args, ctx) => store.find("Order", {}, ctx),
  },
  Book: { author: (books, _args, ctx) => store.byIds("Author", books.map((b) => b.authorId), ctx) },
  Author: { books: (authors, args, ctx) => store.pagesByField("Book", "authorId", authors.map((a) => a.id), args.page, ctx) },
};

A Date field reaches your resolvers as YYYY-MM-DD text, not a JavaScript Date, whatever the time zone, and an Instant goes out as RFC 3339 in UTC. JdbcStore on the JVM returns both the same way.

One statement for a whole screen ​

The resolvers above send one query per nesting level, the result a GraphQL server gets from DataLoader. store.screen() compiles the shape the op asked for into a single statement. It gathers related rows with correlated JSON subqueries, pushes each level's read policy into its own WHERE, and gives every nested page its own total. Depth costs no extra round trip to the database.

Declare the relations a shape may follow, then hand the store the shape the runtime gives the resolver as ctx.shape:

ts
const store = createPgStore(new pg.Pool(), {
  ir: server.ir,
  naming: "snake",
  tables: {
    Book: { table: "books", columns: { authorId: "author_id" }, relations: { author: { type: "Author", kind: "one", key: "authorId" } } },
    Author: { table: "authors", relations: { books: { type: "Book", kind: "page", key: "authorId" } } },
  },
});

const resolvers = {
  Query: { books: (args, ctx) => store.screen("Book", ctx.shape, args.page, {}, ctx) },
};

kind: "one" names the field on this type holding the other row's key; kind: "page" names the field on the other type holding this row's key. Nested pages are first pages, as a screen shows them. Their size is the one the shape asks for, or else the field's declared default. The root page still takes a cursor. A field the shape selects must be a mapped column or a declared relation, so a shape that reaches past the mapping is refused instead of being served wrong. For the same reason a field selected twice under two aliases must be selected the same way both times.

Each row also carries the columns read by its type's read rule and by the rules of the fields it selects, whether the shape asks for them or not. The runtime checks those rules on every row, so a rule such as @allow(read: this.publishedAt != null) holds on a screen that does not select publishedAt. The runtime leaves those columns out of the answer.

A relation that takes arguments, such as Author.books(page:), needs no loader here: the runtime serves the page screen already gathered. checkWiring cannot see that from the resolvers alone and still reports it as missing-loader. Filter that finding out for the fields your screens gather.

Idempotency records for more than one server ​

A command's result is kept so that a retry is answered with the first attempt's result instead of running the command again. Kept in memory, that only holds for one server: a retry that lands on another one runs the command a second time. PgIdempotencyStore keeps the records in Postgres, so every server shares them.

ts
import { PgIdempotencyStore } from "@rayfold/postgres";

const idempotency = new PgIdempotencyStore(pool);
await idempotency.migrate(); // once, or run idempotencySchema() in your migrations

const server = createRayfoldServer({ schema, resolvers, idempotency });

With this store, a command runs once across servers. A command takes its key with one statement. When two servers start the same retry at the same moment, exactly one runs it; the other waits, then replays its answer. The running server holds a lease and renews it while the command runs. If that server stops, the lease runs out and the next retry takes the key over. Records last 24 hours and the table is bounded; you can change both:

ts
new PgIdempotencyStore(pool, { table: "rayfold_idempotency", ttlMs: 24 * 3600_000, maxRecords: 100_000 });

idempotencySchema() returns the table and index the store needs, so you can put them in your own migrations instead of calling migrate().

The table name is quoted, as JdbcIdempotencyStore quotes it, so a mixed-case name is one table for a fleet of both runtimes. Earlier versions of this store folded such a name to lower case: if you configured one, rename that table or pass the lower-case name.

Live updates across servers ​

A command's patches reach the live queries and streams open on the server that ran it. On another server behind the same load balancer they reach nothing: each server hears only itself, so a screen open there stays stale until it asks again. PgRelay carries changes and events between servers over Postgres LISTEN/NOTIFY, through the database they already share.

ts
import pg from "pg";
import { PgRelay, pgNotifications } from "@rayfold/postgres";

const listener = new pg.Client({ connectionString: process.env.DATABASE_URL }); // LISTEN belongs to one connection: not the pool
await listener.connect();
const relay = new PgRelay(pgNotifications(listener), pool);
await relay.migrate(); // once, or run relaySchema() in your migrations

const server = createRayfoldServer({ schema, resolvers, idempotency, relay });
await server.ready(); // listening to the other servers

A message travels in one notification unless it is large. A large one goes through the rayfold_relay table, and the notification names the row, which is swept later. A relay never hands a server back what that server published, so nothing is applied twice. If the relay refuses a message, the command that made the change still succeeds on its own server; onRelayError and server.relayFailure say what the other servers missed. If the listening connection drops, the server hears nobody from then on. It reports that through onRelayError and stops being ready, so the balancer takes it out and the orchestrator replaces it. It does not go on serving live queries that no longer update. server.close() stops listening, for a shutdown.

Why pass ctx ​

When a resolver loads an entity whose read policy can run in SQL (it reads only the viewer, the arguments, literals and the row's own fields), the runtime hands the policy to the resolver as ctx.policy.filter (spec 06 §4). The store turns it into part of the query: @allow(read: viewer.id == customerId) becomes customer_id = $viewer. A customer's list then holds exactly their rows and a page's total counts only what they may see.

Without it, the runtime still checks every row, but a list that contains a row the viewer may not see is refused as a whole (spec 06 §3), and the database reads rows nobody can use.

The translation never drops a row the policy allows. Where SQL cannot match the runtime exactly (ordering text, comparisons across types, Decimal inequality) it lets the row through and the runtime's check removes it. A property test checks this by running every translation against a real Postgres for many policies, viewers and rows.

The package README lists every call and the SQL it sends.

On the JVM the hint reaches resolvers the same way, as ctx.policy: the pushable part of the type's read policy, or null when nothing can be pushed. The rule for what is pushable is the same in both runtimes, and both are held to it by tests. dev.rayfold:rayfold-jdbc is the JVM counterpart of this package and uses that hint the same way.

Released under the Apache-2.0 license.