Coline Docs
Sites

Data and rules

Collections, visitor rules, the site browser client, live data and site tools.

A site's data is the app's records. Visitors reach it two ways: directly, through collections whose rules let them, and through the tools the site lists.

Collection rules

Declare a collection under collections to open it to visitors. Each action has a rule:

collections: {
  // Anyone reads; any visitor posts; only the author edits or deletes.
  notes: { access: { read: "public", create: "visitor", update: "owner", delete: "owner" } },
  // Only people who signed in.
  members: { access: { read: "signedIn", create: "signedIn" } },
  // Each visitor sees only their own entries.
  diary: { access: { read: "owner", create: "visitor", update: "owner", delete: "owner" } },
  // Not declared, or declared without access: visitors see nothing.
  signups: {},
},
RuleWho
publicAnyone who opens the site
visitorAny guest or signed-in visitor
signedInVisitors who signed in
ownerThe visitor who created the record
teamNobody on the site. Workspace members and the app's own logic only (the default)

create can't be owner. Opening a collection to visitors needs the storage.app permission and a site. Widening a rule later, such as owner to public, asks workspace admins to approve the new version, like any new permission.

Members and the app's logic don't use these rules. They keep the per-record access model described in capabilities.

Record schemas

Give a collection a zod schema and Coline checks every visitor write against it. A record that doesn't match fails with record_invalid and changes nothing.

const note = z.object({ text: z.string().min(1).max(200), done: z.boolean().default(false) });

collections: {
  notes: { access: { read: "owner", create: "visitor", update: "owner", delete: "owner" }, schema: note },
},

The schema becomes JSON Schema in the manifest, up to 20 kB per collection. Updates are checked on the merged record. Writes from site tools and the app's own logic aren't checked: validate those with the tool's input schema. The testing harness enforces schemas the same way.

The schema also types the collection in the page. See typed client.

React hooks

@colineapp/sdk/site/react has hooks for the common cases. Each runs as the current visitor.

import { useCollection, usePaginatedCollection, useTool, useUpload, useSession, SignInButton } from "@colineapp/sdk/site/react";

const notes = useCollection<Note>("notes", { orderBy: "createdAt", order: "desc", limit: 50 });
// notes.records, notes.status, notes.insert(), notes.update(), notes.delete(), notes.refresh()

const feed = usePaginatedCollection<Post>("posts", { orderBy: "createdAt", order: "desc" }, 20);
// feed.records, feed.hasMore, feed.loadMore()

const add = useTool<{ text: string }, { id: string }>("notes.add");
// await add.run({ text }), add.running, add.error, add.output

useCollection is live: it refreshes when the site's records change, and its writes show at once. SiteErrorBoundary catches render errors with a fallback(error, retry).

For pages with more than one screen, Router, Routes, Route, Link, useParams and navigate route on the client without reloading. Every client path must also be listed in surfaces.site.routes so Coline serves it with its head.

<Router>
  <Routes>
    <Route path="/"><Home /></Route>
    <Route path="/notes/:id"><NoteView /></Route>
    <Route path="*"><NotFound /></Route>
  </Routes>
</Router>

Typed client

createSiteHooks<typeof app>() returns the same hooks typed from the app: collection names, record shapes from each schema, tool names, inputs and outputs from defineTool. Without React, createSiteClient<typeof app>() from @colineapp/sdk/site does the same for collection(name) and tools.invoke(name, input).

import { createSiteHooks } from "@colineapp/sdk/site/react";
import type app from "../app.config";

const { useCollection, useTool } = createSiteHooks<typeof app>();
const notes = useCollection("notes");          // records typed from the schema
const add = useTool("notes.add");              // add.run({ text }) is type-checked

Always use import type for the app config. It keeps the app's server code out of the page bundle.

The browser client

@colineapp/sdk/site is what the hooks are built on. Every call goes to the site's own /_coline API and runs as the current visitor.

import { site, liveCollection, getSession, SiteError } from "@colineapp/sdk/site";

const notes = site.collection<Note>("notes");
await notes.insert({ text: "Hello" });
await notes.query({ where: { text: { contains: "hello" } }, orderBy: "createdAt", order: "desc", limit: 20 });
await notes.get(id);
await notes.count({ done: false });
await notes.update(id, { text: "Edited" });
await notes.delete(id);

where takes a value for equality, or eq, ne, gt, gte, lt, lte, contains, startsWith, in, notIn and exists, combined with and and or. query returns { records, nextCursor }; pass cursor for the next page. Records carry id, data, createdBy, createdAt, updatedAt and version.

Visitors can't use key-value storage, bulk writes (insertMany, updateMany, deleteMany, batch), set record access, or call any other capability. Those calls fail with unavailable_to_visitors; do that work in a site tool.

Live data

liveCollection keeps a view current, and is what useCollection uses. It refreshes when the site's records change and every 5 seconds as a fallback, and applies your own writes immediately.

import { liveCollection } from "@colineapp/sdk/site";

const view = liveCollection<Note>("notes", { orderBy: "createdAt", order: "desc", limit: 50 });
await view.start();
const unsubscribe = view.subscribe(() => render(view.getSnapshot()));

The snapshot has records, status (loading, ready or error), pending and error. view.insert, view.update and view.delete show the change at once and confirm it with the server. Call stop() when the view is no longer shown.

Errors

Failed calls throw SiteError with a stable code, an HTTP status and a message that's safe to show:

CodeMeans
record_forbiddenThe collection's rule doesn't allow this visitor
record_invalidThe record doesn't match the collection's schema
record_not_foundNo such record, or the visitor can't see it
unavailable_to_visitorsVisitors can't call this operation
site_tool_not_allowedThe tool isn't listed in surfaces.site.tools
sign_in_requiredThe site's access mode needs a signed-in visitor
approval_requiredThe site admits approved visitors only, and this one isn't yet
site_data_fullThe site's records reached the plan's data limit
SITE_OVER_LIMITThe site hit its daily limit
network_errorThe request didn't reach the site

Site tools

A tool listed in surfaces.site.tools is the site's server code. Visitors call it with site.tools.invoke(name, input) and get back { output, card }.

  • It runs on Coline as the app, with the app's granted permissions, so it can write to team collections, notify members or call other capabilities.
  • A visitor can never cause more than a write: a tool declared destructive runs at write, and destructive calls inside it fail.
  • ctx.actor is the visitor: { kind: "visitor", visitorId, signedIn, name, email }. Records the tool creates belong to that visitor, so owner rules work for them.
  • Visitors see a generic message when a tool fails. The real error is in npx coline-app logs.

Validate input with the tool's schema and check ownership in the tool. Hiding a button is not an access check.

Test the rules

@colineapp/sdk/testing enforces the same rules in memory:

import { createTestWorkspace } from "@colineapp/sdk/testing";
import app from "./app.config";

const workspace = createTestWorkspace(app);
await workspace.invokeSiteTool("guestbook.sign", { name: "Ada", message: "Hi" }, { visitorId: "vis_ada" });

const bob = workspace.asVisitor({ visitorId: "vis_bob" }).collection("entries");
await expect(bob.delete(id)).rejects.toMatchObject({ code: "record_forbidden" });

Pass { signedIn: true } in the visitor to test signedIn rules, and grantedPermissions to createTestWorkspace to test what a site can do before it's claimed. Visitor writes are checked against each collection's schema, and workspace.request(endpointId, { visitor }) calls an endpoint as a visitor.

On this page