Athena

Getting Started

Install Athena.js, create a client, run reads and writes, and move into typed registry workflows when the schema stabilizes.

This is the shortest path from a raw Athena.js client to the typed registry and generator flow.

The practical order is:

  1. create a client
  2. run reads, writes, and RPC
  3. handle AthenaResult correctly
  4. move into fromModel(...) and generated contracts when string-table calls stop scaling

Prerequisites

  • Node.js 18+
  • An Athena gateway URL and API key

1) Install

npm install @xylex-group/athena
# or
pnpm add @xylex-group/athena

React support is optional and lives under @xylex-group/athena/react.

npm install react

2) Create a client

Use createClient(...) when you want the smallest setup surface:

import { createClient } from "@xylex-group/athena";

const athena = createClient(process.env.ATHENA_URL!, process.env.ATHENA_API_KEY!, {
  client: "web-dashboard",
  backend: { type: "athena" },
});

Use the builder when you want explicit configuration, auth bindings, or staged options:

import { AthenaClient, Backend } from "@xylex-group/athena";

const athena = AthenaClient.builder()
  .url(process.env.ATHENA_URL!)
  .key(process.env.ATHENA_API_KEY!)
  .options({
    client: "web-dashboard",
    backend: Backend.Athena,
    headers: { "X-App-Region": "eu" },
    auth: { baseUrl: process.env.ATHENA_AUTH_URL! },
  })
  .experimental({ traceQueries: true })
  .build();

AthenaClient.fromEnvironment() loads from:

  • ATHENA_URL or ATHENA_GATEWAY_URL
  • ATHENA_API_KEY or ATHENA_GATEWAY_API_KEY

If you mix fluent calls with .options(...), Athena.js merges the staged headers, auth, and experimental config rather than discarding what you already set.

3) Optional runtime features

Query tracing

Enable this when you need the exact payload, synthesized SQL, result, and best-effort callsite for every operation.

const traced = createClient(process.env.ATHENA_URL!, process.env.ATHENA_API_KEY!, {
  experimental: {
    traceQueries: true,
  },
});

You can also pass a custom logger:

const traced = createClient(process.env.ATHENA_URL!, process.env.ATHENA_API_KEY!, {
  experimental: {
    traceQueries: {
      logger(event) {
        observability.emit("athena.query.trace", event);
      },
    },
  },
});

findMany(...) transport

findMany(...) normally compiles its object-tree input into the existing gateway transport. On the current Athena server contract, that compiled path is the supported public read surface.

Top-level direct AST request bodies are not part of the current /gateway/fetch contract and should not be treated as the default integration path for Athena 2.4.0 compatibility work.

Utility subpath exports

Use @xylex-group/athena/utils for small operational helpers that are not part of the root export surface:

import {
  clearAuthCookies,
  isLocalHostname,
  parseBooleanFlag,
  proxyRequestHeaders,
  slugify,
  trimTrailingSlashes,
} from "@xylex-group/athena/utils";

const slug = slugify("Internal User Sessions");
const baseUrl = trimTrailingSlashes("https://api.example.com///");
const shouldEnablePreview = parseBooleanFlag(process.env.ENABLE_PREVIEW, false);
const isLocal = isLocalHostname("api.localhost");
const forwardedHeaders = proxyRequestHeaders(request);

4) Read data

Athena.js has two main read styles:

  • .select(...) for column-string chains
  • .findMany(...) for eager object-tree reads

Use findMany(...) when you want relation structure to stay explicit:

type UserRow = {
  id: string;
  email: string;
  active: boolean;
  created_at: string;
};

const users = await athena.from<UserRow>("users").findMany({
  select: {
    id: true,
    email: true,
  },
  where: {
    active: true,
  },
  orderBy: {
    created_at: "desc",
  },
  limit: 25,
});

Use .select(...) when a column string is enough:

const list = await athena
  .from<UserRow>("users")
  .select("user_id:id, user_email:email, createdAt:created_at")
  .eq("active", true)
  .limit(25);

Common read modifiers:

  • .select()
  • .findMany()
  • .eq, .neq, .gt, .gte, .lt, .lte
  • .like, .ilike, .is, .in
  • .contains, .containedBy, .range, .offset, .currentPage, .pageSize
  • .order
  • .or
  • .single, .maybeSingle

Column strings accept response aliases in the form customName:columnName.

