Skip to content

Repository files navigation

@askrjs/orm

Postgres-first, SQL-shaped data access for Askr.

@askrjs/orm sits between a micro-ORM and a generated data layer: database definitions are TypeScript, generated artifacts are committed, CRUD is status-first, and read queries retain an explicit SQL shape. It does not track objects, infer relation graphs, or perform nested writes.

The supported runtime is Node 20.19+ or Node 22.12+. PostgreSQL 16, 17, and 18 are the v1 compatibility targets.

Define a database

// database/users.ts
import { table, text, timestampTz, uuid } from "@askrjs/orm";
import { groups } from "./groups";

export const users = table("users", {
  id: uuid().primaryKey().defaultRandom(),
  email: text().notNull().unique(),
  groupId: uuid().notNull().references(() => groups.id),
  createdAt: timestampTz().notNull().defaultNow(),
});

Properties map from camel case to snake case. Use .name("legacy_name") for an explicit SQL identifier. Column codecs preserve application-specific value types while controlling database encoding and decoding.

The root exports lazy factories. Importing definitions never opens a connection:

// database/index.ts
import { database } from "@askrjs/orm";
import { migrationManifest } from "./generated";
import { groups } from "./groups";
import { users } from "./users";
import { openScratch, openTarget } from "./postgres-adapter";

export default database({
  tables: { groups, users },
  manifest: migrationManifest,
  targetIdentity: "app-production",
  scratchIdentity: "app-orm-scratch",
  target: openTarget,
  scratch: openScratch,
});

The application-owned adapter implements DatabaseAdapter; generation uses the separate DatabaseToolingAdapter. This keeps the runtime contract driver-neutral and makes destructive scratch reset an explicit integration boundary.

Generate and validate

Install @askrjs/orm in the project, then use the unified Askr CLI:

askr database generate
askr database validate
askr database migration plan
askr database migration apply --yes

Generation resets only the named scratch database, replays committed migrations, describes keyed SQL, writes a forward migration when needed, and regenerates database/generated/. It never applies to the target.

Validation performs the same scratch replay and then compares both the introspected schema and every generated byte. askr check discovers database/index.ts and runs this validation automatically.

See migration safety and the adapter contract. The structural runtime overhead gate is documented in performance.

CRUD and batches

const db = await database.open("target");

await db.users.get(userId); // User | null
await db.users.insert({ email, groupId }); // { rowsAffected }
await db.users.update(userId, { email }); // { rowsAffected }
await db.users.delete(userId); // { rowsAffected }

const user = await db.users.insert(
  { email, groupId },
  { returning: "row" },
);

await db.users.insertMany(rows, { chunkSize: 500 });
await db.users.upsertMany(rows, { chunkSize: 500 });

const [inserted, removed] = await db.batch([
  (current) => current.users.insert({ email, groupId }),
  (current) => current.users.delete(oldUserId),
] as const);

Bulk operations and db.batch do not create transactions. Wrap them in db.transaction(...) when atomicity is required.

SQL-shaped reads

import { eq } from "@askrjs/orm";

const query = db.users
  .leftJoin(db.groups)
  .on(({ users, groups }) => eq(users.groupId, groups.id))
  .select(({ users, groups }) => ({
    userId: users.id,
    email: users.email,
    groupName: groups.name,
  }))
  .where(({ users }) => eq(users.email, email))
  .orderBy(({ users }) => users.email);

const rows = await query.execute({ signal });
const prepared = query.prepare("users-with-groups");
const sql = query.toSQL();

Joins require an explicit on and projection. Self-joins require as. Outer-join references carry nullable value types. Values are parameters; identifiers must come from generated descriptors or sql.identifier. sql.unsafe is the explicit arbitrary-SQL boundary.

Keyed SQL

Static SQL can live anywhere in project source:

export const userByEmail = sql.key(
  "users.by-email",
  { email: "" as string },
)`
  SELECT id, email
  FROM users
  WHERE email = :email
`;

Keys must be stable and unique. Generation scans the static template, asks the scratch adapter to describe it, and emits the parameter/result registry in database/generated/queries.ts. Runtime execution uses the key as the prepared statement name:

const rows = await executeKeyedSql(adapter, userByEmail, { email });

Non-goals

V1 has no identity map, change tracking, lazy loading, relation includes, nested writes, rollback migrations, startup auto-migration, seeds, materialized views, extensions, triggers, functions, RLS, partitions, or generated runtime validation schemas. Existing databases are not adopted by introspection.

About

No description, website, or topics provided.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages