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: {},
},| Rule | Who |
|---|---|
public | Anyone who opens the site |
visitor | Any guest or signed-in visitor |
signedIn | Visitors who signed in |
owner | The visitor who created the record |
team | Nobody 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.outputuseCollection 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-checkedAlways 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:
| Code | Means |
|---|---|
record_forbidden | The collection's rule doesn't allow this visitor |
record_invalid | The record doesn't match the collection's schema |
record_not_found | No such record, or the visitor can't see it |
unavailable_to_visitors | Visitors can't call this operation |
site_tool_not_allowed | The tool isn't listed in surfaces.site.tools |
sign_in_required | The site's access mode needs a signed-in visitor |
approval_required | The site admits approved visitors only, and this one isn't yet |
site_data_full | The site's records reached the plan's data limit |
SITE_OVER_LIMIT | The site hit its daily limit |
network_error | The 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
teamcollections, notify members or call other capabilities. - A visitor can never cause more than a
write: a tool declareddestructiveruns atwrite, and destructive calls inside it fail. ctx.actoris the visitor:{ kind: "visitor", visitorId, signedIn, name, email }. Records the tool creates belong to that visitor, soownerrules 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.