If you need relation examples or transport details, continue to findMany AST and server contract. If you only need alias behavior, continue to Select column aliases.

5) Mutations, RPC, and raw SQL

Insert

await athena.from<{ id: string; email: string }>("users").insert({ email: "a@b.com" }).select("id, email");
await athena.from<{ id: string; email: string }>("users").insert([{ email: "a@b.com" }]).select("id, email");

Update

await athena
  .from<{ id: string; email: string }>("users")
  .eq("id", "u-123")
  .update({ email: "new@b.com" })
  .select("id, email");

Upsert

await athena
  .from<{ id: string; email: string }>("users")
  .upsert(
    { id: "u-123", email: "a@b.com" },
    {
      onConflict: "id",
      updateBody: { email: "a@b.com" },
    },
  )
  .select("id, email");

Delete guardrail

Delete requires one of these before the request is allowed to execute:

  • eq("id", ...)
  • eq("resource_id", ...)
  • delete({ resourceId })

If none is present, Athena.js throws before making a network call.

RPC

const rpcResult = await athena
  .rpc<{ count: number }, { active_only: boolean }>("list_users", { active_only: true })
  .single("count");

Raw SQL

const rows = await athena.query<{ id: string; email: string }>(
  "select id, email from users where active = true",
);

6) Handle results correctly

Most awaitable Athena.js operations resolve to AthenaResult<T>.

  • data
  • error
  • status
  • statusText
  • count
  • raw
  • errorDetails as a compatibility alias for gateway error metadata

error is a structured object, not a plain string. The field you normally surface is error.message.

import { isOk, requireAffected, unwrapOne, unwrapRows } from "@xylex-group/athena";

const list = await athena.from<{ id: string }>("users").select("id");
if (!isOk(list)) {
  throw new Error(list.error?.message ?? "Unknown Athena error");
}

const rows = unwrapRows(list);

const single = await athena.from<{ id: string }>("users").eq("id", "u-1").single("id");
const user = unwrapOne(single, { allowNull: true });

const inserted = await athena.from("users").insert({ email: "a@b.com" }).select("id");
requireAffected(inserted, { min: 1 });

7) Move to the typed registry when strings stop scaling

If the same tables, payloads, and tenant headers keep repeating, define a registry once and switch to fromModel(...).

import {
  createTypedClient,
  defineDatabase,
  defineModel,
  defineRegistry,
  defineSchema,
} from "@xylex-group/athena";

const users = defineModel<
  { id: string; email: string; created_at: string | null },
  { email: string },
  { email?: string }
>({
  meta: {
    primaryKey: ["id"],
    nullable: { id: false, email: false, created_at: true },
  },
});

const registry = defineRegistry({
  app: defineDatabase({
    public: defineSchema({ users }),
  }),
});

const typed = createTypedClient(registry, process.env.ATHENA_URL!, process.env.ATHENA_API_KEY!, {
  tenantKeyMap: {
    organizationId: "X-Organization-Id",
  },
});

await typed
  .withTenantContext({ organizationId: "org-1" })
  .fromModel("app", "public", "users")
  .findMany({
    select: {
      id: true,
      email: true,
    },
  });

This gives you:

  • typed row, insert, and update contracts
  • stronger filter key inference
  • tenant-header propagation from one client surface
  • model-driven relation inference on findMany(...)

8) Generate contracts from PostgreSQL

Use generation when the schema changes too often to maintain the registry by hand.

athena-js generate
athena-js generate --dry-run
athena-js generate --config ./athena.config.ts

Minimal direct-mode config:

import { defineGeneratorConfig } from "@xylex-group/athena";

export default defineGeneratorConfig({
  provider: {
    kind: "postgres",
    mode: "direct",
    connectionString: process.env.DATABASE_URL!,
    database: "app_db",
    schemas: ["public", "athena"],
  },
  output: {
    targets: {
      model: "athena/models/{schema_kebab}/{model_kebab}.ts",
      schema: "athena/schemas/{schema_kebab}.ts",
      database: "athena/relations.ts",
      registry: "athena/config.ts",
    },
  },
});

Use gateway mode when CI or remote runners cannot open a direct database connection:

provider: {
  kind: "postgres",
  mode: "gateway",
  gatewayUrl: process.env.ATHENA_URL!,
  apiKey: process.env.ATHENA_API_KEY!,
  database: "app_db",
  schemas: ["public", "athena"],
}

9) Where to go next