@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/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 |
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) plus a SQL-API read client. 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 } });
});The SQL-API read client is a separate import for dashboards/reports:
import { createAnalyticsSqlClient } from "@lunora/bindings/analytics";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.