The TypeScript libraries give apps, services, devices, portals, and CLIs a typed way to connect to Trellis and use the APIs their contracts allow.
Use this page as a practical map of the TypeScript surface. For exact signatures, see the API Reference.
Packages
Common packages are:
@qlever-llc/trellis: core TypeScript runtime, contract helpers, client connection helpers, generated Trellis-owned SDK re-exports, Result helpers, operations, transfers, state, jobs, and errors.@qlever-llc/trellis/service/deno: service runtime entrypoint for Deno.@qlever-llc/trellis/service/node: service runtime entrypoint for Node.js.@qlever-llc/trellis/telemetry: OpenTelemetry helpers for tracing, propagation, initialization, and Trellis error metrics.@qlever-llc/trellis-test: Deno-first integration test helpers for service repositories that need isolated NATS/JetStream and a real Trellis control-plane process.@qlever-llc/trellis-svelte: Svelte and SvelteKit integration helpers.- generated SDK packages under
generated/packages/jsr/andgenerated/packages/npm/for contracts in your project.
Generated SDKs include contract metadata, TypeScript schema-derived types, use(...) helpers for consumers, and client.ts facade types such as Client and Service.
Connect as an app or CLI
Apps and CLIs usually connect with TrellisClient.connect(...). The contract
controls which surfaces are visible on the returned client.
import { TrellisClient } from "@qlever-llc/trellis";
import appContract from "./contract.ts";
const client = await TrellisClient.connect({
trellisUrl: "http://localhost:3000",
contract: appContract,
}).orThrow();
const me = await client.rpc.auth.sessionsMe({}).orThrow();
console.log(me.participantKind); Use the surface-first generated facade (client.rpc.group.method(...)) for
application code instead of stringly typed RPC calls.
const order = await client.rpc.orders.get({ orderId: "ord_123" }).orThrow(); Connect as a service
Services connect through the service runtime. The runtime authenticates the service instance, presents contract evidence, resolves resource bindings, and returns a typed service object.
Treat those resolved bindings as runtime internals. Service code should use the service.kv, service.store, and service.jobs handles returned by TrellisService.connect(...); do not import the core SDK to bootstrap a service,
call Trellis.Bindings.Get, construct TrellisService or StoreHandle, or pass
binding/resource payloads into Trellis constructors.
import { TrellisService } from "@qlever-llc/trellis/service/deno";
import { ordersService } from "./contracts/orders_service.ts";
const service = await TrellisService.connect({
trellisUrl: Deno.env.get("TRELLIS_URL")!,
contract: ordersService,
name: "orders-service",
sessionKeySeed: Deno.env.get("ORDERS_SERVICE_SESSION_KEY_SEED")!,
}).orThrow();
await service.wait(); For Node.js, import from @qlever-llc/trellis/service/node and use your normal
environment variable loader.
Service telemetry
TrellisService.connect(...) initializes Trellis telemetry by default using the
service name. This configures trace IDs on Trellis errors and, when the
environment configures OpenTelemetry exporters, installs tracing and metrics
runtime support.
const service = await TrellisService.connect({
trellisUrl: Deno.env.get("TRELLIS_URL")!,
contract: ordersService,
name: "orders-service",
sessionKeySeed: Deno.env.get("ORDERS_SERVICE_SESSION_KEY_SEED")!,
}).orThrow(); Disable automatic telemetry only when your process owns OpenTelemetry setup and will initialize Trellis telemetry explicitly.
import { initTelemetry } from "@qlever-llc/trellis/telemetry";
import { TrellisService } from "@qlever-llc/trellis/service/deno";
initTelemetry("orders-service");
const service = await TrellisService.connect({
trellisUrl: Deno.env.get("TRELLIS_URL")!,
contract: ordersService,
name: "orders-service",
sessionKeySeed: Deno.env.get("ORDERS_SERVICE_SESSION_KEY_SEED")!,
telemetry: false,
}).orThrow(); The telemetry subpath also exports trace propagation helpers such as injectTraceContext(...), extractTraceContext(...), getTracer(...), and getActiveSpan(...) for advanced runtime integration. The old @qlever-llc/trellis/tracing subpath is no longer published; use @qlever-llc/trellis/telemetry.
Trellis records runtime error metrics with a trellis.errors OpenTelemetry
counter. Metrics are low-cardinality by design: labels describe stable surfaces,
directions, operations, phases, and Trellis error types. Do not attach user IDs,
session keys, trace IDs, request IDs, payloads, raw NATS subjects, or error
messages as metric attributes in application code.
Connect as a device
Devices have their own activation and connection flow. Check activation first, then connect once the deployment has approved the device identity.
import { TrellisDevice } from "@qlever-llc/trellis";
import { checkDeviceActivation } from "@qlever-llc/trellis/device/deno";
import deviceContract from "./contract.ts";
const activation = await checkDeviceActivation({
trellisUrl: "http://localhost:3000",
contract: deviceContract,
rootSecret: await Deno.readFile("./device-root-secret.bin"),
});
if (activation.status === "activation_required") {
console.log("Open", activation.activationUrl);
await activation.waitForOnlineApproval();
}
const device = await TrellisDevice.connect({
trellisUrl: "http://localhost:3000",
contract: deviceContract,
rootSecret: await Deno.readFile("./device-root-secret.bin"),
}).orThrow(); Use generated SDKs in contracts
When one participant needs another contract, import that contract’s generated
SDK and use its use(...) helper.
import { defineAppContract } from "@qlever-llc/trellis";
import { sdk as orders } from "@my-org/orders-service-sdk";
export default defineAppContract(() => ({
id: "orders-console@v1",
displayName: "Orders Console",
description: "Operator UI for order lookup.",
uses: {
required: {
orders: orders.use({
rpc: { call: ["Orders.Get"] },
events: { subscribe: ["Orders.Shipped"] },
feeds: { subscribe: ["Orders.Live"] },
operations: { call: ["Orders.Process"] },
}),
},
},
})); Only selected surfaces appear on the connected client type.
Surface facades
Trellis groups names by the first dot and lower-cases the first word. For
example, Orders.Get becomes client.rpc.orders.get(...).
RPC
Use RPC for a typed request and response.
const result = await client.rpc.orders.get({ orderId: "ord_123" });
const order = result.take();
if (order.isErr()) {
console.warn(order.error.message);
} else {
console.log(order.value.status);
} Events
Use events for durable facts that other participants can react to.
await client.event.orders.shipped.listen(async (event) => {
console.log("shipped", event.orderId);
}, {}, { mode: "ephemeral", replay: "new" }).orThrow(); Service code that needs durable delivery must declare an eventConsumers group
in its service contract and pass that logical group name from startup code when
listening:
await service.event.orders.shipped.listen(async (event) => {
await projectShipment(event);
return Result.ok(undefined);
}, {}, { group: "shipmentProjection" }).orThrow(); Do not pass durableName; Trellis provisions the physical JetStream consumer
from the contract and returns the binding during service bootstrap. Use mode: "ephemeral" for live-only listeners that do not need a durable cursor.
Register service listeners during startup, not inside RPC, job, operation, or
feed handlers. Handler-injected clients are outbound-only for events: they can publish(...) and prepare(...), but they cannot listen(...).
Services publish events from handler or job code through the scoped client passed to that code:
import { Result } from "@qlever-llc/trellis";
await service.handle.rpc.orders.ship(async ({ input, client }) => {
await client.event.orders.shipped.publish({
orderId: input.orderId,
customerId: input.customerId,
shippedAt: new Date().toISOString(),
}).orThrow();
return Result.ok({ accepted: true });
}); Direct publish(...) is the normal default. It prepares the event body, assigns
runtime event metadata, publishes to JetStream, and returns when the publish is
acknowledged.
Generated SDK event types represent the contract event body you author. Runtime
metadata such as the generated event id and timestamp is available on PreparedTrellisEvent.header after prepare(...) and through the listener
context when consuming events.
For Deno-first integration tests that need to assert a live event was published,
use @qlever-llc/trellis-test and start runtime.captureEvents(...) before the
publish. The capture subscribes through generated contract event surfaces and
returns decoded payloads plus metadata, so tests should not hand-build NATS
subjects or JSON envelopes:
await using capture = await runtime.captureEvents({
name: "orders-event-capture",
contract: ordersContract,
events: ["Orders.Shipped"],
});
await client.event.orders.shipped.publish({
orderId: "ord_123",
customerId: "cust_456",
shippedAt: new Date().toISOString(),
}).orThrow();
const shipped = await capture.waitFor(
"Orders.Shipped",
(record) => record.payload.orderId === "ord_123",
);
console.log(shipped.payload, shipped.context.id, shipped.receivedAt); Use prepare(...) plus an outbox only when the event must be committed together
with service-local database state. The prepared form freezes the subject,
encoded body, runtime event metadata, and publish headers so a worker can publish
the same event later. PreparedTrellisEvent intentionally has no contract id or
contract digest; persisted outbox rows should not be coupled to the publisher’s
current deployment metadata.
import {
OutboxDispatcher,
SqlOutboxRepository,
} from "@qlever-llc/trellis/service";
import { createDrizzleSqlExecutor } from "@qlever-llc/trellis/service/drizzle";
import { Result } from "@qlever-llc/trellis";
// Create one dispatcher for the service process. Use a non-transactional
// repository here; `outboundClient` is the publisher used by the dispatch
// worker, and transactional handlers still create repositories over `tx`.
const outboxRepository = new SqlOutboxRepository(
createDrizzleSqlExecutor(db),
"sqlite",
);
const outboxDispatcher = new OutboxDispatcher(outboxRepository, outboundClient, {
idleRetryMs: 30_000,
});
await service.handle.rpc.orders.ship(async ({ input, client }) => {
const prepared = client.event.orders.shipped.prepare({
orderId: input.orderId,
customerId: input.customerId,
shippedAt: new Date().toISOString(),
}).orThrow();
await db.transaction(async (tx) => {
await tx.update(orders).set({ status: "shipped" });
// Wrap the same transaction executor so the local DB write and outbox row
// commit or roll back together.
const executor = createDrizzleSqlExecutor(tx);
const outbox = new SqlOutboxRepository(executor, "sqlite");
await outbox.enqueue(prepared);
});
// Signal after the transaction commits so rolled-back rows are never
// dispatched. Signals are debounced and only one drain loop runs at a time.
outboxDispatcher.notify();
return Result.ok({ accepted: true });
}); SQL and Drizzle-based services own their schema migrations and transaction
boundaries. Trellis provides SQL repository adapters, DDL helpers, and the
optional @qlever-llc/trellis/service/drizzle executor helper for caller-owned
Drizzle SQLite databases or transactions. Your service should put the
outbox/inbox tables into its normal migration flow and use the same database
transaction when coupling an outbox row to local state. The dispatcher is
process-local coordination; idleRetryMs is optional, but useful as a
low-frequency recovery scan for missed signals or rows left behind after a
restart.
Use an inbox only for handlers that are not naturally idempotent. If replaying a message would be harmless because the handler writes idempotently by business key, no inbox is needed.
import { SqlInboxRepository } from "@qlever-llc/trellis/service";
await service.event.orders.shipped.listen(async (event, context) => {
const inbox = new SqlInboxRepository(db, "postgres");
if (!(await inbox.record(context.id))) return;
await applyNonIdempotentSideEffect(event);
}, {}, { group: "shipmentProjection" }).orThrow(); NatsKvOutboxRepository and NatsKvInboxRepository are durable queue and dedupe
helpers for services that do not have SQL state. They are not transactional with
unrelated database side effects, so do not use them to claim that an arbitrary DB
write and an event enqueue committed atomically.
Feeds
Use feeds for filtered live views where the caller sends an input and receives a stream of typed events.
const subscription = await client.feed.orders.live({
customerId: "cust_456",
}).orThrow();
for await (const event of subscription) {
console.log(event.name, event.event);
} Operations
Use operations for caller-visible work that may take time and report progress.
const ref = await client.operation.orders.process
.start({ orderId: "ord_123" })
.orThrow();
for await (const event of ref.watch()) {
if (event.type === "progress") {
console.log(event.snapshot.progress);
}
if (event.type === "completed") {
console.log(event.snapshot.output);
break;
}
} For the common case where you only need the terminal result:
const terminal = await ref.wait().orThrow();
console.log(terminal.output); Service provider APIs
Services register handlers through service.handle. The handler receives typed
input, request context, and a typed outbound client for the service’s allowed
dependencies.
Bind application-owned dependencies once with service.with(deps) when handlers
need local repositories, loggers, clocks, or projections. Trellis keeps those
dependencies separate from request context and passes them as args.deps.
import { Result } from "@qlever-llc/trellis";
const app = service.with({ db, logger, clock, orders });
await app.handle.rpc.orders.get(async ({ input, context, client, deps }) => {
deps.logger.info({ caller: context.caller }, "loading order");
const entry = await client.kv.orders.get(input.orderId);
if (entry.isErr()) return Result.err(entry.error);
return Result.ok(entry.value.value);
}); Feed handlers receive an emit(...) helper and an abort signal.
await app.handle.feed.orders.live(async ({ input, emit, signal, deps }) => {
while (!signal.aborted) {
const update = await deps.orders.nextVisibleUpdate(input.customerId, signal);
if (!update) break;
await emit(update).orThrow();
}
}); Operation handlers receive an operation control helper. Mark the operation started, publish progress if useful, then complete, fail, cancel, attach work, or defer completion to another durable path.
await app.handle.operation.orders.process(async ({ input, op, deps }) => {
await op.started().orThrow();
await op.progress({ step: "queued", message: "Queued" }).orThrow();
const output = await deps.orders.process(input.orderId);
return Result.ok(output);
}); The same binding applies to app.jobs.<queue>.handle(...) and app.event.<group>.<leaf>.listen(...). Registration options such as event
subject data and listener options stay in their existing argument positions; do
not pass dependencies as a settings object.
Result and AsyncResult
Trellis TypeScript APIs model expected failures with Result and AsyncResult from @qlever-llc/result, re-exported by @qlever-llc/trellis.
That means most calls do not throw for expected remote or validation failures.
You can either branch explicitly or use .orThrow() when throwing is appropriate
for your boundary.
const result = await client.rpc.orders.get({ orderId: "missing" });
const value = result.take();
if (value.isErr()) {
console.warn(value.error.message);
return;
}
console.log(value.value); Use declared error classes for business failures callers are expected to handle. Use unexpected errors for bugs, infrastructure failures, or malformed responses.
Svelte integration
Svelte and SvelteKit apps should use @qlever-llc/trellis-svelte for app-local
connection state and login integration.
import {
createTrellisApp,
type TrellisClientFor,
} from "@qlever-llc/trellis-svelte";
import contract from "$lib/contract";
type AppClient = TrellisClientFor<typeof contract>;
export const trellisApp = createTrellisApp({
contract,
trellisUrl: "http://localhost:3000",
});
export function getTrellis(): AppClient {
return trellisApp.getTrellis();
} Mount the same app owner with TrellisProvider near the Svelte app root. Read
connection status from trellisApp.getConnection(), which returns a
Svelte-reactive SvelteTrellisConnection.
Browser apps should pass the app source contract default export to Trellis
helpers. The source contract returned by defineAppContract(...) already has the
module shape Trellis needs: CONTRACT, CONTRACT_DIGEST, API.trellis, and
contract metadata.
import { defineAppContract } from "@qlever-llc/trellis/contracts";
import { sdk as orders } from "#trellis-generated-sdk/orders";
const contract = defineAppContract(() => ({
id: "acme.dashboard@v1",
displayName: "Acme Dashboard",
description: "Browser app for order workflows.",
uses: {
required: {
orders: orders.use({
rpc: { call: ["Orders.Get"] },
}),
},
},
}));
export default contract; If TypeScript reports that a contract is missing API or related contract
properties, check whether the app passed the generated CONTRACT manifest
constant instead of the app source contract default export. Avoid manually
composing { CONTRACT, API: { owned: OWNED_API } } for normal Svelte app
bootstrap; that partial shape can lose API.trellis, CONTRACT_DIGEST, state
metadata, and clean inferred client types.
Generated SDKs are workspace artifacts. There is no separate published path for
an app’s generated SDK; use them from source contracts for sdk.use(spec) and
keep imports pointed at the local generated output or an import-map alias owned by
the consuming repo.
For cross-contract app calls, declare the narrow uses selection the app needs
and call the other contract SDK’s sdk.use(spec) helper. Do not depend on broad
generated surfaces just to make client types appear.
Vite, npm, JSR, and svelte-check
Browser apps often combine Deno generation, JSR-style generated SDKs, npm packages, and Vite. Keep each resolver layer explicit:
- Mirror
@nats-io/*specifiers indeno.jsonso Deno generation and Vite/npm builds resolve the same transport packages. - Preserve generated npm literals such as
npm:/typebox@^1.1.33/value; do not hand-normalize them to a different TypeBox entrypoint unless generation changes the source. - If Vite aliases an
npm:Trellis specifier, use a regexfindthat catches subpaths as well as the package root, for example bothnpm:@qlever-llc/trellisandnpm:@qlever-llc/trellis/.... - If
svelte-checkcannot find Trellis types from the npm artifact, add acompilerOptions.pathsmapping in the app’s typecheck config to the installed packagemod.tsand its subpath.tsfiles. The npm artifact is not the source of generated app SDK types.
See Writing SvelteKit Apps for the full app flow, including login pages, providers, and local workspace aliases.
API reference
Use the API Reference for generated TypeScript docs and exact exported
symbols. Guides show common patterns; /api is the source to consult when you
need precise signatures.