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:
- create a client
- run reads, writes, and RPC
- handle
AthenaResultcorrectly - 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/athenaReact support is optional and lives under @xylex-group/athena/react.
npm install react2) 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_URLorATHENA_GATEWAY_URLATHENA_API_KEYorATHENA_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>.
dataerrorstatusstatusTextcountrawerrorDetailsas 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.tsMinimal 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"],
}