Typed Schema Registry
Define model contracts, build a typed registry, and use fromModel with createTypedClient.
The typed layer is the SDK's schema contract system. It keeps model metadata in TypeScript, ensures runtime calls line up with table names, and lets the generator emit those same contracts for you.
Why it exists
Use registry models when two things start to become expensive:
- duplicated row types across call sites
- schema changes causing drift between runtime strings and typed assumptions
The model system reduces both by introducing a source-of-truth object graph:
registry -> database -> schema -> model -> metadata/metadata types.
createClient(...).from<Table>("table") remains valid and fully supported. The typed path is additive.
1) Core contracts
defineModel, defineSchema, defineDatabase, and defineRegistry are lightweight identity builders with explicit type signatures.
import {
defineModel,
defineSchema,
defineDatabase,
defineRegistry,
createTypedClient,
} from "@xylex-group/athena";
const users = defineModel<
{ id: string; email: string; createdAt: string | null },
{ id?: string; email: string },
{ email?: string }
>({
meta: {
primaryKey: ["id"],
nullable: { id: false, email: false, createdAt: true },
},
});
const primarySchema = defineSchema({ users });
const primaryDb = defineDatabase({ public: primarySchema });
const registry = defineRegistry({ app: primaryDb });
const client = createTypedClient(registry, process.env.ATHENA_URL!, process.env.ATHENA_API_KEY!);2) Model metadata contract
meta is where runtime behavior and typed metadata are stored.
primaryKey: string[](required)database,schema,model: logical naming hintstableName: explicit SQL table target; overridesschema.modelnullable: map used to shape insert/update inference and nullabilityrelations: optional relation graph metadata emitted by generator
tableName resolution order
fromModel(database, schema, model) resolves to:
meta.tableNamewhen provided${meta.schema}.${meta.model}when missing (defaults from registry path)
This means you can keep model names stable even when DB objects are renamed or cross-namespace.
3) Client behavior and type coupling
createTypedClient(registry, url, apiKey, options?) returns an AthenaSdkClient with registry helpers:
.registry.tenantKeyMap.tenantContext.withTenantContext(context).fromModel(database, schema, model)
fromModel() uses registry lookup and then delegates to the same runtime query builder
so chain methods behave exactly like from().
const typed = createTypedClient(registry, "https://athena-db.com", "secret", {
tenantKeyMap: {
organizationId: "X-Organization-Id",
workspaceId: "X-Workspace-Id",
},
});
await typed
.withTenantContext({ organizationId: "org-1" })
.fromModel("app", "public", "users")
.select("id, email")
.eq("active", true);Tenant context behavior
- returns a new client
- merges new keys into existing context
- maps keys to headers using
tenantKeyMap - drops
null/undefinedvalues instead of serializing them
const scoped = typed.withTenantContext({ organizationId: "org-1" });
const scopedAgain = scoped.withTenantContext({ workspaceId: "ws-2" });
// scopedAgain sends both tenant headers4) Types generated from model metadata
defineModel<Row, Insert, Update>(...) influences type extraction:
Rowdrives read results (selectpayloads)Insertdefaults toPartial<Row>Updatedefaults toPartial<Insert>
The helper types available at runtime:
RowOf<Model>InsertOf<Model>UpdateOf<Model>ModelAt<Registry, DB, Schema, Model>
If you need explicit override typing for insert/update payloads while still sharing fields, pass the generics directly.
5) Relation metadata
Generated models can include relation metadata for tooling that reads it.
type Kind = "one-to-one" | "many-to-one" | "one-to-many" | "many-to-many";
{
kind: Kind;
sourceColumns: string[];
targetSchema: string;
targetModel: string;
targetColumns: string[];
targetDatabase?: string;
through?: {
schema: string;
model: string;
sourceColumns: string[];
targetColumns: string[];
};
}The SDK preserves this metadata and forwards it in generated model files. It does not perform automatic join expansion in current query builders.
6) Error behavior and guardrails
fromModel throws early when registry paths are invalid:
- missing database ->
Unknown database "..." - missing schema ->
Unknown schema "..." in database "..." - missing model ->
Unknown model "..." in schema "..."
You can rely on constructor-time errors before making HTTP calls.
7) Generator interoperability
The generator outputs directly in the same shape:
- model files:
defineModel<...>with row/insert/update types + metadata - schema files:
defineSchema({ ... }) - database files:
defineDatabase({ ... }) - optional registry file:
defineRegistry({ ... })
That means generated registry artifacts can be imported as-is with createTypedClient(...).
File template defaults
By default, generator target templates are:
src/generated/{database_kebab}/{schema_kebab}/{model_kebab}.model.tssrc/generated/{database_kebab}/{schema_kebab}/index.tssrc/generated/{database_kebab}/index.tssrc/generated/index.ts
These can be changed via output.targets.
8) Migration strategy: untyped -> model-first
A practical rollout sequence for existing code:
- Keep existing
from("table")call sites untouched for now. - Add
defineModeldeclarations per bounded domain. - Build a local registry from manual contracts.
- Move call sites to
fromModel(...)only where stability gains are high. - Replace manual contracts with generated output once generator config and checks are stable.
9) Configuration tips for stable generation
- Keep naming conventions explicit in config (
modelType,modelConst, etc.) - Use
emitRelationsonly when relation metadata consumers are ready - Use
emitRegistrywhen your app importsregistryas the primary source-of-truth; disable in transitional branches if needed - Run
athena-js generate --dry-runin CI to validate output deterministically before writing files
10) Common pitfalls
- Manual
defineModeland generated model definitions with the same logical key but different metadata - Using raw DB table names in
fromModelcalls; prefer logical model names and settableNameonly for legacy mapping - Assuming relation metadata automatically rewires queries (it is metadata only)
- Skipping tenant header mapping and setting tenant headers manually in each call
11) Next
For concrete CLI/config examples, flags, and provider behavior, continue to: