Data and files
The flat key/value store, key conventions for ownership, pagination, and the file store.
db: one flat store
Each app has one flat key/value store. It has no scopes or namespaces and no built-in per-user partition. Data written by earlier deploys is still there.
const notes = db.collection("notes");
await notes.put("key", { title: "hi" });
await notes.get("key"); // null when absent
await notes.delete("key");
await notes.query()
.where("status", "=", "open")
.order("created_at", "desc")
.page(1, 100);You own partitioning
"Per-user" is a key convention plus a check you write:
const key = `${ctx.user!.id}:${recordId}`; // partition
const row = await notes.get(key);
if (!row) return c.json({ error: "not found" }, 404); // and the checkPick a convention and keep it everywhere:
| Ownership | Key convention |
|---|---|
| Shared across everyone | "<id>" |
| Per user | "<userId>:<id>" |
| Per role or team | "role:<roleUuid>:<id>" |
| A singleton (settings) | "settings" |
Store the owner inside the record too, so a read can be verified without parsing the key:
await db.collection("notes").put(`${user.id}:${id}`, {
id, owner: user.id, title, body, updated_at: new Date().toISOString(),
});Always paginate
query() returns one page (default 100, max 500). A large collection silently loses its tail
unless you paginate.
async function all(name: string) {
const out = [];
for (let page = 1; ; page++) {
const rows = await db.collection(name).query().page(page, 500);
out.push(...rows);
if (rows.length < 500) break;
}
return out;
}What the store is not
The store does no joins, transactions, or aggregations. Keep anything large (analytics, history, reporting) in a warehouse and read it through a saved query. The key/value store filters, orders, and pages; it does not aggregate.
There are also no embeddings and no vector search.
Files
Your backend function holds the bytes. Uploads are one direct call and downloads are a
streamed Response. There is no presign step.
await files.put("report.pdf", bytes, "application/pdf"); // string | ArrayBuffer | Blob | stream
const resp = await files.get("report.pdf"); // Response | null
await files.list();
await files.delete("report.pdf");Upload through your own route:
app.put("/api/files/:name", async (c) => {
const body = await c.req.arrayBuffer();
const meta = await files.put(c.req.param("name"), body, c.req.header("content-type"));
return c.json(meta);
});Handing files to the frontend
const one = await files.url("report.pdf"); // { name, url, expires_in }
const batch = await files.urls(["a.png", "b.png", "c.png"]); // { items, missing }Use urls() for more than one file. A backend invocation has a limited subrequest budget
(about 100), and a loop of url() is exactly what uses it up.
// ❌ N subrequests
const urls = await Promise.all(names.map((n) => files.url(n)));
// ✅ one
const { items, missing } = await files.urls(names);missing is data rather than an error. Show those names as unavailable instead of failing
the page. Cap: 100 names per call (422 above it).
AI and files
Never feed a file into the backend LLM. File contents, file URLs, and payloads derived
from files are always a managed agent's job. The backend function may put,
get, list, and delete files. Anything that must read, understand, extract, summarize,
transform, or generate a file goes to an agent with app_files and its sandbox.
Inspecting live data
railcode app kv and railcode app files read and write the deployed app's stores. That is
the fastest way to confirm what an app persisted, seed demo records, or clear a collection
between tests.
railcode app kv collections
railcode app kv list <collection> [--query '[["stage","eq","won"]]'] [--limit 20]
railcode app kv get <collection> <key>
railcode app kv set <collection> <key> '{"n":1}' # or --file value.json
railcode app kv delete <collection> <key>
railcode app kv drop <collection> --yes
railcode app files list
railcode app files download <name> [--out <path>]
railcode app files upload <path> [--name <remote-name>]These require an app owner grant or an org admin with app:manage_any. An editor gets a
403, because the editor tier only grants deploy rights.
They talk to the deployed app, never to the local emulation of railcode dev. set,
delete, drop, and upload are writes against live tenant data. Match the shape the app
itself writes: create one record through the UI and read it back with kv get, rather than
inventing fields.