Skip to content

Latest commit

 

History

History
154 lines (117 loc) · 5.58 KB

File metadata and controls

154 lines (117 loc) · 5.58 KB

Typegres Architecture

Typegres exposes SQL semantics to clients through a typed API: clients get the full query language; the underlying schema stays an internal detail. The type system is generated from pg's catalogs; the query API is a thin layer over composable SQL fragments; mutations, transactions, and row materialization all compose through the same abstractions.

Vision

Core tenets:

  1. Expose SQL semantics directly to clients through a typed API — full query power for clients, schema decoupled from the interface they see.
  2. The API is an abstract data type on top of the database: logic, permissions, and state transitions live alongside the data.
  3. Clients query that data type by composing methods.

Example:

class User {
    static forToken(token: string) {
        const user_id = validate(token);
        return User.from().where((u) => u.id.eq(user_id));
    }

    @expose
    id = (Int8<1>).column({ generated: true });

    @expose
    todos() {
        return Todo.from().where((t) => t.user_id.eq(this.id));
    }

    @expose
    name = (Text<1>).column();
}

class Todo {
    @expose([z.string(), z.string()])
    update(name: string, content: string) {
        return super.update(...);
    }
}

Columns expose full Postgres types, including nullability. Every non-side-effecting Postgres function is a method on the corresponding type. A capability-based RPC endpoint exposes a subset of JS with the top-level class (User) as the entry point; @expose methods are chainable and discoverable by clients. The result: data, permissions, and state transitions live in one place, in one language.

Runtime architecture

Driver vs Database

  • Driver is the low-level connection layer (PgDriver, PgliteDriver). It exposes execute(sql), runInSingleConnection(fn), close().
  • Database is the typed query surface on top — what user code interacts with. It holds a Driver and a query/hydrate/transaction API.

Single-class Database, two states

A Database is either pool-backed (every execute routes through the driver's pool) or transaction-bound (carries a single-connection ExecuteFn). Both are instances of the same class. transaction(fn) hands the callback a transaction-bound Database:

await db.transaction(async (tx) => {
    await tx.execute(User.insert(...));
    await User.from().execute(tx); // fluent form
});

There is no AsyncLocalStorage threading ambient context — the tx is passed explicitly. Nested calls flatten because Transaction.transaction(fn) = fn(this), so callees that accept a Database don't have to know whether they're getting the pool or a txn.

Transactions use pg's default isolation. No stricter level is imposed by the framework.

Query builders and terminators

The query API is object-capability shaped: clients can only reach what the BE author exposed, and the builder primitives enforce that at the type level.

  • QueryBuilder, InsertBuilder, UpdateBuilder, DeleteBuilder are immutable. Every method returns a new instance — no mutable state to smuggle references through.
  • where, select, on, etc. are callbacks evaluated against a fresh scope minted by bind(). Aliases are ephemeral to compilation, never stored on classes, so client code can't fabricate references to tables or rows outside the scope it was handed.
  • .execute(db), .hydrate(db), .one(db), .maybeOne(db) are fluent terminators that accept any Database (pool or tx); db.execute(...) / db.hydrate(...) are the non-fluent equivalents.

hydrate materializes rows as class instances — each column field is an Any wrapping a CAST(param) of the value, so methods on the class (relations, derived columns, mutations) compose into follow-up queries without breaking the capability chain.

Type system

All Postgres types are represented as TS classes. Functions are methods on those classes. Nullability is tracked in the N extends number type parameter (0 = null, 1 = non-null, 0 | 1 = maybe null).

Full hierarchy: AnyAnycompatibleAnyelementAnynonarray → concrete types. Generic container types (Anyarray<T>, Anyrange<T>) wire through .of().

Codegen

Types under src/types/generated/ are generated from the pg catalog (pg_type, pg_proc, pg_operator) via pglite introspection:

npm run codegen

The generated files are committed. npm run codegen:check regenerates into a temp dir and diffs against the committed copies — CI runs this to catch drift between the pg version and the checked-in output.

Table codegen is separate: npx tg generate introspects a user's schema and writes typed Table files into their project (uses typegres.config.ts).

Raw SQL

sql is the escape hatch — a tagged template returning an immutable Sql builder. Supports sql.param, sql.raw, sql.ident, sql.join. Fragments compose via template nesting. Compiles to pg ($1) or sqlite (?) style.

Development environment

The nix develop shell is the source of truth for tool versions: nodejs_22, postgresql_17, act for local GHA runs. It also exports DATABASE_URL pointing at the socket provisioned by bin/startpg, so tests and codegen connect without any per-developer setup.

Target users

  1. Traditional app builders: one BE layer, full pg power, minimal boilerplate. FE has the tools to compose queries directly.
  2. "Vibe-coders": entire backend can be a single file; FE is decoupled.
  3. Future shapes:
    • Core system-of-record where clients integrate directly.
    • Agents constructing UIs on demand, rooted in the typed API.