Building apps
Saving data
Per-account data with useStore and useSynced, shared collections with useShared, and the routes and limits behind them.
Two kinds of data
| Kind | Hook | Who sees it | Where it goes |
|---|---|---|---|
| Per-account | useStore, useSynced (src/lib/store.ts) | Only the account that saved it | app_data on Ripping, plus a copy in this browser |
| Shared | useShared (src/lib/shared.ts) | Other people, by the collection's rule | app_records on Ripping |
Never write app data to localStorage directly. The store owns that.
Per-account data
useSynced
function useSynced<T>(key: string, initial: T): [T, (next: T | ((prev: T) => T)) => void, boolean];
One saved value: settings, a flag, a draft. The third item, ready, turns true once the server's copy is in (at once when signed out).
useStore
type WithId = { id: string; createdAt: string };
function useStore<T extends WithId>(key: string, seed: T[] = []): {
items: T[]; ready: boolean;
add: (data: Omit<T, "id" | "createdAt">) => T;
update: (id: string, patch: Partial<T>) => void;
remove: (id: string) => void;
undo: () => void; canUndo: boolean;
exportJson: () => string; importJson: (json: string) => void;
};
A collection built on useSynced. add fills in id (a UUID) and createdAt and puts the record first. undo steps back through the last 20 states. importJson replaces the whole list with a parsed array.
function exportAllData(): Record<string, unknown>; // every key for this account (or this browser when signed out)
function clearAllData(): Promise<void>; // empties every array-valued key, here and on every device
How syncing works
- Signed out, values live in
localStorageunderdata:local:<key>. Signed in, underdata:<userId>:<key>, and each change is pushed to Ripping after a 500 ms debounce. - On first use of a key per session the server snapshot is fetched once (one
GETfor all keys). The server's copy wins unless this browser has unsynced changes for that key; a key the server has never seen is sent up with its seed. - A push that fails stays marked unsynced and is sent again when the browser comes back online or the store is next scoped to that account.
- Last write wins. There is no merging.
auth:change(sign in, sign out) rescopes the store and refetches.
The data route
/api/apps/<projectId>/data, always with Authorization: Bearer <token>. Without a valid session every method returns 401 Sign in again.
GET /api/apps/<projectId>/data
{ "items": { "habits": { "value": [ … ], "updated_at": "2026-09-20T10:00:00.000Z" } } }
PUT /api/apps/<projectId>/data
Content-Type: application/json
{ "key": "habits", "value": [ … ] }
{ "updated_at": "2026-09-20T10:00:00.000Z" }
DELETE /api/apps/<projectId>/data?key=habits
DELETE /api/apps/<projectId>/data?all=1
{ "ok": true }
| Limit | Value | Message |
|---|---|---|
| Key | letters, digits, . _ : -, 1–100 characters | 400 Invalid key. |
| One value | 512,000 bytes of JSON | 413 That's too much data for one collection. |
| Keys per user per app | 100 | 413 Too many collections. |
| All values per user per app | 5,000,000 bytes | 413 Your account is out of storage space. |
GET | 120 per minute per user | 429 Too many requests. |
PUT | 240 per minute per user | 429 Too many requests. |
Other failures: 400 Missing value., 400 bad json, 500 Could not load your data., 500 Could not save., 400 Could not delete. The skeleton never calls DELETE; clearAllData writes empty arrays instead so the change reaches every device.
Shared data
Declaring collections
src/lib/collections.ts is read by Ripping to enforce the rules. A collection that is not declared there cannot be read or written: the records route answers 404 Unknown collection "x". Declare it in src/lib/collections.ts.
export type Rule = "public" | "members" | "inbox";
export const COLLECTIONS = { reviews: "public", bookings: "inbox" } as const satisfies Record<string, Rule>;
The server also accepts a fourth rule, "team", which the SaaS scaffold declares by widening Rule to include it. Names match ^[A-Za-z][A-Za-z0-9_-]{0,63}$. Ripping parses the file with a regular expression (comments stripped), so keep the declaration a plain object literal of string values. Rules are cached for 30 seconds on each server instance after a change.
| Rule | Read | Add | Edit or delete |
|---|---|---|---|
public | Anyone, signed in or not | Signed-in people | The record's owner, or an app admin |
members | Signed-in people, everything | Signed-in people | The record's owner, or an app admin |
inbox | Each signed-in person sees their own; admins see all | Signed-in people, and visitors without an account | The record's owner, or an app admin |
team | Members of the team named on the record | Members of that team | Any member of the team; the team itself and its member list need an owner or admin |
useShared
type SharedMeta = { id: string; ownerId: string | null; ownerName: string | null; mine: boolean; createdAt: string; updatedAt: string };
type Shared<T> = T & SharedMeta;
function useShared<T extends object>(collection: CollectionName, opts: { mine?: boolean; refreshMs?: number } = {}): {
items: Shared<T>[]; ready: boolean; error: string | null;
add: (data: T) => Promise<Shared<T>>;
update: (id: string, patch: Partial<T>) => Promise<Shared<T>>;
remove: (id: string) => Promise<void>;
refresh: () => Promise<void>;
};
CollectionName is keyof typeof COLLECTIONS, so an undeclared name is a type error. The list is fetched on mount, every refreshMs (default 20,000 ms), and on auth:change. update and remove apply at once and roll back by refreshing when the server refuses. Each rejected call throws an Error with the server's message, or Request failed (<status>).
The records route
/api/apps/<projectId>/records/<collection>. A Bearer token is optional; without one the caller is a visitor. Every error is { "error": "<message>" }.
GET /api/apps/<projectId>/records/reviews?mine=1&limit=100&before=2026-09-01T00:00:00.000Z
{
"records": [
{ "id": "…", "data": { "stars": 5, "text": "…" }, "ownerId": "…", "ownerName": "Ada", "mine": true, "createdAt": "…", "updatedAt": "…" }
],
"next": "2026-08-30T12:00:00.000Z"
}
Newest first. limit is 1–200 (default 100); next is the createdAt to pass as before for the next page, or null. mine=1 needs a session, as does any rule but public: 401 Sign in to see this. Rate: 240 per minute per user (or per IP for visitors): 429 Too many requests.
POST /api/apps/<projectId>/records/reviews
Authorization: Bearer <token>
Content-Type: application/json
{ "data": { "stars": 5, "text": "…" } }
Returns 201 { "record": { … } }. A body without a data object: 400 Send { data: { … } }. A visitor posting to anything but an inbox: 401 Sign in to add this.
PATCH /api/apps/<projectId>/records/reviews?id=<recordId>
Authorization: Bearer <token>
{ "data": { "text": "edited" } }
Merges data into the record and returns { "record": { … } }.
DELETE /api/apps/<projectId>/records/reviews?id=<recordId>
Authorization: Bearer <token>
{ "ok": true }
PATCH and DELETE fail with 401 Sign in to change this., 400 id required, 404 Not found., or 403 Only the person who added this (or an admin) can change it.
| Limit | Value | Message |
|---|---|---|
| One record (signed in) | 64,000 bytes of JSON | 413 That record is too large. |
| One record (visitor) | 8,000 bytes | 413 That record is too large. |
| Records per app, all collections | 50,000 | 413 This app is out of space for shared records. |
POST, signed in | 60 per minute per user | 429 Too many requests. |
POST, visitor | 10 per minute per IP per app | 429 Too many requests. |
| Collection name | ^[A-Za-z][A-Za-z0-9_-]{0,63}$ | 400 Invalid collection name. |
Anonymous inbox posting
An inbox collection accepts a POST with no token: a contact form, a newsletter box, a booking request on a site with no accounts. The record is saved with no owner, so only the app's admins ever read it (on the project's Data tab on Ripping, or through GET as an admin). Every workspace owner is emailed each submission as it arrives, subject New <collection> on <app name>, with the fields as sent; when data.email is a valid address the email's reply-to is set to it. The email is best effort and never blocks the save.
The website scaffold's submitForm(inbox, data) in src/lib/forms.ts wraps this call and resolves to an error message or null.
The team rule
Multi-tenant apps use team. Two collections carry membership:
teams: a record'sidis the team id. Creating one also inserts its creator intoteam_membersasowner.team_members:{ team, email, role }. Owners and admins add rows; that is the invite. Membership is by email (case-insensitive), so it applies the moment that address signs in. OnPOSTthe role is coerced to"admin"or"member"; a row needs a valid email (400 A member needs an email address.).
Every other team collection's records carry { team }:
POSTwithout it:400 A team record needs { team }.; to a team you are not in:403 You are not a member of that team.- Adding a member without owner or admin role:
403 Only the team's owner or admins can add members. - Changing a team or its member list without that role:
403 Only the team's owner or admins can change that.A member may always remove their own row. - Moving a record to another team:
400 A record can't move to another team. GETreturns only records whoseteamis one of the caller's teams (forteams, the teams they belong to). Someone in no team gets an empty list.
The SaaS scaffold's useTeam() and useTeamRecords(collection) in src/lib/team.ts wrap this: they create a personal team on first sign-in, remember the current one in the synced key team.current, and fill in team on add.