@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({
args: { name: v.string(), enabled: v.boolean() },
handler: async (ctx, { name, enabled }) => ctx.kv.put(`flag:${name}`, { enabled }),
});
export const getFlag = query({
args: { name: v.string() },
handler: async (ctx, { 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({
handler: 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({
handler: 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({
handler: 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";
export const search = action({
args: { query: v.string() },
handler: async (ctx, { query }) => ctx.vectors.query("docs", { input: query, topK: 5 }),
});query(index, { input }) embeds input with the index's configured embedder, then runs the search; pass a precomputed vector instead of input to skip embedding. upsert / upsertMany write vectors back. The index name is narrowed to the ones your schema declares, so a typo is a compile error.
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. Both accept an optional third namespace argument (ctx.vectors.getByIds(index, ids, namespace)) that overrides the default the same way query's input.namespace does; @lunora/ai/rag uses this 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", { 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({
handler: 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.