@lunora/bindings bundles the thin, zero-dependency Cloudflare binding helpers into a single install, exposed as per-binding subpaths. Each is a typed ctx.* facade over its Cloudflare binding; codegen wires the matching context helper when a lunora/ source imports the subpath (or reads the ctx.* property).
pnpm add @lunora/bindings| Subpath | Context helper | Cloudflare binding | Where |
|---|---|---|---|
@lunora/bindings/kv | ctx.kv | Workers KV | Query / Mutation / Action |
@lunora/bindings/images | ctx.images | Cloudflare Images | Action only |
@lunora/bindings/analytics | ctx.analytics | Analytics Engine | Query / Mutation / Action |
@lunora/bindings/analytics-sql | ctx.analyticsSql | Analytics SQL | Action only |
@lunora/bindings/pipelines | ctx.pipelines | Pipelines (R2-backed) | Action only |
@lunora/bindings/vectors | ctx.vectors | Vectorize | Query / Mutation / Action |
@lunora/bindings/r2sql | ctx.r2sql | R2 SQL (Apache Iceberg) | Action only |
@lunora/bindings/artifacts | ctx.artifacts | Artifacts (Git repos) | Action only |
@lunora/bindings/ai-search | ctx.aiSearch | AI Search (types only) | Action only |
sideEffects: false subpath exports keep tree-shaking per-binding: an app that imports only @lunora/bindings/kv bundles nothing from the other helpers. Heavier add-ons with framework/driver peer deps (@lunora/browser, @lunora/hyperdrive, @lunora/ai, @lunora/payment) stay separate installs.
KV — @lunora/bindings/kv
Typed Workers KV with scoped key helpers, available on every context. Add a kv_namespaces binding (env.KV) to your wrangler.jsonc:
{ "kv_namespaces": [{ "binding": "KV", "id": "<your-kv-namespace-id>" }] }import { mutation, query } from "@/lunora/_generated/server";
import { v } from "@lunora/values";
export const setFlag = mutation
.input({ name: v.string(), enabled: v.boolean() })
.mutation(async ({ ctx, args: { name, enabled } }) => ctx.kv.put(`flag:${name}`, { enabled }));
export const getFlag = query.input({ name: v.string() }).query(async ({ ctx, args: { name } }) => ctx.kv.get(`flag:${name}`));Images — @lunora/bindings/images
Cloudflare Images transforms (resize / format / optimize) plus signed and unsigned delivery URLs. Action-only (non-deterministic compute). Add an images binding (env.IMAGES):
import { action } from "@/lunora/_generated/server";
export const thumbnail = action.action(async ({ ctx }) => {
// transform(input, transformOptions?, outputOptions?) — sizing and output format are separate args.
const out = await ctx.images.transform(sourceStream, { width: 128 }, { format: "image/webp" });
return out;
});Build delivery URLs without a binding via the helpers:
import { buildImageDeliveryUrl, buildSignedImageUrl } from "@lunora/bindings/images";Analytics — @lunora/bindings/analytics
Analytics Engine: typed writeDataPoint (and an ergonomic track). Fire-and-forget writes ride every context. Add an analytics_engine_datasets binding (env.ANALYTICS):
import { mutation } from "@/lunora/_generated/server";
export const recordSignup = mutation.mutation(async ({ ctx }) => {
ctx.analytics.track("signup", { dimensions: { plan: "pro" }, metrics: { mrr: 20 } });
});Read the data back with ctx.analyticsSql.
Analytics SQL — @lunora/bindings/analytics-sql
Read-only SQL over Cloudflare's Analytics SQL API through the Analytics SQL Workers binding. It reads your Analytics Engine datasets as events.analyticsEngine."<dataset>", plus Cloudflare's own events., states. and logs. datasets. The account comes from the Worker, so there is no token to manage. It is a separate surface from ctx.analytics: that one is a write-only sink on every context, while a query is billed, non-deterministic network I/O, so ctx.analyticsSql is action only.
Add the binding. It needs wrangler 4.145.0 or later, which is the first release that accepts the analytics key. lunora dev writes it for you when a handler reads ctx.analyticsSql, unless the app chains .analyticsSql(...) onto defineApp() (see below):
{
"analytics": { "binding": "ANALYTICS_SQL" },
}import { action, v } from "@/lunora/_generated/server";
export const callsSince = action.input({ since: v.string() }).action(async ({ args, ctx }) => {
assertAdmin(ctx); // your own admin check: this reads the account's analytics
const { rows } = await ctx.analyticsSql.query<{ calls: number; fn: string }>(
`SELECT blob2 AS fn, COUNT(*) AS calls
FROM events.analyticsEngine."ANALYTICS"
WHERE timestamp >= $since AND blob1 = 'function_call'
GROUP BY fn ORDER BY calls DESC LIMIT 25`,
{ since: args.since },
);
return rows;
});query(sql, params?) binds $1 / $name placeholders from an array or an object of strings, numbers, booleans or null, and resolves { rows, rowCount, statistics } (statistics holds elapsed_ms, rows_read and bytes_read). The dialect is the Analytics SQL API's, not the older Analytics Engine SQL API's:
- one statement and one dataset per query, at most 10 KiB of SQL;
- a lower
timestampbound is required; COUNT,SUM,AVGandtopKare sample-weighted for you; passsampleIntervalas the weight toquantileWeighted(level, expr, weight).
A failed query rejects with an AnalyticsSqlQueryError (ANALYTICS_SQL_QUERY_ERROR) whose retryable flag says whether repeating it may help. Nothing is retried automatically, so back off before retrying.
The binding reads every dataset the account has, so treat an action that queries it like any other admin endpoint: gate it, and never pass SQL from the caller into query. Take a choice from the caller and build the statement on the server.
Without the binding (wrangler older than 4.145.0, or code outside a Worker), createAnalyticsSqlRest runs the same dialect over the REST endpoint with an API token that has Account Analytics Read. It has the binding's shape, so point ctx.analyticsSql at it or use it directly. With the .analyticsSql(...) override in place, lunora dev stops adding the analytics binding, so an older wrangler keeps accepting the config:
import { createAnalyticsSql, createAnalyticsSqlRest } from "@lunora/bindings/analytics-sql";
// In lunora/app.ts:
defineApp().analyticsSql((env) => createAnalyticsSqlRest({ accountId: env.CF_ACCOUNT_ID as string, apiToken: env.CF_ANALYTICS_TOKEN as string }));
// Or anywhere:
const analyticsSql = createAnalyticsSql({ binding: createAnalyticsSqlRest({ accountId, apiToken }) });The REST transport fails with the same error: a non-2xx status (429, 500, 503 and 507 are retryable), the timeoutMs deadline (504, retryable, default 60 s), or a request that never reached the API, such as a DNS failure or a reset connection (retryable).
The binding has no local simulator, so lunora dev queries the account's live analytics: miniflare proxies the binding to Cloudflare even without --remote, and every query is billed. On celld and Node the binding does not exist, so codegen omits ctx.analyticsSql and reports platform_unsupported_feature.
Studio usage panels
The Studio Analytics tab (request volume, p50/p95 latency and hot shards per function) asks the host for each panel by key. Answer with an admin-gated action that builds the statement with functionUsageQuery:
import { functionUsageQuery, isFunctionUsagePanel } from "@lunora/bindings/analytics-sql";
import { LunoraError } from "@lunora/errors";
import { action, v } from "@/lunora/_generated/server";
export const usagePanel = action.input({ panel: v.string() }).action(async ({ args, ctx }) => {
assertAdmin(ctx); // your own admin check
if (!isFunctionUsagePanel(args.panel)) {
throw new LunoraError("BAD_REQUEST", `unknown usage panel "${args.panel}"`);
}
const { params, query } = functionUsageQuery(args.panel); // last 24 hours of the ANALYTICS dataset
return ctx.analyticsSql.query(query, params);
});Then hand Studio a runner defined once, at module level, so it keeps its identity across renders:
const usagePanel = async (panel: FunctionUsagePanel) => client.action(api.studioAnalytics.usagePanel, { panel });
<Studio analyticsSqlQuery={usagePanel} />;The panels read the function_call data points ctx.analytics.track writes.
Pipelines — @lunora/bindings/pipelines
Cloudflare Pipelines: durable, batched, R2-backed streaming ingestion. Action-only and fire-and-forget; never read a record back in-handler. Add a pipelines binding (env.PIPELINES) created with wrangler pipelines create:
import { action } from "@/lunora/_generated/server";
export const ingest = action.action(async ({ ctx }) => {
await ctx.pipelines.send({ userId: "u_1", event: "purchase", amount: 19.99 });
});Vectors — @lunora/bindings/vectors
Cloudflare Vectorize: typed vector indexes and similarity search, wired from defineVectorIndex / inline .vectorize() declarations and surfaced as ctx.vectors.
import { action } from "@/lunora/_generated/server";
import { v } from "@lunora/values";
import { embed } from "../app/embed"; // the same embedder the index declares
export const search = action.input({ query: v.string() }).action(async ({ ctx, args }) => ctx.vectors.query("docs", { embed, input: args.query, topK: 5 }));query(index, { input, embed }) embeds input with the embed you pass, then runs the search. The index's own embedder is not applied for you, so pass the one the index declares, or the query vector will not be comparable with the stored ones. Pass a precomputed vector instead of input to skip embedding. upsert (held until the mutation commits) and upsertNow (immediate) write one vector; deleteByIds removes vectors. The index name is narrowed to the ones your schema declares, so a typo is a compile error.
Rows written before an index existed are not synced by the write hook. Index them with the __lunora_admin__:backfillVectors admin operation, which embeds a bounded number of pages per call and resumes where the last call stopped. It also re-embeds a table when its recorded config changes: index names, the source field, dimensions, metric, metadata keys, the declared model, and the table's .softDelete() field. Nothing else is compared. embed is a function it cannot compare, so declare model on .vectorize() / defineVectorIndex() (for example model: "@cf/baai/bge-m3") to have a model swap re-embed automatically. Without model, a model swap is not detected, and neither is an edit to a defineVectorIndex select or metadata function; for those you pass {"restart":true} yourself. See Vector search.
Tenant isolation on .shardBy() tables
Vectorize indexes are account-global: every shard DO shares the same index, so a query with no namespace matches every tenant's vectors. When a .shardBy()'d table declares a vector index, codegen scopes both sides automatically: the auto-sync write hook and ctx.vectors itself default namespace to the owning DO's shard key for that specific index, so ctx.vectors.query/getByIds/deleteByIds/upsert/upsertNow only ever see this tenant's vectors unless you pass an explicit namespace yourself. That override is deliberate, not a hole: ctx.vectors is trusted server-side app code (the same trust level ctx.db gives any table read), so an explicit namespace is trusted to mean a genuine cross-tenant admin operation, the same way an explicit table argument on ctx.db is.
A schema can mix .shardBy()'d and root-scoped vectorized tables. ctx.vectors is one flat facade over every declared index, reachable from any DO instance, so the default above only applies to indexes sourced from a .shardBy()'d table; a root-scoped index always stays namespace-less, from any instance. The single default (root) DO instance owns no shard key at all: calling a sharded index from it with no explicit namespace throws (rather than silently searching/mutating every tenant, or silently returning nothing). Pass an explicit namespace, or issue the call from the sharded DO instance that owns the tenant.
Vectorize's id-based operations (getByIds, deleteByIds) take no namespace filter remotely, so once a namespace applies (explicit or defaulted) it's enforced client-side: getByIds drops any returned record whose namespace doesn't match (a record with no namespace is treated as a mismatch, never as "belongs to everyone"), and deleteByIds resolves the ids first and only deletes the ones that belong to the resolved namespace, silently: a caller asking to delete 5 ids and having only some belong to its namespace gets no per-id signal today. On ctx.vectors both take (index, ids) only, so the id path always uses the default namespace. The adapter underneath (createContextVectors) accepts an optional third namespace argument, and @lunora/ai/rag uses it to thread its own tenant namespace through getByIds/deleteByIds.
A vectorized table with no .shardBy() at all (or the single default DO, for a schema with no sharded vector tables) keeps today's namespace-less behavior, since there is no per-tenant key to scope by.
Filtering on metadata
.vectorize(field, { metadata: ["authorId"] }) mirrors those columns into each vector's metadata so query(index, { filter }) can narrow by them:
ctx.vectors.query("docs", { embed, filter: { authorId: userId }, input: query, topK: 5 });Vectorize only filters on a property that has a metadata index, which is created separately from the vector index itself. lunora deploy provisions one per declared property (idempotently, and non-fatally: it reports the command if it can't); lunora doctor lists what your schema expects. Deploying with wrangler directly means creating them yourself:
wrangler vectorize create-metadata-index docs --property-name=authorId --type=stringA missing metadata index is silent. Vectorize does not error on an unindexed filter property; it returns nothing, which reads like "no matches" rather than "misconfigured". Only string, number and boolean columns can be filtered on; other kinds are stored with the vector but never match a filter, and both deploy and doctor say so.
R2 SQL — @lunora/bindings/r2sql
A typed, chainable query builder over R2 SQL (serverless queries against Apache Iceberg tables): window functions, DISTINCT, set operations. Action-only: every query is an external HTTPS round-trip and is not tracked by Lunora live queries.
import { action } from "@/lunora/_generated/server";
import { desc } from "@lunora/bindings/r2sql";
export const topEvents = action.action(async ({ ctx }) =>
ctx.r2sql.from("events").select("type", "COUNT(*) AS total").groupBy("type").orderBy(desc("total")).limit(10),
);Tag descending order with desc(...) (or asc(...)); a bare string column sorts ascending. Aggregates go in the select list as raw SQL expressions.
Artifacts — @lunora/bindings/artifacts
Cloudflare Artifacts is a versioned file system that speaks Git: a namespace holds repos, and ctx.artifacts reaches them through an artifacts binding. It is in open beta and needs Workers Paid. Every call is a billed, remote operation, so ctx.artifacts is action-only. Add the binding (env.ARTIFACTS):
{ "artifacts": [{ "binding": "ARTIFACTS", "namespace": "default" }] }import { action } from "@/lunora/_generated/server";
import { v } from "@lunora/values";
export const readme = action.input({ repo: v.string() }).action(async ({ ctx, args }) => {
// withRepo opens the repo handle and disposes of it when the callback settles,
// even when it throws, so a handle never outlives the request.
const file = await ctx.artifacts.withRepo(args.repo, (repo) => repo.readFile({ ref: "main", path: "README.md" }));
return file === null ? null : await file.text();
});The client wraps the binding without hiding it:
- Namespace operations:
create,import(from an external HTTPS Git remote),list,delete, andinfo(name). Without a binding, every method ofctx.artifactsthrows,authenticatedRemoteincluded; importauthenticatedRemotefrom@lunora/bindings/artifactsto build a remote URL where no binding is configured. - Repo operations, inside
withRepo(name, (repo) => …):info,createToken(scope, ttlSeconds),listTokens,revokeToken,fork,log,readCommit,readTree,readBlob, andreadFile.readFileandreadBlobreturn the binding'sBlobas is, without buffering it. - Errors come back as
LunoraError:NOT_FOUND→NOT_FOUND;ALREADY_EXISTSand the*_IN_PROGRESScodes →CONFLICT;INVALID_*→BAD_REQUEST;REMOTE_AUTH_REQUIRED→FORBIDDEN; everything else →INTERNAL. The binding's owncodeandnumericCodeare kept inerror.data. Its message is not copied into ours, so nothing the service echoes back ends up on the wire.
Writing is a git push
The binding has no write method. You can't put a file into a repo from a Worker. A Git client pushes it, using a repo-scoped token. A write token is a push credential, so minting one is an authorization decision: check that the caller may write to the repo first, use the credential on the server, and never return it:
import { LunoraError } from "@lunora/errors";
import { api } from "@/lunora/_generated/api";
export const publish = action.input({ repo: v.string() }).action(async ({ ctx, args }) => {
// `api.repos.owner` is your own query mapping a repo to the user who may push to it.
const owner = await ctx.runQuery(api.repos.owner, { repo: args.repo });
if (owner === null || owner !== ctx.auth.userId) {
throw new LunoraError("FORBIDDEN", "you can't push to this repo");
}
const { remote } = await ctx.artifacts.info(args.repo);
const token = await ctx.artifacts.withRepo(args.repo, (repo) => repo.createToken("write", 900));
// https://x:<secret>@host/… — the `?expires=` suffix is stripped.
const pushRemote = ctx.artifacts.authenticatedRemote(remote, token.plaintext);
// … hand `pushRemote` to the Git client through its environment, never its arguments
// (the container recipe below passes the token as a remote-scoped `http.extraHeader`
// instead), then revoke the token. Return at most its id.
return { tokenId: token.id };
});The Git client can be a container (see @lunora/container's Artifacts recipe) or a CI runner the action starts. Don't send the authenticated remote, or token.plaintext, back to a browser: anyone holding it can push until it expires. Keep write tokens short-lived and revokeToken(token.id) when the session that needed one ends.
Namespaces and jurisdiction
A namespace's jurisdiction (eu or us) is set when the namespace is created and can't change afterwards. The first create() against a namespace that does not exist creates it unrestricted. If your schema declares .jurisdiction("eu") or .jurisdiction("us"), create the namespace first over the REST API with that jurisdiction (wrangler has no namespaces create):
curl --request POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/artifacts/namespaces" \
--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
--header "Content-Type: application/json" \
--data '{ "namespace": "my-eu-namespace", "jurisdiction": "eu" }'lunora dev prints this as a hint when it sees ctx.artifacts and no artifacts binding named ARTIFACTS (the name ctx.artifacts reads unless .artifacts() on defineApp points it at another binding), and it never writes the binding for you. A .jurisdiction("fedramp") schema that uses ctx.artifacts fails codegen, because Artifacts has no FedRAMP namespace.
Events
Artifacts publishes repo events through Queues event subscriptions. Consume them with an ordinary defineQueue and type the messages as ArtifactsEvent, narrowing on type:
// lunora/queues.ts
import type { ArtifactsEvent } from "@lunora/bindings/artifacts";
import { defineQueue } from "@lunora/queue";
import { api } from "./_generated/api";
export const repoEvents = defineQueue<ArtifactsEvent>({
handler: async (_ctx, batch) => {
for (const message of batch.messages) {
const event = message.body;
if (event.type === "cf.artifacts.repo.pushed") {
await message.run(api.review.start, { repo: event.source.repoName, sha: event.payload.after });
}
message.ack();
}
},
});Then subscribe the queue. Account-level lifecycle events (created, deleted, forked, imported) come from the artifacts source:
npx wrangler queues subscription create repo-events --source artifacts --events repo.created,repo.deleted,repo.forked,repo.importedRepo-level activity (pushed, cloned, fetched, token.created, token.revoked) comes from the artifacts.repo source, which is scoped to one namespace and repo. wrangler queues subscription create has no flags for that scope yet, so create those subscriptions in the dashboard (Queues → your queue → Subscriptions). Lunora doesn't provision subscriptions. Token events carry the token id, never the plaintext.
Development and testing
There is no local simulator: miniflare only proxies the binding to the remote service, so lunora dev needs an authenticated wrangler, and every call reaches your account. In unit tests, use the in-memory fake from @lunora/testing:
import { createArtifacts } from "@lunora/bindings/artifacts";
import { createArtifactsFake } from "@lunora/testing";
const fake = createArtifactsFake({ namespace: "default" });
const artifacts = createArtifacts({ binding: fake.binding });
await artifacts.create("docs");
fake.putFile("docs", { ref: "main", path: "README.md", content: "# hello" }); // stands in for a pushfake.handles counts opened and disposed repo handles, and fake.failNext(code) injects a binding error.
Artifacts is Cloudflare-only: target: "node" and celld rate it unsupported, so codegen omits ctx.artifacts there and reports platform_unsupported_feature. Pushing to an Artifacts repo from Workers Builds is not wired into lunora deploy: a push-to-deploy build would skip codegen, migrations and the schema-drift gate.
AI Search — @lunora/bindings/ai-search
Cloudflare AI Search (formerly AutoRAG): managed ingestion, chunking, embedding, hybrid (vector + BM25) retrieval and reranking. Action-only: every call is billed, non-deterministic network I/O. The subpath exports types only: ctx.aiSearch is the raw ai_search_namespaces binding (AiSearch), passed through unwrapped, and AiSearchInstance is what its get(name) returns. Both are structural mirrors of Cloudflare's binding classes, like every other subpath here, so they type-check without @cloudflare/workers-types installed — and the real binding is pinned (by a type test) to satisfy them.
lunora dev / lunora deploy add the binding for you the first time a lunora/ source reads ctx.aiSearch, and never touch an entry you wrote:
{ "ai_search_namespaces": [{ "binding": "AI_SEARCH", "namespace": "default" }] }import { action } from "@/lunora/_generated/server";
import { v } from "@lunora/values";
export const searchDocs = action.input({ query: v.string() }).action(async ({ ctx, args }) => ctx.aiSearch.get("docs").search({ query: args.query }));Point it at another binding with defineApp().aiSearch((env) => env.MY_NAMESPACE). When to choose AI Search over defineRag, streaming chat completions, per-tenant instances and the billing and filter caveats are covered in @lunora/ai.