SDKs and AutoGraph

Official clients for four languages, with no runtime dependencies. Each takes the database URL or reads PROPUS_URL, and includes AutoGraph and clients for the cache, streams, change feed, objects and search.

LanguagePackageFolder
JavaScript / TypeScript (Node 18+, browsers with a token)@propus/clientsdks/js
Python 3.9+propussdks/python
Java 17+io.propus:propus-clientsdks/java
C# / .NET 8+Propus.Clientsdks/dotnet

Until the packages are published, install from the folder: npm install ./sdks/js, pip install ./sdks/python, mvn -f sdks/java install, or a project reference to sdks/dotnet.

Queries

import { Propus } from "@propus/client";
const db = new Propus(process.env.PROPUS_URL);
const { rows } = await db.query("SELECT id, body FROM notes WHERE id = $1", [1]);
const tx = await db.begin(); await tx.query("UPDATE notes SET body = $1 WHERE id = 1", ["hi"]); await tx.commit();
from propus import Client
db = Client()                                   # PROPUS_URL
rows = db.query("SELECT id, body FROM notes WHERE id = $1", [1]).rows
PropusClient db = PropusClient.connect();       // PROPUS_URL
List<Map<String, Object>> rows = db.query("SELECT id, body FROM notes WHERE id = $1", 1).rows();
using var db = new PropusClient(Environment.GetEnvironmentVariable("PROPUS_URL")!);
var rows = (await db.QueryAsync("SELECT id, body FROM notes WHERE id = $1", 1)).Rows;

Errors carry the SQLSTATE code and HTTP status.

Acting for a user

An admin connection can act as a role, tenant and user; row-level security applies exactly as for that caller:

const acme = db.as({ role: "app", tenant: "acme", userId: "u_42" });
await acme.query("SELECT * FROM invoices");     // only acme's rows

For browsers and phones, issue a short-lived token on your server (db.tokens) and hand that out, never the key.

Engines

Cache Streams
db.cache.set(key, value, ttlSeconds)
db.cache.get(key) · delete(key)
db.cache.command("HSET", "h", "f", "v")
db.streams.publish(stream, ...messages)
db.streams.next(stream, group) · ack · nack
db.streams.read(stream, from) · lag(stream, group)
Objects Search
db.objects.createBucket(name, { public })
db.objects.put(bucket, key, body) · get · list
db.search.index(table, fields, { tenantColumn })
db.search.query("blue shoes") · status() · rebuild(table)
Change feed
db.changes.read(since)
db.changes.subscribe(since, onChange)

Python uses the same names in snake_case (create_bucket, ttl_seconds). Any Redis, Kafka, S3 or Postgres client also works with its own URL.

AutoGraph

Tables in code; Propus makes the database match.

import { Propus, table, relations, serial, int, text, numeric } from "@propus/client";

export const customers = table("customers", {
  id: serial().primaryKey(),
  email: text().notNull().unique(),
  tenantId: text().notNull(),
}, {
  policies: { own_tenant: `"tenantId" = current_setting('propus.tenant')` },
  grants: ["app"],
  search: { columns: ["email"], tenantColumn: "tenantId" },
});
export const orders = table("orders", {
  id: serial().primaryKey(),
  customerId: int().notNull().references(customers, "id", "CASCADE"),
  total: numeric(12, 2).notNull(),
});
relations(customers, (r) => ({ orders: r.many(orders, "customerId") }));

const db = new Propus(process.env.PROPUS_URL);
await db.sync([customers, orders]);
const c = await db.insert(customers, { email: "asha@example.com", tenantId: "acme" });
const big = await db.select(customers).where({ id: c.id }).with({ orders: { orderBy: [["total", "desc"]] } }).first();
from propus import Client
from propus.orm import Table, serial, text
notes = Table("notes", id=serial().primary_key(), body=text().not_null())
db = Client()
db.sync([notes])
db.insert(notes, {"body": "hello"})

Schema changes, like Prisma

