anolilab/lunora/lunora-functions

Claude Code and agent skill by anolilab/lunora

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Authoring rules for Lunora schema and server functions under `lunora/` —

Install

npx vskill@latest install anolilab/lunora/lunora-functions

SKILL.md

Lunora Functions

The core authoring rules for Lunora backend code. The verify loop after every edit:

  1. lunora codegen — regenerates lunora/_generated/ and prints advisor

findings (it does not run tsc).

  1. lunora verify — codegen dry-run plus tsc --noEmit, no files written. The

type-check needs a tsconfig.json (without one it warns and skips) and is off under --no-typecheck.

When to Use

  • Writing or editing schema, queries, mutations, or actions.
  • Reviewing lunora/ code for correctness and idiom.
  • Deciding query vs mutation vs action, or public vs internal.

When Not to Use

  • Setting up a new project (lunora-quickstart) or auth (lunora-setup-auth).
  • Diagnosing a slow query or write conflict (lunora-performance-audit).
  • Changing an existing schema with data at rest (lunora-migration-helper).

Schema: defineSchema + defineTable

lunora/schema.ts exports defineSchema as the default export. Every column is a v.* validator. Declare an index for every access pattern you query by.

ts
import { defineSchema, defineTable, v } from "lunorash/server";
export default defineSchema({
messages: defineTable({
channelId: v.id("channels"),
authorId: v.id("users"),
body: v.string(),
createdAt: v.number(),
}).index("by_channel", ["channelId", "createdAt"]),
channels: defineTable({
name: v.string(),
}),
});
  • Lunora injects _id and _creationTime on every row, so don't declare them.
  • lunorash/* is the umbrella package templates install; @lunora/server is

the same module if the project depends on it directly.

  • .index("name", ["a", "b"]) — columns are ordered; put equality columns

first, then the range/sort column.

  • .shardBy("ownerId") partitions the table across Durable Objects by key;

.global() replicates it to D1 for cross-region reads. Default (neither) is a single root-scoped ShardDO. They are not combined on one table — for choosing between them, see the side-by-side comparison in the lunora-performance-audit skill.

Validators (v.*)

string, number, boolean, id("table"), null, any, bigint, bytes, literal(value), array(item), object({...}), record(key, value), union(a, b, …), optional(inner), partial({...}), geoPoint(), the convenience types date, timestamp, and storage (an R2 object key), and v.from(schema) to adopt any Standard Schema v1 validator (Zod, Valibot, …).

  • Required is the default. v.optional(x) means the field may be absent;

x.nullable() means it may be null. They are different.

  • Refinements chain on the validator and are enforced at the trust boundary:

v.string().min(1).max(4096), .email(), .url(), .pattern(re); v.number().int().min(0), .positive(); v.array(x).max(n). Bound every client-supplied string and array — templates cap text at .max(4096).

Functions: query / mutation / action

Each function declares its inputs with .input(...) (a v.* map), optionally adds .use(middleware) (rate limiting, RLS, masking) and .output(validator), and ends with a terminal .query / .mutation / .action taking a ({ ctx, args }) => … handler. Export them as named consts from lunora/*.ts. Codegen nests references by path: lunora/messages.ts → api.messages.<fn>, lunora/billing/invoices.ts → api.billing.invoices.<fn>. Public functions land in _generated/api.ts, internal* ones in _generated/internal.ts.

KindReads ctx.dbWrites ctx.dbSide effects / fetchReactive
queryyesnonoyes
mutationyesyes, transactionalno—
actionyesyes, autocommittingyes—

Import the builders from _generated/server, not from lunorash/server: codegen binds them to your schema, which is what types ctx.db, v.id("channels"), and index names. lunorash/server exports the schema/HTTP/validator surface and LunoraError, never query / mutation / action.

ts
// lunora/messages.ts
import { LunoraError } from "lunorash/server";
import { api } from "./_generated/api.js";
import type { Doc, Id } from "./_generated/server.js";
import { action, mutation, query, v } from "./_generated/server.js";
export const listByChannel = query.input({ channelId: v.id("channels") }).query(async ({ ctx, args: { channelId } }): Promise<Doc<"messages">[]> =>
ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.take(100),
);
export const send = mutation
.input({ channelId: v.id("channels"), body: v.string() })
.mutation(async ({ ctx, args: { channelId, body } }): Promise<Id<"messages">> => {
if (!ctx.auth.userId) {
throw new LunoraError("UNAUTHORIZED", "not signed in");
}
return ctx.db.insert("messages", {
channelId,
authorId: ctx.auth.userId as Id<"users">,
body,
createdAt: Date.now(),
});
});
export const notifySlack = action.input({ channelId: v.id("channels") }).action(async ({ ctx, args: { channelId } }) => {
// A public action is callable by anyone: gate it like a mutation (and check
// channel access too, if your app has per-channel membership).
if (!ctx.auth.userId) {
throw new LunoraError("UNAUTHORIZED", "not signed in");
}
const messages = await ctx.runQuery(api.messages.listByChannel, { channelId });
const response = await fetch(String(ctx.env?.SLACK_WEBHOOK_URL), { method: "POST", body: JSON.stringify(messages) });
if (!response.ok) {
throw new Error(`Slack webhook failed: ${String(response.status)}`); // fetch resolves on 4xx/5xx
}
});

Templates also map #lunora/* to ./lunora/* in package.json imports, so #lunora/_generated/server.js works from anywhere in the project.

  • Pick the right kind. Reactive read → query. Transactional write →

mutation. External I/O (fetch, third-party SDKs, calling other functions) → action. An action has a ctx.db, but its own writes are not transactional: each autocommits as it runs, so a later throw rolls nothing back. Reach data through ctx.runQuery / ctx.runMutation — a mutation called that way runs in the same all-or-nothing transaction a top-level one gets, so put every write that has to land together in one mutation rather than sequencing several from the action.

  • `internal*` variants (internalQuery, internalMutation,

internalAction) are not exposed to clients — use them for server-only logic called from actions, crons, or other functions.

  • Throw `LunoraError(code, message?, options?)` from lunorash/server for

expected failures ("UNAUTHORIZED", "NOT_FOUND", …); it serializes with its code to the client, where isUnauthorizedError and friends branch on it.

The ctx.db API

Reads:

ts
await ctx.db.get("table", id); // one row by id (or null)
ctx.db.query("t").withIndex("by_x", (q) => q.eq("x", v)); // indexed query
.order("asc" | "desc") // by the active index (or _creationTime)
.filter((doc) => …) // JS predicate, applied after the index range
.collect(); // all matching rows
.take(n); // first n rows
.first(); // first row or null
.unique(); // the one match, null if none; throws if more than one
.paginate({ numItems, cursor }); // { page, isDone, continueCursor }

Writes (mutations, and autocommitting in actions):

ts
await ctx.db.insert("t", { ...fields }); // returns the new Id
await ctx.db.patch(table, id, { field: next }); // shallow-merge update
await ctx.db.replace(table, id, { ...allFields }); // full overwrite
await ctx.db.delete(table, id);

Prefer withIndex over .filter: a .filter(...) without a covering index scans the whole table (advisor: filter_without_index), and a bare .collect() grows with the table and with every subscriber's payload (unbounded_collect). Constrain with .withIndex, cap with .take(n), or paginate.

Pagination

A paginated query takes a paginationOpts arg and returns .paginate(...) as-is; the client hook (usePaginatedQuery, see lunora-realtime) supplies it:

ts
// `v.any()` would type the arg `unknown`, which `.paginate()` rejects. The hook
// also sends `endCursor`; an undeclared key is stripped, so declare it. Bound
// `numItems` too: it is caller input and sets the read limit.
const cursor = v.optional(v.union(v.string(), v.null()));
export const page = query
.input({ channelId: v.id("channels"), paginationOpts: v.object({ cursor, endCursor: cursor, numItems: v.number().int().min(1).max(100) }) })
.query(async ({ ctx, args: { channelId, paginationOpts } }) =>
ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.paginate(paginationOpts),
);
Following foreign keys: ctx.db.related

Every v.id("table") column is an edge in a graph your schema already declares. ctx.db.related walks it, so "this customer's tickets, and those tickets' messages" is one call instead of a hand-written chain of withIndex lookups.

ts
const { continueCursor, isDone, nodes } = await ctx.db.related(
{ table: "customers", id: customerId }, // or a row you already loaded
{ depth: 2, direction: "in", edges: ["tickets.customerId", "messages.ticketId"], limit: 50 },
);
for (const node of nodes) {
node.table; // "messages"
node.document; // the row itself
node.depth; // 2
node.score; // 0.5 — 1 at depth 1, halving per hop
node.path; // ["tickets.customerId", "messages.ticketId"]
node.pathIds; // ids along the way, start included
}
  • Edge names are `"<table>.<column>"`. edges restricts the walk to the

named ones; a name the schema does not declare is refused, not ignored.

  • `direction` — "out" follows the ids this row holds, "in" the rows that

point at it, "both" (the default) does both.

  • `depth` defaults to 1, max 4. `limit` defaults to 50, max 200.

Both caps refuse rather than clamp, so a probe for the ceiling just errors.

  • Only a column is an edge: a bare v.id(...), v.optional(v.id(...)) or

v.array(v.id(...)). An id nested in a v.object / v.union / v.record is not. An array FK is followed outward only.

  • It is an ordinary read — RLS, column masks, soft delete, .global()

routing and reactivity all apply, because every hop goes back through ctx.db. Under a .rls("required") schema each hop gets exactly the verdict a direct read of that table would, so declare a read policy for every table the walk can reach — or narrow it with edges.

  • Index the foreign keys. Each inward hop is a WHERE fk IN (…) read, and

unindexed it scans — see the lunora-performance-audit skill for the cost model and the traversal caps.

Other ctx capabilities

Always available:

  • ctx.auth — the resolved session (ctx.auth.userId, null when anonymous).
  • ctx.secrets — Cloudflare Secrets Store.
  • ctx.ip — the caller's CF-Connecting-IP; undefined off Cloudflare.
  • ctx.runQuery (all kinds), ctx.runMutation (mutations, actions),

ctx.runAction (actions) — call another function by its api.* / internal.* reference.

  • ctx.log, ctx.metrics, ctx.span / ctx.trace — logging, metrics, the

current wide-event span and a scoped tracing helper.

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 95 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
5

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/anolilab/lunora/lunora-functions/badge)](https://verified-skill.com/skills/anolilab/lunora/lunora-functions)

Something wrong with this skill or its page? Tell us.