The Disc TypeScript SDK provides a typed HTTP client for querying a Disc server, managing authentication, executing transactions, and subscribing to real-time updates over WebSocket.
Import from the SDK module directly:
import {
AuthManager,
createClient,
createSubscriptionClient,
DiscClient
} from "disc/sdk/mod.ts";
If you have generated a typed client with disc codegen, you can import the generated types alongside the SDK:
import { createClient } from "disc/sdk/mod.ts";
import type { Post, User } from "./dbschema/disc-client/interfaces.ts";
interfaces.tsis emitted only for multi-module schemas; a single-module schema emitstypes.tsinstead, so import from./dbschema/disc-client/types.tsin that case.
Use createClient() to construct a DiscClient instance. All options are optional and have sensible defaults.
import { createClient } from "disc/sdk/mod.ts";
const client = createClient({
baseUrl: "http://localhost:5656",
headers: { "X-Custom-Header": "value" },
retries: 3,
retryDelay: 1000,
timeout: 30000
});
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
DISC_SERVER_URL env, else disc.toml, else "http://localhost:5656" |
URL of the Disc server |
headers |
Record<string, string> |
{} |
Custom headers included with every request |
retries |
number |
0 |
Number of retries on network or server errors |
retryDelay |
number |
1000 |
Base delay between retries in milliseconds (linear backoff) |
timeout |
number |
30000 |
Request timeout in milliseconds |
The DiscClient class can also be instantiated directly if you prefer:
import { DiscClient } from "disc/sdk/mod.ts";
const client = new DiscClient({ baseUrl: "https://disc.example.com" });
When baseUrl is omitted, the client resolves the server URL automatically in this order:
config.baseUrl — an explicit value always wins.DISC_SERVER_URL env var — works on any runtime (Deno, Node, Bun). The recommended option for deployed servers, where the working directory and filesystem permissions are unpredictable.disc.toml — on Deno, the client walks up from the current working directory for a disc.toml (the same lookup the CLI uses) and derives http://<host>:<port> from its [server] section.http://localhost:5656 — the final fallback.So a codegen client running inside your project connects on the configured port with no options:
import { DiscClient } from "./dbschema/disc-client/index.ts";
const client = new DiscClient(); // baseUrl from DISC_SERVER_URL, else disc.toml
# Or pin it explicitly for a deployed GraphQL/API server:
DISC_SERVER_URL=http://db.internal:5656 deno run --allow-net --allow-env server.ts
disc.toml resolution is best-effort and Deno-only: a Node or Bun process (e.g. an Apollo/Yoga server) skips step 3 entirely, so set DISC_SERVER_URL there. Within Deno it also needs --allow-read for the project directory and --allow-env to read the variable. If a disc.toml is found but can’t be read (permission denied), the client emits a logger.warn (when a logger is configured) instead of silently falling back — pass { logger: console } while debugging:
const client = new DiscClient({ logger: console });
// → warns: "DiscClient: could not read …/disc.toml; falling back to http://localhost:5656…"
client.query<T>(query, variables?, options?)Executes an EdgeQL query and returns the result data directly. Throws DiscQueryError (or one of its typed subclasses) if the server returns query errors, and every other non-OK response is an error too: DiscAuthError for 401/403, DiscServerError for 5xx, and DiscProtocolError (with statusCode and the server’s message) for any other 4xx such as 413 body-too-large or 429. query() never resolves undefined on a failed request.
The optional options argument accepts { revive, validate }: revive auto-converts wire-encoded scalars (e.g. ISO date strings into Date), and validate runs a validator against the result, throwing DiscValidationError if it rejects.
bigint variables (the type codegen assigns to int64 and bigint fields) are supported directly — the client encodes them as numeric strings on the wire, so { count: 0n } works where plain JSON.stringify would throw "Do not know how to serialize a BigInt". Inbound, int64, bigint and decimal values are exact JSON numbers; client.query() reads one a double can’t hold exactly as a string of its digits, and the generated builders return int64/bigint fields as bigint and datetime fields as Date (see Codegen → Property Type Mappings). Uint8Array variables are sent as base64 automatically, at any depth — see Bytes below.
Variables are bound by name, so their key order is irrelevant; a missing or extra variable is a 400 VALIDATION_ERROR naming it.
// Select all users
const users = await client.query<User[]>("select User { email, name }");
// Select with variables: every query answers with a set
const [user] = await client.query<User[]>(
`select User { email, name }
filter .email = <str>$email`,
{ email: "ada@example.com" }
);
// Insert: the set of rows it wrote (one)
const [newUser] = await client.query<User[]>(
`insert User {
email := <str>$email,
name := <str>$name
}`,
{ email: "billie@example.com", name: "Billie" }
);
// Update
await client.query(
`update User
filter .email = <str>$email
set { name := <str>$name }`,
{ email: "billie@example.com", name: "Robert" }
);
// Delete
await client.query(
`delete User filter .email = <str>$email`,
{ email: "billie@example.com" }
);
Every query answers with a set, as an array: a select with its objects or values (select count(User) is [2], so read rows[0], not Object.values(row)[0]), and an insert, update or delete with the rows it wrote, [] when none (see Server → POST /query).
bytes is base64 on the wire, both directions (EdgeQL → Bytes on the wire). The SDK handles the encoding:
Uint8Array (or a Node Buffer) anywhere in variables — top level, nested in an object, inside an array, inside a <json> payload — is sent as base64. encodeBytes() is no longer needed for variables. For multi-MB Buffers, pass a Uint8Array view (new Uint8Array(buf.buffer, buf.byteOffset, buf.length)) to skip Buffer.toJSON().select, selectById, filter, insert and update on a generated client return Uint8Array for bytes fields, including through links, via reviveTyped(data, typeInfo).bytes fields to decode with revive.bytes — dot paths relative to a result row; arrays are transparent, so "content" covers a list of rows, "obj.content" a link’s rows, and the same path an array<bytes> field. revive: true alone never touches bytes.const rows = await client.query<{ object_id: string; content: Uint8Array; }[]>(
"select GitObject { object_id, content } filter .program.id = <uuid>$p",
{ p: programId },
{ revive: { bytes: ["content"] } }
);
await client.query(
"insert GitObject { program := <Program><uuid>$p, object_id := <str>$oid, size := <int64>$n, content := <bytes>$content }",
{ content: new Uint8Array([0x1f, 0x8b]), n: 2, oid, p: programId }
);
parseBytes(str) decodes a wire value by hand and accepts both base64 and PostgreSQL \x hex. The request body is capped at 4 MiB by default (DISC_MAX_REQUEST_BODY_BYTES), which is about 3 MiB of raw bytes after base64 inflation; a larger payload is a 413 (DiscProtocolError). Server and SDK must be upgraded together: an old SDK against a new server gets 400s for Uint8Array variables, and a new SDK against an old server stores the base64 text as ASCII.
client.queryRaw<T>(query, variables?)Returns the full response envelope including data, errors, and timing extensions. Does not throw on query errors -- you must check response.errors yourself.
const response = await client.queryRaw<User[]>("select User { email, name }");
if (response.errors && response.errors.length > 0) {
for (const err of response.errors) {
console.error(err.message, err.extensions);
}
} else {
console.log(response.data);
}
// Access timing information
console.log(response.extensions?.parseMs);
console.log(response.extensions?.compileMs);
console.log(response.extensions?.executeMs);
console.log(response.extensions?.cacheHit);
The QueryResponse<T> type has this shape:
interface QueryResponse<T = unknown> {
data?: T;
errors?: QueryError[];
extensions?: QueryExtensions;
}
interface QueryError {
extensions?: Record<string, unknown>;
locations?: Array<{ column: number; line: number; }>;
message: string;
path?: Array<string | number>;
}
interface QueryExtensions {
cacheHit?: boolean;
compileMs?: number;
executeMs?: number;
parseMs?: number;
[key: string]: unknown;
}
When you generate a typed client with disc codegen, junction-backed multi links are writable directly through the generated insert() / update() methods — no raw EdgeQL required. Multi links are typed as arrays of target UUIDs.
Given a schema where User has multi link teams -> Team:
// Insert — assign the full set of linked targets
const user = await client.user.insert({
name: "Ada",
teams: [teamId1, teamId2]
});
// Update (replace) — pass an array to replace the whole set
await client.user.update(user.id, { teams: [teamId2, teamId3] });
// Update (delta add) — add members without disturbing the rest
await client.user.update(user.id, { teams: { add: [teamId1] } });
// Update (delta remove) — remove members without disturbing the rest
await client.user.update(user.id, { teams: { remove: [teamId2] } });
The set semantics are:
insert({ link: [...] }) assigns exactly the listed targets.update(id, { link: [...] }) replaces the set with exactly the listed targets.update(id, { link: { add: [...] } }) adds targets (existing members are left in place; re-adding an existing member is a no-op).update(id, { link: { remove: [...] } }) removes the listed targets.add and remove may be combined in a single update call. Each write compiles to a single atomic statement against PostgreSQL, so the junction rows are never left half-applied.
The SDK provides three methods for checking server health, suitable for orchestration systems and monitoring.
client.health()Returns the full health status including database connectivity, connection pool stats, uptime, and extension health.
const health = await client.health();
// health.database?.connected: boolean
// health.database?.latencyMs: number
// health.pool?.active: number
// health.pool?.idle: number
// health.status: "healthy" | "degraded" | "unhealthy"
// health.uptimeMs: number
client.isAlive()Liveness probe. Returns true if the server process is running. Never throws -- returns false on any error.
const alive = await client.isAlive();
client.isReady()Readiness probe. Returns true when the database is connected and the server can accept queries. Returns false if the database is unreachable.
const ready = await client.isReady();
client.stats()Returns detailed server statistics including connection counts, query metrics, transaction counts, memory usage, cache stats, and rate limiter info.
const stats = await client.stats();
console.log("Active connections:", stats.connections.active);
console.log("Total queries:", stats.queries.total);
console.log("Failed queries:", stats.queries.failed);
console.log("Avg query duration:", stats.queries.avgDurationMs, "ms");
console.log("Active transactions:", stats.transactions.active);
console.log("Heap used:", stats.memoryUsage.heapUsed);
console.log("Uptime:", stats.uptimeMs, "ms");
// Cache stats (when available)
if (stats.cache) {
console.log("Compilation cache hit rate:", stats.cache.compilation.hitRate);
console.log("Parse cache size:", stats.cache.parse.size);
}
// Query performance metrics
if (stats.queryMetrics) {
console.log("Avg parse time:", stats.queryMetrics.avgParseMs, "ms");
console.log("Avg compile time:", stats.queryMetrics.avgCompileMs, "ms");
console.log("Avg execute time:", stats.queryMetrics.avgExecuteMs, "ms");
console.log("Cache hit rate:", stats.queryMetrics.cacheHitRate);
}
The AuthManager class handles the full authentication lifecycle: registration, login, logout, token refresh, and profile management. It wraps a DiscClient and automatically sets the JWT token on the client after successful authentication.
import { AuthManager, createClient } from "disc/sdk/mod.ts";
const client = createClient({ baseUrl: "http://localhost:5656" });
const auth = new AuthManager(client, {
autoRefresh: true, // automatically refresh tokens before expiry (default: true)
refreshBuffer: 60 // seconds before expiry to trigger refresh (default: 60)
});
const response = await auth.register({
email: "ada@example.com",
metadata: { department: "engineering" }, // optional
password: "secure-password-123",
username: "ada" // optional
});
// response contains:
// - response.refreshToken: refresh token (optional)
// - response.session: session details (id, userId, expiresAt)
// - response.token: JWT access token
// - response.user: AuthUser object
After registration, the client is automatically authenticated. All subsequent queries include the JWT token.
// Login with email
const response = await auth.login({
email: "ada@example.com",
password: "secure-password-123"
});
// Login with username
const response = await auth.login({
password: "secure-password-123",
username: "ada"
});
auth.isAuthenticated(); // true if a token is set on the client
auth.getUser(); // cached AuthUser from the last login/register call
getUser() returns the user from the most recent successful login() or register() call without making a server request. Returns null if no session is active.
const profile = await auth.getProfile();
// profile.email, profile.id, profile.username
// profile.createdAt, profile.updatedAt
// profile.active, profile.emailVerified
// profile.metadata
getProfile() makes a GET /auth/profile request and updates the cached user.
await auth.updatePassword("old-password", "new-password");
Throws DiscAuthError if not authenticated.
When autoRefresh is enabled (the default), the AuthManager parses the JWT exp claim and schedules a background refresh refreshBuffer seconds before the token expires. This happens automatically -- you do not need to call refreshTokens() manually.
To refresh manually:
const tokens = await auth.refreshTokens();
// tokens.refreshToken: new refresh token (if issued)
// tokens.token: new JWT access token
Throws DiscAuthError if no refresh token is available.
await auth.logout();
This sends a POST /auth/logout request, then clears all local state: tokens, cached user, and the auth header on the client.
When you are done with the AuthManager, call dispose() to cancel any pending auto-refresh timer:
auth.dispose();
This is important in environments where timers would prevent garbage collection or process exit.
withToken and withHeadersclient.withToken(token) returns a client that sends token as its bearer credential — for example a server’s service credential (DISC_SERVICE_TOKEN). client.withHeaders(headers) returns one with extra custom headers. Both share the parent’s configuration (base URL, timeout, headers, retries, logger, schema epoch, and a generated client’s builders) but not its credentials: neither client’s setAuthToken affects the other. A Transaction uses the credential of the client that opened it, so concurrent callers with different identities each need their own client. Prefer this over mutating a shared client.
const service = client.withToken(Deno.env.get("DISC_SERVICE_TOKEN")!);
await service.transaction(async tx => {
await tx.query("insert Locked { name := <str>$n }", { n: "audit" });
});
Transactions execute multiple queries atomically. The SDK uses a callback pattern: the transaction auto-commits on success and auto-rolls back on error.
const result = await client.transaction(async tx => {
const [user] = await tx.query<User[]>(
`insert User { email := <str>$email, name := <str>$name }`,
{ email: "cher@example.com", name: "Cher" }
);
await tx.query(
`insert Post {
author := (select User filter .id = <uuid>$userId),
body := <str>$body,
title := <str>$title
}`,
{ body: "Written atomically.", title: "First Post", userId: user.id }
);
return user;
});
If the callback throws, the transaction is automatically rolled back and the error is re-thrown. A commit failure is reported as a failure: transaction() rejects, and the transaction is over on the server — nothing is retried and nothing is silently reported as committed.
As in PostgreSQL, once a statement inside a transaction fails, the transaction cannot commit. The server marks it aborted; the SDK moves the Transaction to the failed state and keeps the cause. Catching the error inside the callback does not rescue it:
try {
await client.transaction(async tx => {
try {
await tx.query("insert GitRef { program := <Program><uuid>$p, name := <str>$n, target := <str>$t }", vars);
} catch (error) {
if (error instanceof UniqueViolationError) {
// The transaction is already failed: any further tx.query() or tx.commit()
// throws DiscTransactionError without contacting the server.
}
throw error;
}
});
} catch (error) {
if (error instanceof DiscTransactionError && error.cause instanceof UniqueViolationError) {
// Handle the duplicate here, outside the transaction, and retry the whole
// transaction if that is what you want.
}
}
Even if the callback swallows the error and returns normally, transaction() rolls back and rejects with a DiscTransactionError whose cause is the statement failure. Catch UniqueViolationError, SerializationFailureError or DeadlockError outside transaction() and retry the whole transaction, not the statement. Parse and compile errors do not abort a transaction (nothing was sent to PostgreSQL); anything that reached the database and failed — or timed out — does. A request that fails on the network also fails the transaction, because the SDK cannot know whether the statement ran.
DiscClientConfig.retries is ignored for /transaction/* and for any request carrying X-Transaction-ID: a statement or a COMMIT is never re-sent. The retry unit is the whole transaction() call.
Inside the callback, the tx object provides:
| Method | Description |
|---|---|
tx.query<T>(query, variables?) |
Execute a query within the transaction |
tx.commit() |
Explicitly commit (usually unnecessary) |
tx.rollback() |
Explicitly roll back (still allowed on a failed transaction) |
tx.getState() |
Returns "active", "committed", "rolled_back", or "failed" |
tx.getFailure() |
The statement failure that put the transaction in failed, if any |
tx.getId() |
Returns the transaction ID string |
You do not need to call tx.commit() explicitly. The client.transaction() wrapper commits automatically when the callback returns without throwing. Explicit commit and rollback are available for advanced control flows.
A Transaction transitions through these states:
active --> committed
|
+------> rolled_back
|
+------> failed (a statement or the commit failed; rollback() still allowed)
Calling query() or commit() on a non-active transaction throws DiscTransactionError; on a failed transaction its cause is the original failure.
Under the hood, client.transaction() performs these steps:
POST /transaction/begin -- server allocates a transaction and returns a transactionId.tx.query() sends a POST /query with an X-Transaction-ID header linking the query to the transaction.POST /transaction/commit with the same header.POST /transaction/rollback, then re-throws.On the server, committing an aborted transaction answers 409 TRANSACTION_ABORTED and a COMMIT PostgreSQL rejects answers 500 with the SQLSTATE; in both cases the id is gone afterwards (a repeated commit is 404). See Server → transactions. Transactions are owned by the credential that opened them; a Transaction uses the credential of the client that created it (see withToken below).
The SubscriptionClient provides real-time data streaming over WebSocket. It supports automatic reconnection with exponential backoff and re-subscribes to all active subscriptions after reconnection.
import { createSubscriptionClient } from "disc/sdk/mod.ts";
const sub = createSubscriptionClient(
{ baseUrl: "http://localhost:5656" },
{
autoReconnect: true, // reconnect on disconnect (default: true)
maxReconnectAttempts: 5, // max reconnect attempts (default: 5)
reconnectDelay: 1000 // base delay in ms (default: 1000)
}
);
await sub.connect();
The SubscriptionClient automatically converts http:// to ws:// and https:// to wss:// when connecting.
const handle = sub.subscribe<User[]>(
"select User { email, name, posts: { title } }",
{
onComplete: () => {
console.log("Subscription stream ended");
},
onData: data => {
console.log("Received update:", data);
},
onError: error => {
console.error("Subscription error:", error.message);
}
},
{ status: "active" } // optional variables
);
The subscribe() method returns a SubscriptionHandle:
interface SubscriptionHandle {
id: string; // unique subscription ID
unsubscribe: () => void; // unsubscribe from this subscription
}
// Via the handle
handle.unsubscribe();
// Or by ID
sub.unsubscribe(handle.id);
sub.isConnected(); // true when WebSocket is in OPEN state
sub.close(); // close the connection and clean up all subscriptions
Calling close() prevents any further reconnection attempts.
When the WebSocket connection drops and autoReconnect is true, the client automatically:
reconnectDelay * 2^attempt milliseconds.If reconnection fails after maxReconnectAttempts, all active subscriptions receive an error via their onError callback.
| Option | Type | Default | Description |
|---|---|---|---|
autoReconnect |
boolean |
true |
Reconnect automatically on disconnect |
maxReconnectAttempts |
number |
5 |
Maximum reconnect attempts before giving up |
reconnectDelay |
number |
1000 |
Base delay between reconnect attempts (ms) |
All SDK errors extend DiscClientError, which carries a code property from the DiscErrorCode enum.
| Error Class | Code | Thrown When |
|---|---|---|
DiscAuthError |
AUTH_ERROR |
401 or 403 response, missing token, expired session |
DiscClientError |
(varies) | Base class for all SDK errors |
DiscConnectionError |
CONNECTION_ERROR |
Server unreachable, connection refused |
DiscNetworkError |
NETWORK_ERROR |
Fetch failed, DNS resolution error |
DiscProtocolError |
PROTOCOL_ERROR |
Any other non-OK response: 413 body too large, 404 unknown transaction, 429, or an unexpected body. Carries statusCode. |
DiscQueryError |
QUERY_ERROR |
Server returns one or more query errors. Carries errors, sqlState (the PostgreSQL SQLSTATE, when the statement reached the database) and hint (Gel’s hint, when the error has one: Please use ISO8601 format. …). |
ConstraintViolationError |
QUERY_ERROR |
DiscQueryError for SQLSTATE class 23; carries constraint, table, detail |
UniqueViolationError |
QUERY_ERROR |
ConstraintViolationError for 23505 — a duplicate on an exclusive constraint or unique index |
ForeignKeyViolationError |
QUERY_ERROR |
ConstraintViolationError for 23503 — a link to a row that does not exist |
CardinalityViolationError |
QUERY_ERROR |
DiscQueryError for 21000 — assert_single of several, or an object cast <T><uuid>$p of an id no T has |
SerializationFailureError |
QUERY_ERROR |
DiscQueryError for 40001 — retry the whole transaction |
DeadlockError |
QUERY_ERROR |
DiscQueryError for 40P01 — retry the whole transaction |
DiscServerError |
SERVER_ERROR |
Server returned a 5xx status code |
DiscTimeoutError |
TIMEOUT |
Request exceeds the configured timeout |
DiscTransactionError |
TRANSACTION_ERROR |
Operation on a non-active or failed transaction; cause holds the statement failure |
DiscValidationError |
VALIDATION_ERROR |
A query() options.validate validator rejects |
The typed query errors are all instanceof DiscQueryError and are chosen from extensions.sqlState of the first error in the envelope; createQueryError(errors) builds the right one from a raw envelope (for queryRaw users). Parse, compile, and validation errors have no sqlState.
import { UniqueViolationError } from "disc/sdk/mod.ts";
try {
await client.query("insert GitRef { program := <Program><uuid>$p, name := <str>$n, target := <str>$t }", vars);
} catch (error) {
if (error instanceof UniqueViolationError) {
console.log(error.sqlState, error.constraint); // "23505" "uk_git_ref_program_id_name"
} else throw error;
}
import {
DiscAuthError,
DiscConnectionError,
DiscErrorCode,
DiscQueryError,
DiscServerError,
DiscTimeoutError
} from "disc/sdk/mod.ts";
try {
const users = await client.query("select User { email, name }");
} catch (error) {
if (error instanceof DiscQueryError) {
// Query syntax or execution error
console.error("Query failed:", error.message);
for (const qe of error.errors) {
console.error(" -", qe.message, qe.extensions);
}
} else if (error instanceof DiscTimeoutError) {
// Request took too long
console.error(`Timed out after ${error.timeoutMs}ms`);
} else if (error instanceof DiscAuthError) {
// Authentication failure
console.error(`Auth error (HTTP ${error.statusCode}):`, error.message);
} else if (error instanceof DiscConnectionError) {
// Server unreachable
console.error("Connection failed:", error.message);
} else if (error instanceof DiscServerError) {
// 5xx server error
console.error(`Server error (HTTP ${error.statusCode}):`, error.message);
}
}
Every SDK error has a code property from the DiscErrorCode enum:
import { DiscClientError, DiscErrorCode } from "disc/sdk/mod.ts";
try {
await client.query("bad query");
} catch (error) {
if (error instanceof DiscClientError) {
switch (error.code) {
case DiscErrorCode.AUTH_ERROR: {
// handle auth error
break;
}
case DiscErrorCode.CONNECTION_ERROR: {
// handle connection error
break;
}
case DiscErrorCode.NETWORK_ERROR: {
// handle network error
break;
}
case DiscErrorCode.PROTOCOL_ERROR: {
// handle protocol error
break;
}
case DiscErrorCode.QUERY_ERROR: {
// handle query error
break;
}
case DiscErrorCode.SERVER_ERROR: {
// handle server error
break;
}
case DiscErrorCode.TIMEOUT: {
// handle timeout
break;
}
case DiscErrorCode.TRANSACTION_ERROR: {
// handle transaction error
break;
}
case DiscErrorCode.VALIDATION_ERROR: {
// handle validation failure
break;
}
}
}
}
The client retries requests based on the error type:
| Error Type | Retried? | Notes |
|---|---|---|
DiscAuthError |
No | 401/403 will not succeed on retry |
DiscConnectionError |
Yes | Network-level failures are retried |
DiscNetworkError |
Yes | General fetch failures are retried |
DiscProtocolError |
No | Response format issues are not transient |
DiscQueryError |
No | Query errors are deterministic |
DiscServerError (5xx) |
Yes | Retried with linear backoff (retryDelay * attempt) |
DiscTimeoutError |
No | Thrown immediately, not retried |
Nothing is retried inside a transaction: /transaction/* requests and any request carrying X-Transaction-ID run with retries = 0, whatever the client is configured with. Retry the whole transaction() call instead.
When the server has multi-database support enabled, you can target a specific database by passing a custom header or by including a query parameter.
const client = createClient({
baseUrl: "http://localhost:5656",
headers: { "X-Database": "analytics" }
});
const data = await client.query("select Event { name, timestamp }");
If you need to query different databases from the same client, set the header on individual requests by using queryRaw with the underlying fetch:
// Default database
const users = await client.query("select User { name }");
// Different database via a second client
const analyticsClient = createClient({
baseUrl: "http://localhost:5656",
headers: { "X-Database": "analytics" }
});
const events = await analyticsClient.query("select Event { name }");
The server resolves the database name in this order:
X-Database header?database= query parameter"disc"A runtime DSL that gives the same end-to-end type safety as disc codegen without producing any generated files. Useful when CI shouldn’t carry a codegen step or when the schema lives in TypeScript next to the application code.
defineSchema() accepts a record of type definitions; field markers come from the t.* namespace:
import { defineSchema, t } from "disc/sdk/mod.ts";
export const schema = defineSchema({
User: {
email: t.str(),
name: t.optional(t.str()),
score: t.int64(),
posts: t.multi("Post")
},
Post: {
title: t.str(),
body: t.optional(t.str()),
author: t.single("User")
}
});
The t.* markers carry compile-time type information; defineSchema() validates at construction (PascalCase type names, valid field names, link targets that resolve to defined types) and throws synchronously on misuse.
createQueryBuilder(client, schema) returns a Proxy where each property is a typed selection chain:
import { createClient, createQueryBuilder } from "disc/sdk/mod.ts";
import { schema } from "./schema.ts";
const client = createClient();
const qb = createQueryBuilder(client, schema);
// Awaiting the chain runs the query through `client.query()`.
const users = await qb
.User
.select({ email: true, name: true })
.filter(u => u.email.eq("alice@example.com"))
.first();
// ^? { email: string; name: string | null } | null
select narrows the awaited row type to the picked shape (a link picked with true is its target’s id: string, string | null when optional, string[] for a multi link, [] when empty; a link picked with a sub-shape is [{ … }], and a row with no select has no links), and results are converted to the markers’ types like a generated client’s: int64/bigint to bigint, datetime to Date, bytes to Uint8Array, on linked objects too. filter predicates receive a typed reference where each property accepts only the right comparison operand: u.email.eq(...) requires a string, u.score.gt(...) requires a bigint (t.int64() is a bigint, as in the generated client, so write u.score.gt(10n); each filter value is cast as its field declares, <int64>$p0). Identifier safety is enforced at construction so schema typos fail before reaching the server.
client.queryThe query builder compiles to the same EdgeQL strings the raw client.query("select User { ... }") path does and runs through the same access-policy + read-only + auth-gate pipeline. The two patterns interoperate freely — pick whichever reads better at the call site, no migration needed. (sdk/query-builder.ts, sdk/schema-types.ts)
When using generated types from disc codegen, you can pass them as type parameters to query() for full type safety:
import { createClient } from "disc/sdk/mod.ts";
import type { Post, User } from "./dbschema/disc-client/interfaces.ts";
const client = createClient();
// Type-safe query results
const users = await client.query<User[]>("select User { email, name }");
// users is typed as User[]
// Type-safe within transactions
const result = await client.transaction(async tx => {
const [user] = await tx.query<User[]>(
`insert User { email := <str>$email, name := <str>$name }`,
{ email: "daena@example.com", name: "Daena" }
);
return user; // typed as User
});
// Type-safe subscriptions
const sub = createSubscriptionClient();
await sub.connect();
sub.subscribe<User[]>(
"select User { email, name }",
{
onData: users => {
// users is typed as User[]
for (const user of users) {
console.log(user.email, user.name);
}
}
}
);
The complete list of exports from disc/sdk/mod.ts:
| Export | Description |
|---|---|
AuthManager |
Authentication lifecycle manager |
DiscClient |
Core HTTP client for Disc |
SubscriptionClient |
WebSocket subscription client |
Transaction |
Transaction handle (received in callback) |
| Export | Description |
|---|---|
createClient(config?) |
Create a DiscClient instance |
createSubscriptionClient(config?, options?) |
Create a SubscriptionClient instance |
| Export | Description |
|---|---|
CardinalityViolationError |
Query error with SQLSTATE 21000 |
ConstraintViolationError |
Query error with SQLSTATE class 23 (constraint, table, detail) |
createQueryError |
Build the typed query error for a raw errors envelope |
DeadlockError |
Query error with SQLSTATE 40P01 |
DiscAuthError |
Authentication/authorization failure |
DiscClientError |
Base error class |
DiscConnectionError |
Server unreachable |
DiscErrorCode |
Error code enum |
DiscNetworkError |
Network failure |
DiscProtocolError |
Any other non-OK response (statusCode) |
DiscQueryError |
Query execution error (errors, sqlState, hint) |
DiscServerError |
Server 5xx error |
DiscTimeoutError |
Request timeout |
DiscTransactionError |
Invalid or failed transaction state (cause) |
DiscValidationError |
Runtime validation failure |
ForeignKeyViolationError |
Query error with SQLSTATE 23503 |
SerializationFailureError |
Query error with SQLSTATE 40001 |
UniqueViolationError |
Query error with SQLSTATE 23505 |
| Export | Description |
|---|---|
AuthManagerOptions |
Auth manager configuration |
AuthResponse |
Login/register response (user, session, token) |
AuthTokens |
JWT token and optional refresh token |
AuthUser |
User profile (id, email, username, metadata) |
CacheStats |
Cache hit/miss/eviction stats |
DiscClientConfig |
Client constructor options |
HealthStatus |
Server health (status, database, pool) |
IsolationLevel |
"read_committed", "repeatable_read", "serializable" |
LoginCredentials |
Email/username and password |
QueryError |
Single query error from the server |
QueryExtensions |
Timing info (parseMs, compileMs, executeMs) |
QueryOptions |
Per-query options (e.g. validator for runtime checks) |
QueryRequest |
Query payload (query, variables, operationName) |
QueryResponse<T> |
Response envelope (data, errors, extensions) |
QueryValidator |
Standard Schema validator applied to query results |
RegisterData |
Email, password, optional username/metadata |
ReviveOptions |
Options for reviveResponse (dates, bigints, bytes dot paths) |
ServerStats |
Connections, queries, transactions, memory, cache |
StandardSchemaIssue |
Single validation issue (Standard Schema spec) |
StandardSchemaResult |
Validation result (value or issues; Standard Schema spec) |
StandardSchemaV1 |
Standard Schema v1 validator interface |
SubscriptionCallbacks<T> |
onData, onError, onComplete handlers |
SubscriptionClientConfig |
Subscription client options |
SubscriptionHandle |
Subscription id and unsubscribe function |
SubscriptionMessage<T> |
Incoming subscription message (id, type, payload) |
SubscriptionRequest |
Subscription request (id, query, variables) |
TransactionState |
"active", "committed", "rolled_back", "failed" |
Codegen-free query-builder DSL and schema declaration exports.
| Export | Kind | Description |
|---|---|---|
and |
function | Combine predicates with logical AND |
compileFilter |
function | Compile a codegen filter object to EdgeQL |
createQueryBuilder |
function | Construct a runtime query builder |
defineSchema |
function | Declare a schema for typed builder inference |
from |
function | Start a select chain from an object type |
not |
function | Negate a predicate |
or |
function | Combine predicates with logical OR |
t |
object | Field-type helpers used by defineSchema |
SelectChain |
class | Fluent select-chain builder |
CompiledFilter |
type | Result of compileFilter (EdgeQL + params) |
CompiledQuery |
type | Compiled query (EdgeQL string + variables) |
DiscSchema |
type | Schema declared via defineSchema |
Expr |
type | Query-builder expression node |
FilterArg |
type | Accepted filter argument (Expr or codegen filter) |
QueryBuilder |
type | Runtime query-builder interface |
QueryRunner |
type | Executor passed to the builder |
Shape |
type | Selected-shape descriptor |
TypeInfo |
type | Type metadata consumed by the filter compiler |
TypedFieldRef |
type | Typed field reference in filter predicates |
TypedQueryBuilder |
type | Type-inferred query builder |
TypedRef |
type | Typed object reference |
TypedSelectChain |
type | Type-inferred select chain |
Schema-declaration type exports: FieldMarker, FieldType, IsLink, Link, LinkCardinality, LinkStub (deprecated: a link selected with true is its target’s id string), LinkTarget, Optional, ResolveSelected, ResolveType, Scalar, SchemaSpec, SelectShape.
Wire-format encode/decode helpers.
| Export | Kind | Description |
|---|---|---|
encodeBytes |
function | Encode a Uint8Array as base64 (what jsonReplacer does for variables) |
jsonReplacer |
function | JSON.stringify replacer encoding outbound bigint as a numeric string and Uint8Array/Buffer as base64 |
parseBytes |
function | Decode a wire-format byte string (base64 or \x hex) |
parseDateTime |
function | Parse a wire-format datetime value |
parseInt64 |
function | Parse a wire-format 64-bit integer |
reviveResponse |
function | Revive typed values in a raw response payload (bytes only at the paths named in ReviveOptions.bytes) |
reviveTyped |
function | Revive bytes, int64, bigint, decimal and datetime fields (inside arrays, tuples and link properties too) of a result using a builder’s TypeInfo (what generated clients and createQueryBuilder call) |