PrismaAutoGraph
prisma db pushawait db.sync(tables); adds only, { drop: true } also drops what you removed
previewawait db.plan(tables) returns the SQL without running it
prisma generate / db pullpropus-generate --lang typescript --out src/schema.ts, or db.introspect() / db.generate(lang)
prisma migrate devpropus-generate --migration 0002_add_phone --schema ./schema.js --out migrations/0002_add_phone.sql
rename promptasked on a terminal, or --rename customers.phone=mobile / --rename clients=customers / --no-rename
prisma migrate deployawait db.migrate(migrations): each once, in its own transaction, recorded in _propus_migrations
–await db.rollbackMigration(migrations) runs the newest one's down

Read SQL migration files with parseMigrationSql(name, text).

Renames

A column that disappears while a new one of the same SQL type appears (or a table replaced by one with the same columns) is listed in plan.renames. Decide it and the data is kept:

const plan = await db.plan(tables);   // plan.renames: [{ table: "customers", from: "phone", to: "mobile", kind: "column" }]
await db.sync(tables, { drop: true, renames: { "customers.phone": "mobile", clients: "customers" } });
// ALTER TABLE "customers" RENAME COLUMN "phone" TO "mobile"; ALTER TABLE "clients" RENAME TO "customers"
await db.sync(tables, { drop: true, renames: { "customers.fax": false } });   // not a rename: drop + add

With drop: true, an undecided candidate refuses instead of dropping data. generateMigration(name, tables, live, { renames }) writes the RENAME in up and its reverse in down. Python: renames={"customers.phone": "mobile"} (None for not a rename); Java: Map<String, ?>; C#: IReadOnlyDictionary<string, string?>.

Nested writes

Under a relation's name, in one transaction as the client's identity (a scoped client's row-level security applies to every row); any failure rolls back everything. Returns the parent row as stored.

await acme.insert(authors, {
  name: "Asha",
  posts: { create: [{ title: "One", tags: { connectOrCreate: [{ where: { label: "tech" }, create: { label: "tech" } }] } }] },
});
await acme.insert(posts, { title: "Two", author: { connect: { name: "Asha" } } });
await acme.update(authors, { id }, { posts: { set: [{ title: "One" }], upsert: { where: { title: "Three" }, create: { title: "Three" }, update: { title: "Three!" } } } });
onemanymanyThrough
insertcreate, connect, connectOrCreate (parent written first, key filled in)create, connect, connectOrCreatethe same, join rows inserted
updatealso update, upsert, disconnect: true, delete: truealso set, disconnect, update, delete, upsertthe same as many, on the join rows

Python uses connect_or_create; Java and C# take the same keys in a Map / dictionary.

Middleware

db.use((params, next) => …) wraps every AutoGraph call: params = { table, action, args }, action one of insert, insertMany, upsert, update, increment, delete, deleteAll, all, first, count, exists, pluck, aggregate. They run in registration order, each around the rest; one may change args, change the result, or return without calling next. as(), database() and transactions keep them.

db.use(async (params, next) => {                       // soft delete
  if (!("deletedAt" in params.table.columns)) return next(params);
  if (params.action === "delete")
    return (await next({ ...params, action: "update", args: { where: params.args.where, data: { deletedAt: new Date().toISOString() } } })).length;
  if (["all", "first", "count", "exists", "pluck", "aggregate"].includes(params.action))
    params.args.where = { ...params.args.where, deletedAt: null };
  return next(params);
});

Python db.use(fn) (actions insert_many, delete_all), Java db.use((p, next) -> …) (Middleware.SELECTS), C# db.Use(async (p, next) => …) (MiddlewareParams.Selects, p with { Action = "update" }). A nested write is one call: its inner rows don't pass through the middlewares again.

Compared with Prisma

AutoGraph addsPrisma has, AutoGraph doesn't yet
Row-level security policies and grants in the table definitionClient extensions ($extends): use middleware
Search indexes in the table definitionDatabases other than Propus
Migrations with down, rolled backYears of production use and tooling (Studio, editor plugins)
One design in TypeScript, Python, Java and C#
TypeScript types without a generate step
Nested writes and middleware under row-level security

The full matrix, with tests behind each row, is in parity.md.