Files, presence & broadcast
const file = await db.$files.upload(blob, { path: "avatars/me.png" });const bytes = await db.$files.download("avatars/me.png");const all = await db.$files.list({ prefix: "avatars/" });await db.$files.delete("avatars/me.png");File bytes live in the FILES R2 bucket. Files of any size upload: past 16 MiB, upload sends the file as an R2 multipart upload in 16 MiB parts, and abandons it if a part fails. The file is listed once the last part is in. Their index, with who uploaded each file, is written through the transaction log, so a restore keeps it.
File access
Section titled “File access”Who may read, write and delete each file is set in the schema. Without rules: read member, write and delete editor. Each rule is a role(), a fileCheck() over the caller and the file ({ path, size, contentType, uploadedBy }, and for a write the file it replaces as existing), or attachedTo(model, path => id), which makes a file follow its row: readable by whoever can read the row, writable and deletable by whoever may update it.
export default schema({ task, project }, { files: { read: attachedTo("task", (path) => path.split("/")[1]), // tasks/<id>/... write: attachedTo("task", (path) => path.split("/")[1]), delete: fileCheck(({ auth, file }) => file.uploadedBy === auth.sub), },});The workspace decides each operation before any byte moves, and list() returns only the files the caller may read. A file the caller may not read answers 404, like one that does not exist. A rule that throws denies, and an editor’s role does not override a rule.
Presence
Section titled “Presence”Presence is ephemeral and rides the workspace socket:
const room = db.$presence("board:" + boardId);room.set({ name, color });room.cursor({ x, y });const stop = room.subscribe((peers) => render(peers));
// When leaving the room:room.leave();A presence room also exposes drag() and clearDrag() for drag state.
Broadcast
Section titled “Broadcast”Broadcast is a fire-and-forget message between the clients of a workspace, on the same socket:
const board = db.$broadcast<{ kind: "ping"; at: number }>("board:" + boardId);const stop = board.subscribe((message, from) => flash(from.userId, message));board.send({ kind: "ping", at: Date.now() }); // false (dropped) while offlineA refused topic subscription reports through the optional onError callback on subscribe. A message reaches every other client listening on the topic, with the sender’s client id and user id; your own messages are not echoed back. Nothing is stored or replayed, and a send while the socket is down is dropped rather than delivered late. Listening clients rejoin their topics after a reconnect. Messages of any size go through: a large one travels in parts the workspace relays as they come, and the receiving client joins them.
By default any member of the workspace may listen and send. Decide per topic on the Worker:
import { defineTapeDB, WORKSPACE_ROLE_LEVELS } from "tapedb/worker";import schema from "./tapedb.schema";
defineTapeDB({ schema, topics: { authorize: ({ auth, topic, action }) => !topic.startsWith("admin:") || auth.role >= WORKSPACE_ROLE_LEVELS.ADMIN, },});React bindings: usePresence and useBroadcast in React.