# 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

```ts
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

```ts
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.

```ts
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 `localStorage` under `data:local:<key>`. Signed in, under `data:<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 `GET` for 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.`

```http
GET /api/apps/<projectId>/data
```

```json
{ "items": { "habits": { "value": [ … ], "updated_at": "2026-09-20T10:00:00.000Z" } } }
```

```http
PUT /api/apps/<projectId>/data
Content-Type: application/json

{ "key": "habits", "value": [ … ] }
```

```json
{ "updated_at": "2026-09-20T10:00:00.000Z" }
```

```http
DELETE /api/apps/<projectId>/data?key=habits
DELETE /api/apps/<projectId>/data?all=1
```

```json
{ "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.`

```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

```ts
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>" }`.

```http
GET /api/apps/<projectId>/records/reviews?mine=1&limit=100&before=2026-09-01T00:00:00.000Z
```

```json
{
  "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.`

```http
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.`

```http
PATCH /api/apps/<projectId>/records/reviews?id=<recordId>
Authorization: Bearer <token>

{ "data": { "text": "edited" } }
```

Merges `data` into the record and returns `{ "record": { … } }`.

```http
DELETE /api/apps/<projectId>/records/reviews?id=<recordId>
Authorization: Bearer <token>
```

```json
{ "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's `id` is the team id. Creating one also inserts its creator into `team_members` as `owner`.
- `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. On `POST` the 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 }`:

- `POST` without 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.`
- `GET` returns only records whose `team` is one of the caller's teams (for `teams`, 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`.
