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.
| Language | Package | Folder | |
|---|---|---|---|
| JavaScript / TypeScript (Node 18+, browsers with a token) | @propus/client | sdks/js | |
| Python 3.9+ | propus | sdks/python | |
| Java 17+ | io.propus:propus-client | sdks/java | |
| C# / .NET 8+ | Propus.Client | sdks/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
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 · nackdb.streams.read(stream, from) · lag(stream, group) |
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) |
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
| Prisma | AutoGraph |
|---|---|
prisma db push | await db.sync(tables); adds only, { drop: true } also drops what you removed |
| preview | await db.plan(tables) returns the SQL without running it |
prisma generate / db pull | propus-generate --lang typescript --out src/schema.ts, or db.introspect() / db.generate(lang) |
prisma migrate dev | propus-generate --migration 0002_add_phone --schema ./schema.js --out migrations/0002_add_phone.sql |
| rename prompt | asked on a terminal, or --rename customers.phone=mobile / --rename clients=customers / --no-rename |
prisma migrate deploy | await 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!" } } } });
one | many | manyThrough | |
|---|---|---|---|
| insert | create, connect, connectOrCreate (parent written first, key filled in) | create, connect, connectOrCreate | the same, join rows inserted |
| update | also update, upsert, disconnect: true, delete: true | also set, disconnect, update, delete, upsert | the 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 adds | Prisma has, AutoGraph doesn't yet |
|---|---|
| Row-level security policies and grants in the table definition | Client extensions ($extends): use middleware |
| Search indexes in the table definition | Databases other than Propus |
Migrations with down, rolled back | Years 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.