# TinyCld docs (full) > Complete developer documentation for extending @tinycld/core and authoring feature packages. All pages from https://tinycld.org/docs are inlined below in reading order. For the slim index version with links to individual pages, see https://tinycld.org/llms.txt. ========================================= # Overview ========================================= ----------------------------------------- ## Documentation Source: /docs/index.md Description: Pick your path - operate a TinyCld instance, or build packages on top of it. ----------------------------------------- TinyCld serves two audiences and the docs are split to match. ## Operate You want to run TinyCld for your team — set up a server, get a domain, manage users, back up your data. - **[Install TinyCld](/docs/installation)** — clone, boot, and run the first-time setup wizard. ## Build You want to write a package for TinyCld, or contribute to one of the existing ones. Packages are independent git repos that join a pnpm workspace as members. - **[Getting started](/docs/getting-started)** — the layout of the ecosystem, how the workspace, app shell, core, and feature packages relate, and how a fresh checkout runs without any features. - **[Adding a package](/docs/adding-a-package)** — bring an existing package into the workspace with `npx @tinycld/bootstrap --assemble-only --with ` + `pnpm install`. - **[Creating a package](/docs/creating-a-package)** — scaffold a new package from scratch with `npx @tinycld/bootstrap --new `. - **[Conventions](/docs/conventions)** — the rules every package follows: collection naming, data access, migrations, state, imports, and style. - **[Anatomy of a package](/docs/anatomy/manifest)** — every directory, manifest field, and file a package can contain. - **[Tasks](/docs/tasks/query-data)** — how to do common things inside a package: query data, mutate, build forms, manage UI state. - **[FAQ](/docs/faq)** — why the ecosystem is shaped this way: the per-developer workspace, independent package repos, and no committed lockfile. - **[Internals](/docs/generator)** and **[reference](/docs/reference/manifest-schema)** — the generator, Go server wiring, and exhaustive field/CLI tables. ----------------------------------------- ## Install TinyCld Source: /docs/installation.md Description: Host TinyCld yourself on a small Linux VM. One container, one compose file, ready in about 15 minutes. ----------------------------------------- TinyCld is one Docker container. You run it on any Linux server with Docker installed; it can get an HTTPS certificate from Let's Encrypt automatically (opt-in), stores its data in a single directory, and updates with `docker compose pull && docker compose up -d`. The actual install is the same on every host: ```sh mkdir tinycld && cd tinycld curl -O https://raw.githubusercontent.com/tinycld/tinycld/main/docker-compose.yml # edit docker-compose.yml: set PRIMARY_DOMAIN, and AUTOCERT_ENABLED: "true" for HTTPS docker compose up -d ``` What changes between hosting providers is how you get the server itself. The sections below walk through the recommended providers. If you already have a Linux box with Docker, skip to [On your own server](#on-your-own-server). ## Before you start You need: - **A domain name** - TinyCld uses it to get an HTTPS certificate. Any domain works. If you don't have one, register a cheap `.com` or `.xyz` at a registrar like Porkbun or Cloudflare (~$10/year). - **15 minutes.** You do not need: - A PaaS account, a deploy platform, a build step. The image is published to GitHub Container Registry; you pull it, you run it. - Kubernetes. When autocert is enabled (`AUTOCERT_ENABLED: "true"` + `PRIMARY_DOMAIN`), TinyCld terminates TLS itself via Let's Encrypt and binds `:80`/`:443` directly — no reverse proxy needed. Without autocert it serves plain HTTP on `:7090`, which is what you'd put behind your own NGINX/Caddy/Cloudflare if you prefer. ## What the container needs - **1 CPU, 2 GB RAM** is comfortable for a small team. 1 GB works if it's just you. - **20 GB disk** to start. The image itself is ~600 MB compressed (~2.5 GB on disk, since it bundles the Go toolchain and Node runtime for the in-app package installer). Email and file uploads add up; plan to grow. - **Ports 80 and 443** open to the internet, when running with `AUTOCERT_ENABLED: "true"` + `PRIMARY_DOMAIN` set. 80 is required for the Let's Encrypt ACME challenge; you cannot skip it. - **Port 7090** for plain-HTTP mode (autocert off — local demos or behind an external reverse proxy). Remap the host side to any port you like in `docker-compose.yml`. - **Ports 993 and 465** if you're running the mail package. If `dovecot` / `postfix` are already on the host using these ports, comment those mappings out of `docker-compose.yml` or remap them. - **Persistent storage** - the `pb_data/` directory on the host. Back it up. :::tip Back up `pb_data/` regularly. It contains the database, file uploads, and the server's private keys — so `tar -czf tinycld-backup.tgz pb_data/` covers everything stored locally. For a consistent snapshot, stop the container first (`docker compose down`), or use PocketBase's built-in backups (Dashboard → Settings → Backups), which set the app read-only while the archive is written. Copying `pb_data/` live can capture a database mid-write because of SQLite's write-ahead log. See PocketBase's [Going to production](https://pocketbase.io/docs/going-to-production/) guide. (Files offloaded to S3 live outside `pb_data/` — back those up separately.) ::: ## On your own server If you already have a Linux VM with Docker and a domain pointed at it: ```sh mkdir tinycld && cd tinycld curl -O https://raw.githubusercontent.com/tinycld/tinycld/main/docker-compose.yml ``` Open `docker-compose.yml` in your editor. To get HTTPS automatically via Let's Encrypt, set both: ```yaml filename="docker-compose.yml" environment: PRIMARY_DOMAIN: "tinycld.example.com" AUTOCERT_ENABLED: "true" ``` If you'd rather run plain HTTP on `:7090` (local demo, or behind your own reverse proxy), leave `AUTOCERT_ENABLED` unset and set `PRIMARY_DOMAIN` so the printed setup URL still points at your real host. Start it: ```sh docker compose up -d docker compose logs -f ``` Once the container's listening, visit `https://tinycld.example.com/a/setup` (or `http://:7090/a/setup` in plain-HTTP mode). Jump to [First-run setup](#first-run-setup). ## Recommended providers Each of these runs Docker well and has first-party guides for getting the engine installed on Ubuntu. Follow their setup, then return here and jump to [On your own server](#on-your-own-server). ### Hetzner Cheapest serious hosting in the world right now. A CX22 (2 vCPU, 4 GB RAM, 40 GB disk) is about €4/month and comfortably handles TinyCld for a small team. No ingress/egress charges up to 20 TB/month. US and EU data centers priced identically. tinycld.org itself runs on a Hetzner CPX11 (2 vCPU AMD, 2 GB RAM, 40 GB disk, ~€4.35/month) and handles the marketing site, docs, and a demo instance without breaking a sweat. Setup guide: [Docker CE on Hetzner Cloud](https://docs.hetzner.com/cloud/apps/list/docker-ce/). ### DigitalOcean The most familiar dashboard for VPS newcomers. A $6/month Basic Droplet handles TinyCld well. Setup guide: [Docker hosting on DigitalOcean](https://www.digitalocean.com/solutions/docker-hosting). :::warning Skip DigitalOcean's **App Platform** for TinyCld. App Platform ephemeralizes storage - your `pb_data` will vanish on every deploy. Use a regular Droplet. ::: ### Linode (Akamai) Notably patient support for beginners. The $5/month Nanode handles a solo TinyCld install fine; bump to $10/month for a team. Setup guide: [Docker guides on Linode Marketplace](https://www.linode.com/docs/marketplace-docs/guides/docker/). ## After the Docker engine is up Whichever provider you picked, once you have a Linux VM with Docker installed and your domain's A record pointed at its public IP, the rest is identical: 1. SSH into the VM. 2. Follow [On your own server](#on-your-own-server). ## Other providers TinyCld runs on any Linux host with Docker 20.10+. Providers not covered above that also work fine: - **Vultr** - similar to DigitalOcean pricing-wise; $6/month compute plans. - **Hetzner Storage Box or OVH VPS** - cheap, reliable for European users. - **Scaleway** - French provider with per-second billing. - **Fly.io** - platform-as-a-service, works but you'll need to mount a Fly volume at `/app/pb_data` and override the entrypoint. Harder than a plain VPS. - **AWS Lightsail** - closest AWS has to a simple VPS. $5/month plans exist. - **Bare metal or home lab** - works perfectly if you can port-forward 80 and 443. The process is always the same: get a Linux machine with a public IP, install Docker, add a DNS A record, run the compose file. ## First-run setup Once the container is running and your domain resolves, visit `https:///a/setup`. On first boot there's no admin account yet, so TinyCld generates a one-time setup token and prints it to the container logs: ```sh docker compose logs tinycld | grep -A 5 'First run' ``` You'll see something like: ``` ┌──────────────────────────────────────────────────────────────┐ │ First run setup, visit below URL to configure tinycld: │ │ │ │ https://tinycld.example.com/a/setup?token=a1b2c3d4e5f6... │ └──────────────────────────────────────────────────────────────┘ ``` Open the URL with the token. The wizard collects four fields: | Field | Default | Description | |-------|---------|-------------| | Application name | `tinycld` | Shows in emails and the admin UI. | | Email | - | Your superuser account email. | | Password | - | Your superuser account password. | | App URL | Current origin | The public URL, used in outgoing links. | After submitting, you're logged in as the superuser and land on the setup dashboard. Create your first organization, add packages, and you're done. :::warning The setup token is single-use and exists only in container memory. If you miss it, `docker compose down`, delete the `pb_data/` directory (this also deletes any data you've created), and restart to get a fresh token. For an already-initialized instance, go to `/a/setup` and sign in with the email and password you created. ::: ## Updates and backups ### Updating ```sh cd tinycld docker compose pull docker compose up -d ``` The container restarts in place. Downtime is typically under ten seconds. Migrations run automatically on boot. ### Backups The entire state is in `pb_data/` — the database, file uploads, and the server's private keys. (Files offloaded to S3 are the exception: they live in your bucket, not `pb_data/`, so back those up on the bucket side.) The safest snapshot is PocketBase's built-in backup: **Dashboard → Settings → Backups**, where you can take one on demand or set an automatic schedule. It briefly puts the app in read-only mode while it writes a ZIP, so the snapshot is always consistent and there's no downtime. Backups can also be stored directly in S3-compatible storage. See [API Backups](https://pocketbase.io/docs/api-backups/) to drive it from a script. A plain tarball also works, but only when the container is stopped — copying `pb_data/` while it runs can grab the database mid-write because of SQLite's write-ahead log. A nightly cron that quiesces first: ```sh 0 3 * * * cd /root/tinycld && docker compose stop && tar -czf "/root/backups/tinycld-$(date +\%F).tgz" pb_data/ && docker compose start ``` This incurs a few seconds of downtime each night. If that's not acceptable, use the built-in scheduled backup above instead. Either way, ship the archives off-site (rsync, rclone to S3/B2/Backblaze, or a managed backup tool). PocketBase's [Going to production](https://pocketbase.io/docs/going-to-production/) guide covers backup strategy in more depth. ### Restoring Stop the container, replace `pb_data/`, restart: ```sh docker compose down rm -rf pb_data tar -xzf tinycld-2026-04-20.tgz docker compose up -d ``` ## Next steps - **[Getting started](/docs/getting-started)** - orient yourself in the admin dashboard once you're logged in. - **[Adding a package](/docs/adding-a-package)** - install mail, contacts, calendar, drive. - **[Troubleshooting](/docs/troubleshooting)** - common issues. ----------------------------------------- ## Command line tool Source: /docs/command-line-tool.md Description: Download the tinycld CLI from your server, log in from a terminal, and script Drive, Mail, Boards, Contacts, Calendar, and comments. ----------------------------------------- Every TinyCld server builds its own copy of the `tinycld` command line tool, containing exactly the command groups for the packages installed on that server. A server with Drive and Mail offers `tinycld drive` and `tinycld mail`; a server without Mail has no `mail` command at all. ## Download and first run Open **Settings → Personal → About** in the app and find **Command line tools**. Download the build for your platform, then make it executable: ```sh chmod +x tinycld ``` The binaries are unsigned in this version. macOS blocks the first run until you clear the quarantine flag; Windows shows a SmartScreen warning (choose **More info → Run anyway**). ```sh xattr -d com.apple.quarantine ./tinycld ``` ## Log in ```sh tinycld auth login your-server.example.com ``` The tool prints a short one-time code and opens your browser. Confirm the code matches and approve the device. The login is a scoped, revocable grant — it appears under **Settings → Personal → Connected apps**, and revoking it there signs that terminal out remotely on its next request. `tinycld auth status` shows who you are logged in as and which scopes the grant carries. `tinycld auth logout` revokes the grant and forgets the credentials. ## Working with more than one server Each server you log into is saved as a **context**. Log in against a second host to add one: ```sh tinycld context list # the current context is marked with * tinycld context use work tinycld context add lab https://lab.example.internal tinycld context remove lab # also deletes its stored credentials ``` Any command takes `--context ` to run against a different server just once, without switching. ## Where your credentials live Contexts are saved in `~/.config/tinycld/config.toml` (mode `0600`; set `$TINYCLD_CONFIG_DIR` to move it, and Windows uses your user config directory instead). **Tokens are not in that file.** They go to the operating system's keychain — macOS Keychain, Windows Credential Manager, or libsecret — under the service name `tinycld`. If no keychain is available the tool falls back to `~/.config/tinycld/credentials/.json` at mode `0600` and warns you that it has done so. Access tokens refresh automatically. If the refresh token itself has expired or been revoked, commands fail with `authentication expired — run tinycld auth login`. ## Work with your files ```sh tinycld drive ls /Projects -l tinycld drive tree /Projects --depth 2 tinycld drive put report.pdf /Projects tinycld drive put ./photos /Albums --recursive tinycld drive get /Projects/report.pdf . tinycld drive search "roadmap" ``` Paths work like shell paths from `/`. Anywhere a path is accepted you can also pass `id:` to skip path resolution. Uploads go through the same server rules as the app — name de-duplication, quotas, sharing — and downloading a folder produces a zip. `mkdir`, `mv`, `cp`, `rm`, `trash`, `restore`, and `usage` round out the basics. Sharing, links, versions, and PDF export are here too: ```sh tinycld drive share /plan.docx --user ada@example.com --role editor tinycld drive link create /plan.docx --role viewer --expires 2026-12-31T23:59:59Z tinycld drive versions /plan.docx --snapshot --label "before rewrite" tinycld drive export /plan.docx report.pdf ``` `share` grants access to people who already have an account on the server; `link` manages public links, including revoking them. ## Search and send mail ```sh tinycld mail search "invoice" --from billing --has-attachment tinycld mail read tinycld mail send --to "Ada " --subject "Report" --body-file - < notes.txt tinycld mail status ``` Search flags mirror the app's advanced search. `read` prints a whole thread as plain text (`--no-mark` leaves your unread counts alone), `download` saves attachments, and `status` reports unread counts per mailbox. Replies, drafts, and filing work from the terminal as well: ```sh tinycld mail reply --all --body "Thanks all." tinycld mail draft --to ada@example.com --subject Later --body wip tinycld mail draft send tinycld mail archive ... tinycld mail label add Work ``` `--from` picks which of your addresses — including aliases — a message is sent as. Folder moves, labels, stars, and read state change only **your own** view of a shared mailbox; other members are unaffected. ## Track work on boards ```sh tinycld boards list tinycld boards view "Roadmap" tinycld boards column show --board Roadmap tinycld boards card add "Fix the login bug" --board Roadmap --list Triage tinycld boards card move --board Roadmap --list Doing tinycld boards card edit --due 2026-09-01 ``` Boards resolve by id, by key, or by name, so you can use whichever is convenient. Lists are the columns on a board: `boards column add`, `rename`, `move`, and `done` manage them, where `done` marks the column that counts as completed work. Removing a list deletes the cards in it. Board sharing and membership are not exposed on the command line — manage those in the app. ## Keep your address book ```sh tinycld contacts list tinycld contacts search "ada" tinycld contacts add --first Ada --last Lovelace --email ada@example.com tinycld contacts edit --company "Analytical Engines" tinycld contacts export --out contacts.vcf tinycld contacts import contacts.vcf ``` `rm` moves a contact to the trash rather than deleting it. The round trip back out is `tinycld contacts list --trashed` to find it and `tinycld contacts edit --restore` to bring it back; `--permanent` is the only flag that actually destroys a record. Import matches on the vCard `UID`, so re-importing the same file updates contacts instead of duplicating them. ## Read and edit your calendar ```sh tinycld calendar agenda --days 14 tinycld calendar list tinycld calendar add --title "Design review" --start "2026-09-01 14:00" --guest ada@example.com tinycld calendar rsvp yes tinycld calendar export --calendar Team --out team.ics tinycld calendar import team.ics ``` Calendar access has two levels: you can **read** any calendar you are a member of, but only **owners and editors** can change one. `tinycld calendar list` prints a ROLE column, which is the only advance warning you get — a write to a calendar you are merely subscribed to fails at the server. `rsvp` works only if you are actually on the event's guest list. ## Comment on documents and spreadsheets ```sh tinycld text comments /Specs/plan.docx tinycld text comments /Specs/plan.docx --add "Looks good to me" --quote "the second phase" tinycld calc comments /Models/budget.xlsx --add "Check this figure" --cell B12 tinycld calc comments /Models/budget.xlsx --resolve ``` Both groups offer a single `comments` command that reads a thread, adds to it, and resolves or reopens it. Spreadsheet comments anchor to a cell, given in ordinary A1 notation. There is no `text new` or `calc new`. Documents and workbooks *are* Drive files, so `tinycld drive put`, `cat`, `get`, and `rm` already manage them. Their contents are collaborative edit operations rather than plain text, which is why the command line reads and writes comments but not the body. ## Scripting and CI Every command accepts: | Flag | What it does | |---|---| | `--output table\|json\|csv` | Output format; `table` is the default | | `--json` | Shorthand for `--output json` | | `--context ` | Run against a saved context other than the current one | | `--quiet` | Suppress informational messages | | `--no-color` | Disable colored output | | `--yes` | Answer yes to prompts and skip interactive input | Without a terminal the tool never blocks on input and turns off color on its own, so it runs cleanly in cron jobs and CI: ```sh tinycld drive ls /reports --json | jq -r '.[].name' tinycld mail status --json | jq '.[0].inbox' ``` Two things to know before you script against it. **JSON output is not a transcription of the table.** Several commands deliberately return a richer or differently-keyed document than the columns they print — `drive tree` nests, where the table draws ASCII; `boards list` keys boards by slug; `calendar list` omits the ROLE column; `mail read` returns the raw message array. Check the actual JSON for a command before depending on a field name. **Warnings go to stderr.** `tinycld search` writes result counts and any partial- or truncated-result warnings to stderr specifically so that stdout stays a single clean JSON document you can pipe into `jq`. ## Going further `tinycld --help` lists every command in a group, and `tinycld --help` gives its full flag list. The [CLI reference](/docs/reference/cli-reference) documents the whole surface, including exit codes and the scopes each group requests. On servers where the packages are installed, the in-app help hub covers each group in more detail. ----------------------------------------- ## Automation rules Source: /docs/automation-rules.md Description: Build rules that react to things happening in your workspace — file mail, notify yourself, create events — and run on the server whether the app is open or not. ----------------------------------------- A rule watches for something happening in your workspace and reacts to it. Every rule has the same three parts: - **When** — the trigger that starts it: an event from a package, a schedule, or a manual run. - **If** *(optional)* — conditions narrowing which occurrences count. - **Then** — one or more actions, run in order. **Rules run on the server.** They fire whether or not you have the app open, on any device, including none. A rule that files newsletters keeps filing them while your laptop is shut. ## Where rules live Open **Settings → Rules**. Two segments: - **My rules** — yours alone. Anyone can create personal rules. - **Organization** — shared rules everyone can see, but only **admins and the owner** can create, edit, or delete. Everyone else sees them read-only. Packages may also offer a filtered view of the same rules. Mail's sidebar has a **Rules** entry that shows only mail-triggered rules and creates new ones with a mail trigger preselected. It is a filtered lens on the same records, not a separate system — anything you make there also appears in Settings → Rules → My rules. ## Building a rule **New rule** opens a builder that walks the three parts in order. Pick a trigger first. Choosing one resets the conditions and actions below it, because both depend on the trigger's fields. Conditions appear only for triggers tied to a record — "Run manually" and "On a schedule" carry no data to filter on. You get one level of grouping: conditions inside a group combine with **all** (AND) or **any** (OR), and the groups themselves combine the same way. Available operators depend on the field's type: | Field type | Operators | |---|---| | Text | contains, does not contain, equals, starts with | | Number | equals, does not equal, greater than, less than | | Boolean | is true, is false | | Date | before, after, within the last N days | | Relation, select | is, is not, is empty | Then add actions. Text parameters accept `{{ }}` placeholders — the builder's `{{ }}` button inserts the trigger's available fields, and they are filled in with real values when the rule runs. So a notification can say `New mail from {{sender_email}}` rather than just "you have mail". Saving without a name, a trigger, or at least one action tells you exactly what is missing. ## Personal and organization rules behave differently This is the distinction that most often surprises people, and it matters most on anything shared. **Personal rules act only for you.** On a shared mailbox, a personal rule that archives a message archives it *in your view*; everyone else still sees it in their inbox. **Organization rules act for everyone the event touches**, and run with admin authority — their actions behave as though an admin performed them, whoever triggered the run. An org rule archiving spam on a shared mailbox archives it for the whole team. That is deliberate: a shared rule that tidied only its author's inbox would be surprising. If you want something that affects only you, make it personal. ## Test before you rely on it **Test against recent items** runs the rule's conditions against your most recent matching records and reports how many would have matched. No actions run. Some triggers cannot be scoped to "recent items you can see" — a shared mailbox's messages don't belong to any single member. On those, a non-admin gets a message explaining that an **organization admin** needs to run the test from the Organization segment instead. This affects only the preview; the rule itself still runs normally. **Run now**, in a rule's overflow menu, fires a manual or scheduled rule immediately, enabled or not. Use it to confirm the actions do what you expect. ## Run history Every firing writes a run record. Open a rule's overflow menu → **Run history** to see: - Whether the run **matched** — conditions satisfied, actions ran — or **Didn't match**, meaning the trigger fired but conditions filtered it out. Non-matching rows are expected and useful: they prove the rule is watching even when there is nothing to do, which is how you debug a rule that seems inert. - How long it took, and each action's result. Personal-rule history is visible to its owner. Organization-rule history is restricted to admins and the owner — tighter than the rule itself, because a run record can quote data belonging to other people. ## Ordering, and stopping early Rules run in the order shown, which you set by dragging. Organization rules always run before personal ones. **Stop processing further rules** halts the chain. On an org rule it stops everything downstream. On a personal rule it stops only *that owner's* later rules — so on a trigger that reaches many people, whoever happens to sort first cannot switch off everyone else's automation. ## When a rule disables itself A rule whose actions keep failing — around twenty consecutive fully-failed runs — is disabled automatically and its owner notified, so it stops generating errors indefinitely. Check its run history for the cause (a deleted label, a revoked permission), fix it, and re-enable the rule from the list. Rules also cannot run away. A rule never re-fires on its own write, chains of one rule triggering another are capped at three levels deep, and a rule that sends email is limited to 20 messages an hour — enough that an exchange with someone else's auto-responder stops on its own. Hitting the cap is recorded in run history. ## What you can build with Available triggers and actions depend on which packages are installed. Anything from a package you don't have simply won't appear. ### Always available | | | |---|---| | **On a schedule** | Trigger: run on a repeating schedule | | **Run manually** | Trigger: run only when you ask | | **A user joins** | Trigger: someone is added to the organization. Make it an org rule — a new user belongs to nobody, so personal rules never match | | **Send me a notification** | Action: the in-app bell | | **Send an email** | Action: to any address, with a subject and body you write | | **Apply label** | Action: attach a label to the record that started the rule | ### Mail | | | |---|---| | **A message arrives** | Trigger: genuinely inbound mail only — never drafts, sends, or bounces. Filter on subject, sender, sender name, attachments, alias | | **A message bounces** | Trigger: something you sent didn't arrive, or was marked as spam | | **Move to folder** | Action: file the thread in Archive, Trash, Spam, or Inbox | | **Mark as read** | Action | | **Star the message** | Action | | **Forward the message** | Action: send a copy elsewhere | | **Send a message** | Action: new mail with a subject and body you write | Moving, marking read, and starring are independent — combine them freely. A rule cannot send to its own mailbox or aliases, so a forward pointed at itself cannot feed itself. ### Calendar | | | |---|---| | **An event is added** | Trigger: any new event on a calendar you belong to. Filter on **From a subscribed feed** to ignore bulk imports from an external feed | | **An event is rescheduled** | Trigger: the start or end time changed — editing a title or location does not count | | **An event is removed** | Trigger | | **A calendar feed fails to sync** | Trigger: a subscribed calendar could not be fetched. Without a rule this is silent — the calendar just quietly stops updating | | **Create an event** | Action: scheduled as an offset from now (starts in N days, duration, all-day, reminder) | ### Drive | | | |---|---| | **A file is added** | Trigger: anything new — an upload, a document, a folder. Filter on name, type, size, and destination folder | | **I'm mentioned in a comment** | Trigger: an @-mention anywhere — documents, spreadsheets, and files alike, in one rule | | **A file is shared with me** | Trigger: someone grants you access | | **A public link is created** | Trigger: best as an org rule, to notice things being published outside the organization. As a personal rule it means "when *I* create a link" | | **Move to folder** | Action: moves the file that started the rule | ### Boards | | | |---|---| | **A card is created** | Trigger: on any board you belong to, not only your own cards | | **A card moves to another list** | Trigger: a change of list. Reordering within a list is not a move | | **A card is completed** | Trigger: the card moved into a list marked as done. Separate from "moved" because whether a list counts as done is a property of the list, not the card | | **A card is assigned** | Trigger: the assignees changed | | **Move the card to a list** | Action: moves the card that started the rule | | **Assign the card to someone** | Action: adds a person to the card's assignees, keeping the existing ones | | **Add a label to the card** | Action: adds one of that board's labels, keeping the existing ones | ### Contacts | | | |---|---| | **A contact is added** | Trigger: created by you, an import, or another rule | | **A contact changes** | Trigger: name, email, phone, company, job title, notes, or favorite. Deliberately ignores bookkeeping — a CardDAV sync assigning an internal identifier, or a move to the trash, is not a change | | **Add a contact** | Action: first name, last name, email, company, each accepting placeholders | **Adding contacts does not check for duplicates.** A rule saving everyone who emails you will save the same person once per message. Add conditions to narrow it, or merge afterwards. ### Documents and spreadsheets | | | |---|---| | **A comment is added to a document** | Trigger: anyone commenting on a document you can see. Filter on the text, the quoted passage, the author, and which document | | **A comment is added to a spreadsheet** | Trigger: same, and because sheet comments anchor to a cell you can also filter by sheet, row, and column — enough to watch one region of a model | Both are broader than drive's mention trigger, which fires only when someone addresses *you*. Neither package contributes actions: document and cell contents are collaborative edit operations rather than fields a rule could set. A rule that starts from a comment can still do anything another installed package offers. ## Some things to build **File newsletters.** When a message arrives, if the sender contains `newsletter@`, move it to Archive and mark it read. Never touches your inbox, still searchable. **Auto-file invoices.** When a file is added, if the folder is Inbox and the name contains `invoice`, move it to Invoices. **Turn a message into a reminder.** When a message arrives, if the subject contains `invoice`, create an event titled `{{subject}}` starting in 3 days with a 60-minute reminder. **Notice a broken feed.** When a calendar feed fails to sync, send yourself a notification. Otherwise a subscribed calendar drifts out of date silently. **Never miss a mention.** When you're mentioned in a comment, notify yourself. **Watch the assumptions.** When a comment is added to a spreadsheet, if the sheet is `Inputs`, notify yourself. Comments elsewhere stay quiet. ## What rules don't do Worth knowing before you plan around them: - **Time passing is not an event.** "If this is still unread in three days", "when a card passes its due date", "if nobody replied in two days" are not expressible. Rules react to things happening. Scheduled rules exist but carry no record with them. - **Content changes are invisible.** Editing a document or a cell produces collaborative operations, not record changes, so there is no "when this document changes" or "when this total exceeds 1000" trigger. - **A rule acts on the record that started it.** It cannot go and find some other file, card, or contact and change that instead. - **No batching.** Each event is handled on its own; there is no "one summary of today's mail". Package authors adding new triggers and actions should read [Automation](/docs/anatomy/automation). ----------------------------------------- ## Getting started Source: /docs/getting-started.md Description: Assemble a TinyCld workspace, run the dev loop, and learn how the workspace, core, the app shell, and feature packages relate. ----------------------------------------- TinyCld is a pnpm workspace that ties together a set of independent repositories: an Expo/PocketBase app shell (the `tinycld` repo) with `@tinycld/core` nested inside it, and a set of feature packages (`@tinycld/mail`, `@tinycld/contacts`, `@tinycld/calendar`, `@tinycld/drive`, and more) that the app opts into at build time. A checkout with just the `tinycld` shell (no feature packages) runs as a lean shell with zero features — you clone exactly the feature packages you want to work on, and the generator wires in whichever are present. ## Fresh-machine setup Use `@tinycld/bootstrap`'s assemble-only mode to lay down the workspace — it writes the workspace coordination files (`package.json`, `pnpm-workspace.yaml`, `tinycld.packages.ts`, `vitest.config.ts`, shared test stubs, version pins) from embedded templates and clones the `tinycld` repo (which carries `@tinycld/core` nested inside it) plus any features you name with `--with` — then run a single `pnpm install` at the root: ```sh mkdir ~/code/tinycld && cd ~/code/tinycld # Assemble the workspace root + clone the tinycld shell + the package(s) you want. # A subset is fine; you can always add more later. npx @tinycld/bootstrap@latest --assemble-only --with mail --with contacts pnpm install # links members + runs the generator (postinstall) cd tinycld && pnpm run dev ``` The root `pnpm install` creates the `node_modules/@tinycld/*` symlinks for every present member and runs the generator via the `postinstall` hook. You do **not** have to clone every feature — the generator scans whichever member directories are present, and the app boots as a lean shell when none are. To add another feature later, re-run `npx @tinycld/bootstrap@latest --assemble-only --with ` — it skips members that already exist and clones the new one alongside. :::note **There's no *shared* committed lockfile in the ecosystem.** The workspace root is assembled per developer by bootstrap rather than cloned from one shared repo, and each member declares framework deps as `peerDependencies` rather than direct deps. You should still commit *your* assembled root to *your own* repo — we encourage it for your own version control. Ours at [`tinycld/workspace`](https://github.com/tinycld/workspace) is a worked example, not a repo to clone or fork. (The EAS cloud build itself builds from the `tinycld` app repo and clones the feature members via a pre-install hook, so it doesn't depend on your root being committed.) For reproducible installs, pin the bootstrap version (`npx @tinycld/bootstrap@2.4.0 --assemble-only`) and pin each member with `--with name@ref` (e.g. `--with mail@v0.3.1`). The `tinycld` shell (with `@tinycld/core` nested) is pinnable the same way: `--with tinycld@v1.2.0`. For *why* the root works this way — and the trade-off it carries — see the [FAQ](/docs/faq#why-isnt-there-a-shared-workspace-repo-to-clone). ::: ## The dev loop ```sh cd ~/code/tinycld/tinycld pnpm run dev # runs the generator, then starts Expo + PocketBase pnpm run checks # biome lint across every member + tsc on the app pnpm exec tinycld-pkg test --all # vitest across every present member pnpm exec tinycld-pkg test:e2e --all # playwright across every present member ``` `cd tinycld && pnpm run dev` runs the package generator first, then starts the normal Expo dev server, a local PocketBase instance, and a single-port HTTP proxy that fronts both — open `http://localhost:7100` (or `https://` if a localhost cert is present at `tinycld/assets/localhost.pem`) and the app talks to PB same-origin via `/api`. The generator produces re-exports under `tinycld/app/a/(app)//...`, writes `tinycld/tinycld.config.ts` (the typed installed-package source of truth), and symlinks package migrations into `tinycld/server/pb_migrations/`. You don't run it manually — the dev launcher and `pnpm install` (via `postinstall`) both invoke it automatically. ### Per-member checks The `tinycld-pkg` CLI runs checks scoped to a single member, or across every present member with `--all`. From any member directory: ```sh cd ~/code/tinycld/contacts pnpm exec tinycld-pkg check # typecheck + unit tests for this member pnpm exec tinycld-pkg test # unit tests only pnpm exec tinycld-pkg test:e2e # playwright specs for this member ``` `tinycld-pkg check` runs `tsc --noEmit` followed by `vitest run` — it does **not** invoke Biome (Biome runs only ecosystem-wide via `pnpm run lint` from `tinycld/`). From anywhere, `tinycld-pkg --all` runs the verb against only the members that are present. Feature unit/e2e tests are discovered automatically from each member's `tests/` directory; cloning or removing a feature changes the run with no config edit. ### Local database The app shell ships three scripts for managing the local PocketBase database. Run them from `tinycld/`; each loads `tinycld/.env` for superuser credentials and defaults to the dev PocketBase at `http://127.0.0.1:7100`. ```sh cd ~/code/tinycld/tinycld pnpm run db:reset # wipe server/pb_data, re-run migrations, seed a test user + org pnpm run db:seed # seed into the current database without wiping it pnpm run db:reset:demo # reset only the singleton demo org (preserves the demo user) ``` `db:reset` is the one you reach for most. It deletes `server/pb_data`, boots PocketBase to apply every present package's migrations against a fresh store, then seeds a test user (`user@tinycld.org`) and org. Use it after editing a migration — PocketBase doesn't hot-reload collection-rule changes from a previously-applied migration, so a reset is what makes the new schema and auth rules take effect. `db:seed` populates an **existing** database without dropping it: it creates the seed user and org, then calls each present package's seed function in turn (sample contacts, a starter mailbox, demo calendar entries, …). It takes a `--mode` flag — `--mode test` (the default) creates `user@tinycld.org` with a primary org plus a second `acme` org for cross-org testing, while `--mode demo` creates the `demo@tinycld.org` singleton used by the hosted demo. See [Seed data](/docs/anatomy/seed) for how a package contributes its own seed function. `db:reset:demo` wipes only the data scoped to the singleton demo org and re-seeds it, leaving the demo *user* in place. It's built to run nightly so the hosted demo workspace is pristine for the next unauthenticated visitor; you rarely need it during local development. #### Logging in `db:reset` and `db:seed` print a boxed summary of how to log in at the end of the run, so you never have to dig through the seed script. It looks like this: ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Seed complete — log in with: │ │ │ │ App (sign in to TinyCld) │ │ http://localhost:7100 │ │ user: user@tinycld.org │ │ password: Dev7f3a…! │ │ │ │ Superuser (PocketBase /_/ dashboard, /a/setup for orgs & packages) │ │ http://localhost:7100/a/setup │ │ user: admin@tinycld.org │ │ password: Tc!Xa1b… │ │ │ │ Org: test-org │ └─────────────────────────────────────────────────────────────────────────┘ ``` There are two accounts: - **App user** (`user@tinycld.org`) — the account you sign in to TinyCld with. When `db:reset` creates this user fresh and you haven't set a password, it **generates a random one and prints it** in the box above. Set `TEST_USER_PW` (and optionally `TEST_USER_LOGIN`) in `tinycld/.env`, or pass `--user-pw`, to pin a known password instead — that's what CI does. - **PocketBase superuser** (`admin@tinycld.org`) — the admin account for the PocketBase dashboard at `/_/` and the TinyCld superuser dashboard at `/a/setup` (where you manage organizations and install packages). Like the app user, its password is **generated and printed** when `db:reset` creates it and you haven't set one. Pin it (so it's stable across resets) by setting `ADMIN_USER_PW` (and optionally `ADMIN_USER_LOGIN`) in `tinycld/.env` — that's what CI does. :::note[Why no setup token?] A fresh self-hosted instance with an empty database prints a one-time `…/a/setup?token=…` URL on first boot (see [First-run setup](/docs/installation#first-run-setup)). You won't see that token locally: `db:reset` creates the superuser up front so it can seed, so PocketBase is no longer on its first run. The `/a/setup` link it prints instead goes straight to the superuser login → dashboard. Both are expected — the token flow is for an empty production instance, the `db:reset` flow is for local development. ::: :::note `pnpm run dev` does **not** reset or seed the database — it reuses whatever is in `server/pb_data`. The first time you boot a fresh checkout (or any time the store is empty or out of date), run `pnpm run db:reset` to get a working test user and org to log in with — and to print the credentials above. ::: ## Ecosystem layout The workspace root holds the coordination files: `package.json` (member devDeps + coordination scripts), `pnpm-workspace.yaml` (the authoritative member list, including the nested `tinycld/package-scripts` entry), `tinycld.packages.ts` (the present-member enumerator), `vitest.config.ts`, shared unit-test stubs under `tests/`, and pinned `.node-version` / `.go-version`. All of these come from `@tinycld/bootstrap`'s embedded templates — there is no separate workspace meta-repo to clone. Every other member is its own git repo, cloned alongside as a sibling directory: ``` ~/code/tinycld/ # workspace root (bootstrap-assembled; commit it to your own repo) package.json # member devDeps + coordination scripts pnpm-workspace.yaml # the authoritative member list (all possible members) tinycld.packages.ts # member enumeration for the generator vitest.config.ts # workspace-wide vitest entry tests/ # shared unit-test stubs (expo-router, lucide, …) .node-version # pinned Node version .go-version # pinned Go version tinycld/ # the Expo/PocketBase app shell (own repo) core/ # @tinycld/core - shared TS + Go library (nested) package-scripts/ # @tinycld/package-scripts - the tinycld-pkg CLI (nested) contacts/ # @tinycld/contacts ─┐ mail/ # @tinycld/mail │ calendar/ # @tinycld/calendar │ feature packages, each its own repo drive/ # @tinycld/drive │ calc/ # @tinycld/calc │ text/ # @tinycld/text │ google-takeout-import/ # ─┘ ``` The `tinycld` repo is always cloned; it carries `@tinycld/core` nested inside it. Each feature is its own git repo with its own history, issues, and CI, and you only clone the ones you intend to work on. `pnpm-workspace.yaml` lists *every* possible member, but pnpm tolerates members whose directories are absent — so a partial checkout installs and runs cleanly. `@tinycld/package-scripts` lives inside the app shell (`tinycld/package-scripts/`) and rides along with the `tinycld` clone; it's registered as the nested member `tinycld/package-scripts` in `pnpm-workspace.yaml`. ## How the workspace, core, the app shell, and features relate `@tinycld/core` is a nested member at `~/code/tinycld/tinycld/core/`, inside the `tinycld` repo. It holds the runtime: React Native, Expo Router, the PocketBase client, pbtsdb, shared UI components, theming, auth. Every feature package peer-depends on those; none of them ship their own copies. Features import from `@tinycld/core/lib/*` and `@tinycld/core/ui/*`, never the other way around. The app shell (the `tinycld` repo root) owns the bundler config, the generator, and the Expo Router `app/` tree, and consumes `@tinycld/core` like any other member. **Features do not depend on each other.** If the takeout importer wants to know whether mail is installed, it reads the runtime package registry (`usePackages()`) rather than taking a compile-time import on `@tinycld/mail`. This keeps each feature independently releasable and keeps the lean-shell guarantee intact — a checkout with no feature packages still typechecks and runs. :::warning Never run `pnpm install` (or any other PM's install) inside a member directory — only at the workspace root. Members declare framework deps as `peerDependencies` and carry no `node_modules` of their own; the workspace install hoists shared deps so every member resolves a single copy of `react`, `react-native`, `pbtsdb`, and everything else. An install inside a member materializes those peers a second time, and TypeScript then sees two of every type and emits hundreds of false "Type X is not assignable to type X" errors. Each member's `.gitignore` covers `node_modules/` and lockfiles — if one slips through, delete both. ::: ## Where to go next - **[Adding a package](/docs/adding-a-package)** if you want to bring an existing feature package into this checkout. - **[Creating a package](/docs/creating-a-package)** if you want to scaffold a new one. - **[Anatomy of a package](/docs/anatomy/manifest)** if you want to understand what a package actually contains. ----------------------------------------- ## Adding a package Source: /docs/adding-a-package.md Description: Bring an existing feature package into your workspace as a member. ----------------------------------------- A checkout with just the `tinycld` shell (no feature packages) runs as a lean shell. To enable a feature - mail, contacts, calendar, drive, anything third-party - you bring its repository into the workspace as a member and run a single `pnpm install` at the root. "Linking" in this layout means *being a present workspace member*: pnpm creates the `node_modules/@tinycld/*` symlink on install, and the generator wires the package in on the `postinstall` hook. :::note The workspace root is generated by `@tinycld/bootstrap` (it doesn't live in any single git repo). `@tinycld/core` is a nested member inside the `tinycld` repo (its standalone `github.com/tinycld/core` repo is being archived); the `tinycld` shell repo carries it along. ::: ## Fresh-checkout default Out of the box, the generator scans whichever member directories are present. With only the `tinycld` shell cloned, the app runs without any feature packages - you get authentication and an empty workspace, and that's about it. You add exactly the features you want by re-running `@tinycld/bootstrap --assemble-only` with the slugs you need. ## Add a feature with the bootstrap CLI Run `@tinycld/bootstrap` in assemble-only mode from the workspace root. It clones the named feature (and the `tinycld` shell if it's somehow missing), skipping any directory that already exists, and writes any missing workspace coordination files - so it's safe to re-run as often as you like: ```sh cd ~/code/tinycld npx @tinycld/bootstrap@latest --assemble-only --with contacts pnpm install # links the new member + reruns the generator cd tinycld && pnpm run dev ``` Pass `--with` more than once to add several at a time: ```sh npx @tinycld/bootstrap@latest --assemble-only --with contacts --with mail --with drive pnpm install ``` Pin to a specific tag/branch by suffixing `--with @`. The same syntax works for `tinycld` (the shell, with `@tinycld/core` nested) so you can pin the whole workspace to a known-good combination: ```sh npx @tinycld/bootstrap@latest --assemble-only \ --with tinycld@v1.2.0 \ --with mail@v0.3.1 --with contacts@main ``` Honor a non-default git host with `TINYCLD_REPO_BASE` (CI uses `https://github.com/tinycld` because runners have no SSH key; the default is `git@github.com:tinycld`): ```sh TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --assemble-only --with mail ``` ## What the install does The root `pnpm install` produces the same end state every time, whether you just bootstrapped or are reinstalling after pulling changes: 1. **Creates a symlink** under `node_modules/@tinycld/` pointing at the member, so the canonical package name (read from the member's own `package.json.name` - `@tinycld/contacts`, `@acme/custom`, or a bare `my-pkg`) resolves everywhere. Third-party scopes are first-class; the tooling doesn't favor `@tinycld/`. 2. **Runs the code generator** (`tinycld/scripts/generate.ts`) on the `postinstall` hook. It enumerates the present members (via `tinycld.packages.ts`), then materializes route re-exports, writes `tinycld/tinycld.config.ts` (the typed installed-package source of truth), generates help and Tailwind source wiring, and symlinks the package's PocketBase migrations and Go server module into `tinycld/server/`. :::note The `node_modules/@tinycld/*` symlinks are pnpm-owned local state, recreated on every install - don't try to `git add` them. The set of "linked" packages is simply the set of present workspace members. ::: ## Remove a package Delete the member directory and re-run `pnpm install` at the root: ```sh cd ~/code/tinycld rm -rf contacts pnpm install # reruns the generator without contacts ``` The generator cleans up that package's generated route re-exports and wiring on the next run. `pnpm-workspace.yaml` continues to list every possible member; pnpm tolerates members whose directories are absent, so you don't need to edit the member list. ## Typical workflow ```sh cd ~/code/tinycld npx @tinycld/bootstrap@latest --assemble-only --with contacts --with mail --with drive pnpm install cd tinycld && pnpm run dev ``` Those commands turn a lean shell into a working mail-contacts-drive app. The feature set is just "which member directories are present" - a CI job reproduces the same build by assembling the same members via `bootstrap --assemble-only`. For the anatomy of what you just added, see [Manifest](/docs/anatomy/manifest). For scaffolding a new package of your own, see [Creating a package](/docs/creating-a-package). ----------------------------------------- ## Creating a package Source: /docs/creating-a-package.md Description: Scaffold a new TinyCld feature package with npx @tinycld/bootstrap. ----------------------------------------- `@tinycld/bootstrap` is the interactive scaffolder for new packages. One command produces a feature repo that matches the conventions every first-party package follows - manifest, CI workflow, tsconfig, sample screens or a settings panel, and (optionally) a Go server stub. (The canonical lint config lives at `tinycld/biome.json` and applies to every member — no `biome.json` ships in the new repo by default.) It's the fastest way to go from idea to "a member of the workspace that typechecks." ## One-shot ```sh npx @tinycld/bootstrap --new my-feature ``` `--new ` selects scaffold mode (the alternative is `--assemble-only`, which assembles a workspace instead of scaffolding a package — see the [CLI reference](/docs/reference/cli)). The slug is kebab-case, 3–40 chars; it becomes the npm package name (`@tinycld/my-feature`), the URL segment (`/a/my-feature/`), and the Go module path (`tinycld.org/packages/my-feature`). Omit the slug to be asked. ## Where the scaffolder puts things The default target directory depends on whether you're already in a TinyCld workspace: - **Attach mode** — if your current directory *is* a workspace root (its `package.json` declares `"name": "@tinycld/workspace"`), the new package lands as a member at `.//` next to the `tinycld` shell. This is the typical setup for active TinyCld contributors. - **Bootstrap mode** — if your current directory is not a workspace root, the scaffolder creates a wrapper directory `./tinycld-/`, assembles a workspace inside it (writes the workspace `package.json` and clones the `tinycld` shell, with `@tinycld/core` nested, when `--link` is set or accepted), and scaffolds the package at `./tinycld-//`. You end up with a self-contained workspace you can `cd` into and run. The detection looks for a `package.json` whose `name` is `@tinycld/workspace` — a coincidentally-named directory won't false-match. You can always override the auto-detection with `--target ./somewhere-else`. ## Prompts The scaffolder walks you through a short interactive session: - **Human-readable name** - defaults to title-cased slug, used in `manifest.name` and the nav label. - **Description** - one sentence, reused in the manifest, `package.json`, and README. - **Preset** - pick `full` for a data package (mail/contacts/drive shape) or `settings-only` for a settings-panel-only package (google-takeout-import shape). - **Icon, nav order, shortcut** - only for the full preset. The icon is any [lucide-react-native](https://lucide.dev/icons) name. - **Include a Go server?** - full preset only. No if you only need JS hooks and migrations. - **Target directory** - defaults to `./my-feature`. Must not exist or must be empty. - **Link into the workspace now?** - the scaffolder can assemble (or attach to) the workspace and run `pnpm install` at the root for you, so the new member is wired in. Say yes to skip the manual steps below. Every prompt has a matching flag, so the scaffolder can run fully non-interactively (handy for CI, scripted setups, and autonomous coding agents): ```sh npx @tinycld/bootstrap --new my-feature \ --yes \ --preset full \ --icon check-square \ --no-server \ --no-link ``` See the [bootstrap CLI reference](/docs/reference/cli) for the full flag list, including the `--assemble-only` / `--with` workspace-assembly mode. ## Presets ### `full` - data package Matches `@tinycld/contacts`, `@tinycld/mail`, `@tinycld/calendar`, `@tinycld/drive`. Package TypeScript lives under a `tinycld//` prefix (which the `package.json` `exports` map maps to subpaths like `@tinycld/my-feature/screens/*`). You get: - `manifest.ts` with `routes`, `nav`, `collections`, `migrations`, `seed`, `sidebar`, and optionally `server`. - `tinycld//screens/{_layout, index, [id]}.tsx` - list + detail routes. - `tinycld//{collections.ts, types.ts}` - pbtsdb registration and schema types. - `tinycld//{sidebar.tsx, provider.tsx}` - optional UI scaffolding. - `tinycld//seed.ts` - an async seed function with a working example write. - `pb-migrations/_create_.js` - a starter PocketBase migration. - `server/{go.mod, register.go}` - Go module stub if the Go prompt was yes. ### `settings-only` - service package Matches `@tinycld/google-takeout-import`. No routes, no nav entry, no collections, no server - just a settings panel: - `manifest.ts` with only `name`, `slug`, `description`, `settings`. - `tinycld//settings/main.tsx` - the panel component. - `tinycld//types.ts` - empty surface for any public type exports. Use this for integrations (import/export tools), admin surfaces, or anything that lives entirely under `/a/settings/`. ## After scaffolding If you accepted the **Link into the workspace now?** prompt, the scaffolder has already assembled (or attached to) the workspace - writing the workspace `package.json`, cloning the `tinycld` shell (with `@tinycld/core` nested) if needed, and running `pnpm install` at the root. Only git and GitHub remain manual: ```sh cd my-feature git init git add . git commit -m 'chore: initial scaffold' gh repo create tinycld/my-feature --public --source=. --push ``` If you declined the link prompt, or want to link later, run `@tinycld/bootstrap` in assemble-only mode from the workspace root to make sure the `tinycld` shell is present, then install: ```sh # from a workspace root (or an empty dir you want to become one) cd ~/code/tinycld npx @tinycld/bootstrap@latest --assemble-only # ensures the tinycld shell is present # place your package directory (named after its slug) next to tinycld/, then: pnpm install # links it + runs the generator cd tinycld && pnpm run checks ``` `pnpm run checks` (from `tinycld/`) runs the ecosystem-wide Biome pass and the app's typecheck. If it's green, your scaffolded package is ready to develop in. Run a single member's typecheck + unit tests with `pnpm exec tinycld-pkg check` from the member directory. Then start the app: ```sh cd ~/code/tinycld/tinycld pnpm run dev ``` This builds and runs the Go PocketBase server, the Expo dev server, and a single-port HTTP proxy that fronts both. Open the URL it prints (default `http://localhost:7100`, or `https://` if a localhost cert is present). For the `full` preset, your package's nav entry shows up in the sidebar; for `settings-only`, your panel appears under the org settings. To log in, first run `pnpm run db:reset` (from `tinycld/`) once — it seeds a test user and org and prints the credentials to use. See [Logging in](/docs/getting-started#logging-in) for the details. ## What the templates assume Scaffolded code imports core via the scoped path: ```ts import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query' import { Modal } from '@tinycld/core/ui/modal' ``` Intra-package imports use relative paths. `~/tinycld//*` is also aliased to the package's own nested source. `@tinycld/core` is a nested member inside the `tinycld` repo (at `tinycld/core/`) - the scaffolded `tsconfig.json` extends `@tinycld/core/tsconfig.package-base.json` by package name, and `@tinycld/core/*` resolves by package name through the `node_modules/@tinycld/*` symlink, so resolution works as soon as the package is a present member. Don't install core as a dependency. See [Screens](/docs/anatomy/screens) for the full story. ## Contributing to another package's sidebar Not every new package wants its own nav entry. If your feature naturally extends one that already exists — for example, booking pages that belong inside the calendar sidebar — you can scaffold a package without `nav` or `routes` and ship a sidebar contribution instead. Drop `sidebarContributions` into your manifest, point at a component file, and add the matching `package.json` exports wildcard. The full pattern (including ordering, validation, and runtime gating with `usePackages()`) is documented in [Sidebar slots](/docs/anatomy/sidebar-slots). For what each generated file means, walk through [Anatomy](/docs/anatomy/manifest). For bringing the new package into a workspace once it's pushed, see [Adding a package](/docs/adding-a-package). ----------------------------------------- ## Conventions Source: /docs/conventions.md Description: The rules every TinyCld package follows - collection naming, data access, state, imports, and style. ----------------------------------------- A TinyCld package is an independent repo, but it lives in a shared workspace and ships into the same app shell as every other package. These conventions are what keep that from turning into a negotiation. Most of them exist because the alternative broke something specific — the reasons are given, not just the rules. This page is the index. Each section links to the task doc that covers the mechanics in full. ## Collections **Prefix every collection with the package slug.** A todo package owns `todo_items`, `todo_lists` — never a bare `items`. ```js filename="pb-migrations/1713000000_create_todo_collections.js" const items = new Collection({ id: 'pbc_todo_items_01', name: 'todo_items', // ✅ slug-prefixed type: 'base', }) ``` This is not a style rule. `core/server/pkgaccess` resolves **which package owns a collection from its name alone** — dashes in a slug match underscores in the name — and uses that to enforce each user's `org_pkg_access` level server-side. A user with `readonly` or `none` has their writes to your package's collections refused on the REST record endpoints, the DAV protocol servers, and mail's IMAP/SMTP sessions. Because ownership is name-derived, that enforcement covers packages with no Go code of their own. It also means the failure mode is silent: **a collection named outside the convention is treated as core/shared data and gets no package-access enforcement at all.** That's deliberate for genuinely shared infrastructure — drive's polymorphic `comment_mentions` is the intended case — and a quiet hole for anything else. The name is also the only scheme that cannot collide. PocketBase has one flat, global namespace and a deployment installs an arbitrary set of independently authored packages, so two that both want `items` cannot coexist — and that surfaces at migration time on a user's server, not in your CI. The same prefix applies to collection **ids** (`pbc_todo_items_01`) and to field ids inside them. `manifest.slug` is the single source of it. :::note Exactly the slug is also a valid owned name — `@tinycld/contacts` owns `contacts`, not `contacts_contacts`. `pkgaccess` matches both `` and `_*`, so the single-table case is fully enforced. ::: See [Collections](/docs/anatomy/collections). ## Data access **Always go through pbtsdb.** Never reach for PocketBase directly from a component, a hook, or a test. | ❌ Never | ✅ Instead | |---|---| | `pb.collection(x).create()` in a component or hook | `useMutation` from `@tinycld/core/lib/mutations` | | a raw REST call to `/api/collections/…` | drive the UI, which already uses `useMutation` | | a new API route or Go endpoint just to read or write rows | `useOrgLiveQuery` to read, `useMutation` to write | | N separate queries stitched together with JS `Map`s | one query with `.join()` + `.select()` | **Read with `useOrgLiveQuery`.** It waits until the user id is known and adds it to the dependency list for you. ```tsx filename="screens/index.tsx" const [itemsCollection] = useStore('todo_items') const { data: items } = useOrgLiveQuery((query, { userId }) => query.from({ item: itemsCollection }).where(({ item }) => eq(item.user, userId)) ) ``` Raw `useLiveQuery` is reserved for the low-level hooks `useOrgLiveQuery` itself depends on. **Prefer a join over PocketBase `expand`.** A join resolves against the local optimistic store immediately; `expand` waits on a realtime round-trip, so a record you just created optimistically reads as *missing* until the server redelivers it. A join condition must be a single equality — push any other predicate into a subquery and join that. **These rules bind tests too.** E2E sets up data by driving the UI. Raw `page.request` against PocketBase is allowed only for read-only assertions, never to create or edit. See [Query data](/docs/tasks/query-data) and [Mutate data](/docs/tasks/mutate-data). ## Migrations **Unreleased migrations may be edited in place. Released ones are frozen — append instead.** PocketBase never re-runs a migration it has already applied. While a version is unreleased, every database that will ever see it starts empty, so rewriting the file is safe. Once you ship, deployments exist that already applied it, and an edit to that same filename silently never runs — leaving those servers on the old schema with rules evaluating against fields that moved. A schema change after release ships as a **new migration in a new version**, always. ## State **Reach for the right primitive before reaching for `useState`.** | Need | Use | |---|---| | Form fields | `useForm` + zod | | Server / async data | `useOrgLiveQuery` | | Mutations | `useMutation` | | Derived values | `.select()` on the query | | Responding to a prop change | compute during render | | DOM refs | `useRef` | | Shared UI state | a Zustand store | `useState` is for genuinely local, synchronous UI state that no other component needs — a modal toggle, an accordion. **Pairing `useState` with `useEffect` to sync or transform data is the signal that you want a different primitive.** **Forms** are React Hook Form + zod, imported from the single `@tinycld/core/ui/form` barrel — it re-exports `useForm`, `Controller`, `zodResolver`, and `z` alongside the inputs. Let TypeScript infer the form type from `defaultValues` rather than specifying the generic. Submit through `useMutation` so pbtsdb errors reach the form via `handleMutationErrorsWithForm`, and never hold form fields in `useState`. A mutation without an explicit `onError` already surfaces an error toast and a Sentry capture. Add one to handle failure *better* — and if you pass a no-op, comment why it's safe to swallow, so an optimistic update never reverts in silence. Zustand covers shared *UI* state only — sidebars, dialog targets, popovers. Not server data, not forms, not URL state. Don't introduce React Context for new shared UI state. Keep mutations in hooks rather than stores, since they need reactive query data and TanStack Query's pending/error flags. See [UI state](/docs/tasks/ui-state) and [Forms](/docs/tasks/forms). ## Imports and coupling **Use `~/*` and `@tinycld/core/*`. Never a relative climb like `../../tinycld/...`.** Inside a feature package, `~/tinycld//*` is your own subtree. `@tinycld/core/*` and any cross-package dependency resolve by package name through the workspace symlinks — no sibling needs a `paths` entry for them. **Siblings must not depend on each other.** To know whether another package is installed, read the runtime registry: ```ts import { usePackages } from '@tinycld/core/lib/packages/use-packages' const installed = new Set(usePackages().map((p) => p.slug)) const mailAvailable = installed.has('mail') ``` A hard `@tinycld/mail` import makes that package load-bearing at compile time and breaks the lean-shell guarantee — a workspace with no features must still typecheck and boot. If you need another package's types, declare a minimal local interface and tolerate its runtime absence. **In the `exports` map, always use wildcards.** Metro cannot resolve literal bracket subpaths, so `"./screens/*": "./tinycld/todo/screens/*.tsx"` matches both `screens/index` and `screens/[id]`, while a literal `"./screens/[id]"` entry does not. **Framework dependencies are `peerDependencies`, never `dependencies`** — and **run `pnpm install` only at the workspace root, never inside a member.** Both rules protect the same thing. pnpm's hoisted linker flattens peers into the root `node_modules`, so every member resolves one copy of `react`, `react-native`, and `pbtsdb`. A member-level install materializes a second copy, TypeScript then sees two structurally identical types from different paths, and you get hundreds of `Type X is not assignable to type X` errors. Recover with `rm -rf /node_modules /*-lock.yaml`. ## Routing **The org never appears in the URL.** Routes are bare — `/contacts`, `/mail`, `/settings/profile`. Org identity comes from the deployment and is resolved before the request reaches the app. Navigate with `useOrgHref()` from `@tinycld/core/lib/org-routes`, never with a literal path and never with an `as OneRouter.Href` cast. Where the slug is a runtime value, the resolved string `` `/${pkgSlug}` `` is correct. See [Routing](/docs/tasks/routing). ## Testing **Write unit tests for new features.** Mock only through the helpers in `tests/unit.helpers.tsx` — never mock our own components or actions. **Wait on the rendered screen, never on the URL.** Use `appShell(page)` or `packageScreen(page, slug)` from `@tinycld/core/e2e-helpers`; otherwise assert on the element the test is about to touch. A URL changes the moment the router *accepts* a navigation — before the lazy chunk has loaded or committed — so gating on it is wrong in both directions: it can return while the screen is still mounting, and when a route shape shifts it hangs for the full timeout while the app is working fine. This has bitten the repo twice. **Don't `page.goto()` for in-app navigation.** A `goto` tears down the SPA and cancels in-flight fetches including lazy route chunks, which means a slow Metro recompile and a flaky run. Use `login(page)`, then `navigateToPackage(page, '')`, then the sidebar helpers. Reserve `page.goto('/')` for the initial load inside `login`, and never assume the post-login redirect lands on a particular package — navigate explicitly. **Never start or kill servers around a Playwright run.** Playwright owns that lifecycle: it resets the database and starts PocketBase and Expo itself. :::warning **No e2e workarounds.** No bumped timeouts, no forced-serial runs, no blind re-runs. Fix flakiness at its source. A red check is never resolved by re-running it, reverting it, or merging around it — and whose change caused it is irrelevant. If the fix is genuinely out of scope, stop and say so. ::: **Run the checks from inside the member you changed:** `pnpm exec tinycld-pkg check` runs biome, then tsc, then vitest, scoped to that member. [Tests](/docs/anatomy/tests) covers the ecosystem-wide variants. ## Style - **Keep JSX minimal.** No complex ternaries, `.map()`, or calculations in the return. State, event handling, and data processing move into a `useFeatureName` hook or a helper above the JSX. - **Conditional visibility takes a prop.** Instead of `{condition && }`, give the component an `isVisible` prop and return `null` when it shouldn't render. - **Comments explain "why", not "what".** Self-explanatory code needs none. - **Never use `any`**, and never silence a lint rule with an ignore comment — fix what the rule found. - **No raw hex colors.** Light and dark mode are both supported: use semantic Tailwind tokens (`className="text-foreground bg-background"`) or `useThemeColor('foreground')` where a `className` doesn't reach, such as Lucide icons and RN `Pressable` style props. - **Never `console.*` in runtime code** — biome enforces this as an error. Use `log` from `@tinycld/core/lib/logger` on the client and `logging.ForPackage("")` on the server. - Keep hooks pure and side-effect free, called at the top level. **Naming:** components are PascalCase (`CustomerList.tsx`), hooks are camelCase with a `use` prefix, utility modules are kebab-case. Biome enforces 4-space indent, single quotes, ES5 trailing commas, and no superfluous semicolons. See [Theming](/docs/tasks/theming) and [Logging](/docs/tasks/logging). ## Platform support **TinyCld supports native and web equally.** Write every feature for both. A web-only or native-only feature needs the drawbacks spelled out and signed off before it lands. ## Generated files are never committed The generator emits route re-exports, `lib/generated/*`, the Go server extensions, and the workspace-root coordination files on every install. They're all gitignored, and hand-editing them means your change disappears at the next `pnpm install`. When you add a generated file, add it to the exclude list in the canonical `tinycld/biome.json`. See [Generated files](/docs/reference/generated-files). ## A feature isn't done until it's documented Whenever you add or significantly change a user-facing feature, add or update its in-app help topic — a `help/.md` in the package root with `title` and `summary` frontmatter, declared as `help: { directory: 'help' }` in the manifest. Write keyboard shortcuts with Mac glyphs only (`⌘` `⇧` `⌥`); the renderer substitutes per-platform. Never hand-author a deployment hostname — write `{{server-host}}` and the viewer substitutes the reader's own. See [In-app help](/docs/tasks/in-app-help). ----------------------------------------- ## The generator Source: /docs/generator.md Description: What tinycld/scripts/generate.ts does on every install and dev, and the files it produces. ----------------------------------------- `tinycld/scripts/generate.ts` (with its `gen-*.ts` helper modules) is the one piece of code that stitches feature members into the app shell. It reads `tinycld.packages.ts` (which enumerates the present workspace members), loads each feature's manifest, and produces a tree of thin re-exports, one typed config file, symlinks, and Go wiring - everything the app shell needs to find and load each package. The generator runs automatically on the workspace-root `pnpm install` (via `postinstall`) and again before `pnpm run dev`; you rarely invoke it directly. It is a **thin** step: rather than emitting a file per concern, it writes a single typed `tinycld.config.ts`, and the runtime derives the package stores, registry, sidebars/providers/settings, and seeds from it. ## What it does Each run, the generator produces the following outputs. Every one of these files (and their parent directories, where relevant) is gitignored - they regenerate on every run. ### Config source of truth - `tinycld/tinycld.config.ts` - a typed `definePackageEntry<…>()` array, one entry per present feature, carrying the manifest plus references to each package's `registerCollections`, `sidebar`, `provider`, and `settings`. It also exports `MergedPackageSchema`, a literal intersection of every package's schema type. This file is the installed-package source of truth; the runtime helpers in `@tinycld/core/lib/packages/` derive stores, the registry, components, and seeds from it. - `tinycld/tinycld.seeds.ts` - a Node-only seed list, kept out of the app bundle (seed modules use Node APIs Hermes can't bundle). - `tinycld/lib/generated/tinycld-config.ts` - a re-export shim so `@tinycld/core` can import `@tinycld/app-generated/tinycld-config` without a hard dependency on the app. ### Route re-exports For each package with `routes.directory`, every `.tsx`/`.ts`/`.jsx`/`.js` file in that directory becomes a thin re-export under `tinycld/app/a/(app)//`: ```ts filename="tinycld/app/a/(app)/mail/index.tsx" export { default } from '@tinycld/mail/screens/index' ``` `/a` is the app's constant route prefix and `(app)` is an Expo Router group (no URL segment), so the file above serves `/a/mail`. Nested folders and layout files are preserved. Non-route extensions (helpers, styles) are ignored. For each package with `publicRoutes.directory`, files are re-exported under `tinycld/app/p//`. `drive/public-screens/share/[token].tsx` becomes `tinycld/app/p/drive/share/[token].tsx`. The hand-written `tinycld/app/p/_layout.tsx` wraps the whole tree with a bare `` — no auth gate, since this is where pre-auth entry points live. ### Other generated wiring under `tinycld/lib/generated/` - `package-help.ts` - parsed help-topic frontmatter + bodies for every package's (and core's) `help/` directory, consumed by the in-app help hub. - `uniwind-sources.css` - one `@source "";` line per present member (core + features), so Tailwind/Uniwind scans class names used inside members. ### Server-side outputs under `tinycld/server/` - `pb_migrations/` - one symlink per migration file in core's `pb_migrations/` plus every feature's `pb-migrations/` directory. - `pb_hooks/` - one symlink per file in every feature's `pb-hooks/` directory. - `package_extensions.go` - a generated Go file whose `registerPackageExtensions(app)` calls each package server's `Register(app)`. - `go.work` - a Go workspace file listing the app, core, and each feature server module (written only when at least one feature ships a server; removed otherwise). - `bundled-packages.json` - the seed manifest core's Go server uses to hydrate its `pkg_registry` collection at boot. Cleanup is per-target: the generator wipes and rebuilds the migration/hook symlink dirs each run, and removes each present feature's own `tinycld/app/a/(app)//` route dir before regenerating it. App-owned files in the route tree (`_layout.tsx`, `index.tsx`, `settings/**`) are left untouched. ## When it runs ```sh pnpm install # workspace root: postinstall → app packages:generate cd tinycld && pnpm run dev # dev.ts: packages:generate → expo + pocketbase + proxy cd tinycld && pnpm run packages:generate # manual re-run, sometimes useful after editing a manifest cd tinycld && pnpm run export:web # production web build (runs the generator first) ``` If a package's manifest or directory layout changes and you haven't restarted dev, `pnpm run packages:generate` (from `tinycld/`) by hand picks up the new output. Bringing a new member in or removing one is just `pnpm install` at the root. ## Footguns :::warning **Use wildcard exports in `package.json`.** The generator emits imports like `@tinycld/mail/screens/[id]` - Metro cannot resolve a literal bracket subpath (`"./screens/[id]": "..."`), even when TypeScript and Node both can. Always use `"./screens/*": "./tinycld/mail/screens/*.tsx"`. See [Screens](/docs/anatomy/screens) for the details. ::: :::warning **Members must not have their own `node_modules/`.** Any package manager materializes peer dependencies if you run `install` inside a member, creating a duplicate `react`, `react-native`, `pbtsdb`, and so on. TypeScript then sees two copies of every type and emits hundreds of false "Type X is not assignable to type X" errors. Only ever run `pnpm install` at the workspace root. If it happens: `rm -rf /node_modules /package-lock.json`. ::: :::warning **The `node_modules/@tinycld/*` symlinks are pnpm-owned.** They're recreated on every workspace-root install. Don't try to `git add` them; the set of "linked" packages is simply the set of present workspace members. ::: ## Reference For the complete list of generated artifacts and where each lives, see [Generated files](/docs/reference/generated-files). For the manifest fields that drive the generator, see [Manifest schema](/docs/reference/manifest-schema). ----------------------------------------- ## FAQ Source: /docs/faq.md Description: Why the TinyCld ecosystem is shaped the way it is — the design decisions behind the per-developer workspace, the independent package repos, and the absence of a committed lockfile. ----------------------------------------- The "why is it built this way?" questions, answered. These are design-rationale answers, not how-tos — for the mechanics see [Getting started](/docs/getting-started) and [Adding a package](/docs/adding-a-package); for failure modes see [Troubleshooting](/docs/troubleshooting). ## Why isn't there a shared workspace repo to clone? There's no *shared* workspace repo to clone. The workspace root (`~/code/tinycld/`) is **assembled per developer by `@tinycld/bootstrap`** — bootstrap writes the coordination files (`package.json`, `pnpm-workspace.yaml`, `tinycld.packages.ts`, `vitest.config.ts`, the shared `tests/` stubs) directly from embedded templates, then clones the members you ask for. That's the part that can't be a single shared repo: there is no one canonical set of installed packages (see below). The reason is **per-developer composition**. The whole ecosystem is built around installing only the packages you're actually working on: a checkout with just the `tinycld` shell (no feature packages) runs as a lean shell, and the generator wires in whichever feature members happen to be present. One developer might assemble `mail` + `contacts`; another `calendar` + `drive`. A single *shared* `pnpm-workspace.yaml` and `pnpm-lock.yaml` can't represent both of those compositions at once — there is no one canonical set of installed packages to share. So instead of pretending there's a shared root, each developer owns their own, and the present-member set (plus the resulting `pnpm-lock.yaml`) is local state by design. **"Not a shared repo" does not mean "not in git."** You absolutely should commit *your* assembled root to *your own* git repository — we encourage it. `pnpm install` keeps a self-maintaining block in `.gitignore` so each member's contents stay in that member's own repo and only the coordination files are tracked. Our own workspace lives at [`tinycld/workspace`](https://github.com/tinycld/workspace) — treat it as a **worked example of what a committed root looks like, not a repo to clone or fork**. Yours will have a different member set, a different `pnpm-lock.yaml`, and its own remote. (The EAS cloud build doesn't clone your workspace root — it builds from the `tinycld` app repo and a pre-install hook clones the feature members alongside it. Committing your root is for your own version control + convenience, not a build requirement.) This is a deliberate trade, not an oversight. See [What's the catch of not having one shared root?](#whats-the-catch-of-not-having-one-shared-root) for the cost we accept in exchange, and [Why not check the root in and use sparse checkout?](#why-not-check-the-root-in-and-use-sparse-checkout) for the conventional alternative and why we don't take it. ## Why not check the root in and use sparse checkout? This is the standard monorepo answer to "not everyone wants every package": commit a root that lists *all* members, commit one union lockfile resolving every package together, and have each developer sparse-check-out the subset they want. It's coherent, and the architecture is already 90% compatible with it — `getPackages()` ignores absent directories, pnpm ignores absent workspace members. We don't take it because of what it costs: - **A heavier, union lockfile.** Committing one lockfile means resolving the dependency graph of *every* package simultaneously, even for a developer who only touches `mail`. The lean-shell guarantee — that a two-package checkout installs only what those two packages need — partly dissolves. - **A workflow everyone has to learn.** Sparse checkout is not most people's daily git, and getting it wrong produces confusing "where did my files go" states. The bootstrap-per-dev model keeps the lean-shell property intact and keeps everyone's daily git ordinary (each member is just a normal repo you clone). The price is that there's no single committed source of truth for workspace *state* — which is the next question. ## What's the catch of not having one shared root? There's no single source of truth for workspace *state* across the team, so ecosystem-wide structural changes are rolled out per developer rather than with one commit everyone pulls. The clearest example is the migration from npm to pnpm. With a committed root, that's one commit: change the lockfile and workspace file, everyone pulls, done. With the per-dev model, every developer's root has to be brought along individually — in practice by bumping the bootstrap version and re-running it, or by a one-off migration script. We absorbed the pnpm move without much pain, but a larger structural change down the road might warrant a proper codemod / `bootstrap upgrade` path. That's the trade we're accepting in exchange for per-developer composition and an ordinary per-repo git workflow. It's worth knowing about, not a reason to abandon the model. ## Why not use git submodules for the packages? Submodules pin each package to a specific commit from a parent repo. They're a separable idea from checking the root in — you could in principle use them with or without a committed root — and we decline them on their own merits: - **Ergonomic tax.** Detached-HEAD confusion, the commit-then-bump-the-pointer two-step on every change, and the easy-to-forget `--recursive` are real, recurring friction. - **They don't solve the composition question.** Each feature member is *already* its own independent git repo with its own remote and history. Submodules would mainly add a pointer-management layer on top of that, without answering "which packages does this developer have installed?" — which is the actual question the per-dev model exists to handle. If you want a reproducible, pinned set of package versions (the legitimate need submodules address), pin them at assemble time instead: `npx @tinycld/bootstrap --assemble-only --with mail@v0.3.1 --with tinycld@v1.2.0`, against a pinned bootstrap version. That gives you reproducibility without the submodule machinery. ## Why is each package its own repo instead of one monorepo? So that packages are genuinely independent and the app can be a lean shell. Each feature package (`mail`, `calendar`, `contacts`, …) ships, versions, and is installed on its own; the app boots with zero features and gains exactly the ones whose directories are present. Siblings never depend on each other directly — when one needs to know about another (e.g. the takeout importer checking whether `mail` is installed), it reads the runtime package registry rather than importing across package boundaries. A single monorepo would make every package's code present at all times and invite exactly the cross-package coupling the architecture is designed to prevent. ## Why can't one package import another directly? Because a hard `import` from `@tinycld/mail` would make `mail` load-bearing at compile time, and that breaks the lean-shell guarantee — a workspace with no feature siblings would no longer typecheck or run. Sibling packages depend only on `@tinycld/core`, never on each other. When a package genuinely needs to react to another's presence (the canonical case is `@tinycld/google-takeout-import` offering a "import my mail" step only when `mail` is installed), it reads the **runtime package registry** instead: ```ts import { usePackages } from '@tinycld/core/lib/packages/use-packages' const installed = new Set(usePackages().map((p) => p.slug)) const mailAvailable = installed.has('mail') ``` If it needs *types* from another package (e.g. a collection schema), it declares a minimal local interface and tolerates the schema being absent at runtime — the takeout importer's local copy of the mail collection types is the reference example. The rule is: coupling is allowed to be *advisory and runtime-checked*, never *load-bearing at compile time*. ## Why does `pnpm install` have to run at the workspace root, never in a member? Because members carry no `node_modules` of their own. Feature packages declare framework dependencies (`react`, `react-native`, `pbtsdb`, `@tanstack/db`, …) as `peerDependencies`, and those resolve through the app shell's single install at the root. Running an install *inside* a member materializes those peers a second time, so TypeScript sees two copies of every type and emits hundreds of spurious "Type X is not assignable to type X" errors. Always install at `~/code/tinycld/`. The fix when it happens is in [Troubleshooting](/docs/troubleshooting#typescript-sees-two-copies-of-react-or-anything-else). ## Why is there only one Biome config for the whole ecosystem? There is exactly one canonical Biome config in the ecosystem — `tinycld/biome.json` — and it lints the app shell *and* every member. Member repos ship no `biome.json`, don't depend on `@biomejs/biome`, and have no `lint` script of their own. The reason is consistency without drift. If every package carried its own config, formatting and lint rules would diverge package by package, and a contributor moving between repos would hit different rules in each. One config means one set of rules, enforced identically everywhere. You run it ecosystem-wide from `tinycld/` (`pnpm run lint`), or scoped to a member via `pnpm exec tinycld-pkg` — but the *rules* come from the single source either way. The config keeps an exclude list for generated artifacts (route re-exports, `pbSchema.ts`, migrations, `dist`, …); that list is the one thing to keep current when you add a new kind of generated file. With the above said, this is one thing we'd like to support. It would be nice if we could allow packages to ship their own configs, while not forcing them to maintain all the exclude lists and other fiddly bits. Ideas welcomed! ## Why must `package.json` exports use wildcards instead of literal bracket paths? Because Metro — the React Native bundler — can't resolve a literal bracketed subpath. A dynamic route like `screens/[id].tsx` looks like it should map with `"./screens/[id]": "./tinycld/mail/screens/[id].tsx"`, and TypeScript and Node both accept that form, but Metro silently fails to resolve it and the screen 404s in the app. The wildcard form resolves everywhere: ```json "./screens/*": "./tinycld/mail/screens/*.tsx" ``` One `*` entry matches `screens/index` *and* `screens/[id]` *and* anything else under the directory, and it works in Metro, Node, and TypeScript alike. So the rule is: **always use `*` wildcards in a member's `exports` map, never literal bracket entries.** A screen 404 right after adding a `[param]` route is almost always this. ## Why is generated code gitignored instead of committed? The generator's output — `tinycld/tinycld.config.ts`, `tinycld/tinycld.seeds.ts`, the route re-exports under `tinycld/app/a/(app)//`, `tinycld/lib/generated/`, the Go wiring (`server/package_extensions.go`, `server/go.work`), and the migration/hook symlinks — is **all gitignored and never committed.** Likewise the PocketBase type artifacts (`tinycld/core/types/pbSchema.ts`, `tinycld/core/types/pbZodSchema.ts`) regenerate on every install. The reason is that the generated output is a pure function of *which members are present*, and that set is per-developer. A committed `tinycld.config.ts` would encode one developer's package selection and immediately be wrong for everyone else — and it would produce noisy, conflicting diffs on every assemble/remove. Treating it as build output instead means it's always correct for whoever's machine it's on: it regenerates on every `pnpm install` (via postinstall) and on every `pnpm run dev`. The on-disk sources — manifests, migrations, the present-member set — are the source of truth; the generated files are just their materialization. This is also why you never hand-edit them: the next install clobbers your changes. ## If the lockfile is local state, how do framework versions stay consistent? The `pnpm-lock.yaml` is per-developer local state (see [Why isn't there a shared workspace repo to clone?](#why-isnt-there-a-shared-workspace-repo-to-clone)), and the in-app package installer — the `/admin` flow that adds, removes, or upgrades feature packages — has to install with `pnpm install --no-frozen-lockfile`, because changing the installed package set is precisely the point: a frozen lockfile would forbid the very change the admin is making. That flexibility has a sharp edge. `--no-frozen-lockfile` lets pnpm re-resolve *every* version range in the graph, not just the package being changed. So a developer who only meant to install a new feature could silently pick up a newer `expo`, `react-native-reanimated`, `uniwind`, or any other framework dependency than the one their machine (or a device's embedded native binary) was built against. For a native-linked module that means a runtime mismatch; for the styling engine (`uniwind`/`tailwindcss`) it means classNames recompile to subtly different styles — visible as app-wide layout drift on an over-the-air update even though the source never changed. We pin against this with a **graph-wide `overrides` block in `pnpm-workspace.yaml`** rather than a committed lockfile. `overrides` force the framework, native, and styling stack to exact versions *before* resolution, so they hold even under `--no-frozen-lockfile`, while leaving every feature package free to install and upgrade. It's the targeted version of what a frozen lockfile would do — locking the handful of dependencies that must stay in lockstep with the native binary, without locking the package set the per-developer model is built to keep fluid. The pinned versions must be bumped in lockstep whenever a new native build (EAS) ships newer ones; the block carries a comment saying exactly that. ## Why does TypeScript sometimes see a type as not assignable to itself? This is the symptom `preserveSymlinks: true` in `tinycld/core/tsconfig.json` exists to prevent, and it's worth understanding because the error message is baffling the first time. Members import `@tinycld/core` through a pnpm workspace symlink (`node_modules/@tinycld/core` → `../tinycld/core`). Without `preserveSymlinks`, TypeScript resolves *through* the symlink to the real path, so a member sees core's types at one path while core sees its own types at another — and TS treats the two as distinct, emitting "Type `Foo` is not assignable to type `Foo`" against what is literally the same declaration. Setting `preserveSymlinks: true` makes TS keep the symlinked path, so both sides agree on one identity for every core type. (The same class of duplicate-identity error, from a different cause, is what a member-level `pnpm install` produces — see [above](#why-does-pnpm-install-have-to-run-at-the-workspace-root-never-in-a-member).) ----------------------------------------- ## Go server extensions Source: /docs/go-server.md Description: How packages ship Go code that the app shell's PocketBase server loads at startup. ----------------------------------------- A package that needs to run Go code on the server - IMAP/SMTP, custom HTTP endpoints, long-lived workers, anything that doesn't fit in PocketBase's JS hooks - ships a `server/` subdirectory with its own Go module. The generator wires it into a generated `go.work` and into the generated `tinycld/server/package_extensions.go` entry point. For the contract the package itself implements (the `Register(app)` function, the module layout, testing), see [Server](/docs/anatomy/server). This page covers what the app shell does around that. ## When to use Go Most packages don't need a Go server. If you can express your logic as: - A PocketBase JS hook → put a `.pb.js` file in `pb-hooks/`. - A migration → put a file in `pb-migrations/`. - Client-only code → don't ship server code at all. Reach for Go only when you need: - A long-lived network server (IMAP, SMTP, WebRTC signalling). - Streaming or binary HTTP endpoints that JS hooks can't serve cleanly. - Logic that must run outside any PocketBase record event (cron-style tickers, worker pools). - Native integrations the PocketBase JS runtime doesn't expose. :::note PocketBase JS hooks go in `pb-hooks/` (symlinked into `tinycld/server/pb_hooks/` by the generator). Go code goes in `server/` at the package root. They're separate mechanisms - a package can use both if it needs to. ::: ## Core's Go module `@tinycld/core` is a nested member inside the `tinycld` repo (at `tinycld/core/`), and its Go side is the module `tinycld.org/core` at `tinycld/core/server/`, exporting `coreserver` (the registration orchestrator) plus subsystems like `notify`, `push`, `mailer`, `audit`, `textextract`, `thumbnails`. The app server (`tinycld/server/main.go`) is the module `tinycld.org/app` and consumes core via a hand-written replace directive in `tinycld/server/go.mod`: ```go filename="tinycld/server/go.mod" require tinycld.org/core v0.0.0 replace tinycld.org/core => ../core/server ``` That path is relative to `tinycld/server/` and points at the nested core's `server/` subdirectory at `~/code/tinycld/tinycld/core/server/`. ## Declaring a package module Two fields in a feature's manifest: ```ts filename="manifest.ts" server: { package: 'server', module: 'tinycld.org/packages/example' }, ``` `package` is the subdirectory name, by convention `'server'`. `module` is the Go module path declared in that subdirectory's `go.mod` - use the `tinycld.org/packages/` namespace to keep module paths out of collisions. A feature's `go.mod` requires `tinycld.org/core v0.0.0`; it does **not** need its own `replace` directive, because the generated `go.work` resolves every module's location. ## What the generator writes On each `pnpm run packages:generate` (and on the workspace-root `pnpm install`), for every present feature with a `server` field, the generator: 1. Regenerates `tinycld/server/package_extensions.go`, a small Go file whose `registerPackageExtensions(app)` calls each package's `Register(app)`. The app shell's `main.go` invokes it (`RegisterExtras: registerPackageExtensions`) so every present package gets a chance to wire in hooks, endpoints, and workers before the server starts. ```go filename="tinycld/server/package_extensions.go" // Code generated by tinycld/scripts/generate.ts. DO NOT EDIT. package main import ( "github.com/pocketbase/pocketbase" example "tinycld.org/packages/example" ) func registerPackageExtensions(app *pocketbase.PocketBase) { example.Register(app) } ``` 2. Writes `tinycld/server/go.work`, a Go workspace file listing the app, core, and each feature server module by its on-disk path (resolved through the workspace `node_modules/@tinycld/*` symlinks): ```go filename="tinycld/server/go.work" go 1.25.0 use ( . ../../node_modules/@tinycld/core/server ../../node_modules/@tinycld/example/server ) ``` The `go.work` file is written only when at least one present feature ships a server; it's removed when none do. Because module locations come from `go.work`, no per-package `replace` directives are appended to `go.mod` - the file stays hand-authored. 3. Writes `tinycld/server/bundled-packages.json`, the seed manifest core's Go server uses to hydrate its `pkg_registry` collection at boot. ## Testing the Go side A feature's Go module is self-contained - it pins the same PocketBase version core uses, so test code hits the same API surface it will in production: ```sh cd ~/code/tinycld/example/server go test ./... ``` Within an assembled workspace, the generated `go.work` ties the feature module, core, and the app together, so a build from `tinycld/server/` reflects exactly what the app ships. Core also has its own Go tests under `tinycld/core/server/**/_test.go` — run those from `tinycld/core/server/` with `go test ./...`. For the package-side concerns (the `Register` function signature, the directory layout, what to import), see [Server](/docs/anatomy/server). ----------------------------------------- ## Troubleshooting Source: /docs/troubleshooting.md Description: Common failure modes when adding, generating, and building packages, with fixes. ----------------------------------------- Symptoms listed first, fixes below each. Most of these are first-time-integration papercuts - once you've hit one, you learn to spot the shape. ## My package's screens 404 in the app shell Two common causes: 1. **The package isn't a present member.** Is its directory cloned in beside the `tinycld` shell? Re-run `@tinycld/bootstrap` to make sure (it skips members that already exist), then re-install so pnpm creates the symlink and the generator picks the new member up: ```sh cd ~/code/tinycld npx @tinycld/bootstrap@latest --assemble-only --with pnpm install ``` 2. **The generator output is stale.** The member is present, but `tinycld/app/a/(app)//` is empty or out of date. Regenerate from the app shell: ```sh cd ~/code/tinycld/tinycld pnpm run packages:generate ``` If you're running `pnpm run dev`, quit and restart - the dev launcher runs the generator on startup. ## TypeScript sees two copies of `react` (or anything else) Symptom: hundreds of "Type X is not assignable to type X" errors, usually involving `React.ReactNode`, `View`, `ScrollView`, or `Transaction`. Shapes match but inference treats them as different types. Cause: you ran `pnpm install` (or any other PM's install) inside a member directory instead of at the workspace root. Members declare framework deps as `peerDependencies` and have no `node_modules` of their own; a member-level install materializes those peers a second time, so TypeScript sees a duplicate `react`, `react-native`, `pbtsdb`, etc. Fix: ```sh rm -rf /node_modules /package-lock.json cd ~/code/tinycld && pnpm install # always at the workspace root ``` Every member's `.gitignore` should cover `node_modules/` and `package-lock.json`. If they aren't there, add them before the next commit. ## `Unable to resolve module @tinycld/` in a Docker build The Docker image builds from the assembled workspace. If the generator didn't run inside the Docker context, the `tinycld/lib/generated/*` wiring and the `node_modules/@tinycld/*` symlinks don't exist. Fix: ensure the build assembles the workspace (`bootstrap --assemble-only`) and runs the root `pnpm install` (whose `postinstall` runs the generator) in a stage where the members are present. ## A Go build complains about a missing module or `go.work` Cause: the generated `tinycld/server/go.work` (which ties the app, `core`, and each feature's server module together) is stale or missing - usually because the generator hasn't run since you added or removed a feature with a Go server. Fix: ```sh cd ~/code/tinycld/tinycld pnpm run packages:generate ``` This rewrites `tinycld/server/go.work` and `tinycld/server/package_extensions.go` for the current set of present members. The app's `go.mod` keeps a hand-written `replace tinycld.org/core => ../core/server`; individual feature modules need no `replace` because `go.work` resolves their locations. If a `go.sum` still drifts during a build, run `cd tinycld/server && go mod tidy` once. ## A bracket-path route file won't resolve Symptom: a dynamic route like `screens/[id].tsx` works in `pnpm run dev` but fails at bundle time with `Unable to resolve "@tinycld//screens/[id]"`. Cause: your package.json `exports` uses a literal bracket subpath (`"./screens/[id]": "./tinycld//screens/[id].tsx"`). Metro cannot resolve that form, even though TypeScript and Node both can. Fix: use a wildcard instead: ```json filename="package.json" { "exports": { "./screens/*": "./tinycld//screens/*.tsx" } } ``` This matches both `screens/index` and `screens/[id]`. See [Screens](/docs/anatomy/screens) for the full pattern. ## My migrations don't run in dev The generator symlinks files from each feature's `pb-migrations/` (and core's `pb_migrations/`) into `tinycld/server/pb_migrations/`. If your migration isn't showing up in PocketBase's migration list on the admin UI, the symlink is probably missing or stale. Fix: ```sh cd ~/code/tinycld/tinycld pnpm run packages:generate ``` Verify the symlink exists in `tinycld/server/pb_migrations/` and points at your package's file. If PocketBase is already running, restart it - migrations are loaded at boot. ## My settings panel is present but doesn't appear Three things to check, in order: 1. Is the package a present member (its directory cloned in, and the root install re-run)? 2. Does `manifest.ts` export a `settings` array with at least one entry? 3. Does the file at `settings/.tsx` default-export the panel component? Named exports are not picked up. If all three are right and it still isn't showing, regenerate (`pnpm run packages:generate` from `tinycld/`) and reload the app. ## Two packages register the same `nav.shortcut` The generator doesn't reject this — it just registers both, and tinykeys (the keyboard-shortcut library) fires whichever it sees first when the user presses the key. The result is a feature that "sometimes" navigates to the wrong screen. Fix: change one of them. `shortcut` in the manifest can be any single lowercase letter or omitted. Letters currently in use across first-party packages, in case you want to avoid them: `m` (mail), `o` (contacts), `c` (calendar), `d` (drive). The package picker UI shows every active shortcut, which is the quickest way to audit for collisions. ## Two packages declare the same public route `manifest.publicRoutes` mounts files directly under `tinycld/app/` with no namespacing. If two packages emit the same ``, the generator writes both — the second wins silently, the first 404s in practice. Coordinate paths between packages or prefix them with the slug (e.g. `drive-share/[token].tsx` instead of `share/[token].tsx`). ========================================= # Package anatomy ========================================= ----------------------------------------- ## Manifest Source: /docs/anatomy/manifest.md Description: Every field a package manifest can declare, and what each one wires into the app shell. ----------------------------------------- Every package exports a default `manifest.ts` at its root. The generator reads this file to decide what to wire into the app shell - routes, collections, settings panels, migrations, Go server modules. Fields other than the four base identifiers are all optional; a package contributes only what it declares. ## Required fields Four fields are mandatory: - `name` - human-readable name used in navigation and the package picker. - `slug` - URL segment and collection-name prefix. Must match the last segment of the npm package name (`@tinycld/mail` → `mail`). - `version` - informational only right now; keep it in sync with `package.json`. - `description` - one-line summary shown in the package registry. ## Full example ```ts filename="manifest.ts" const manifest = { name: 'Example', slug: 'example', version: '0.1.0', description: 'An example package', routes: { directory: 'screens' }, publicRoutes: { directory: 'public-screens' }, nav: { label: 'Example', icon: 'box', order: 20, shortcut: 'e', }, migrations: { directory: 'pb-migrations' }, hooks: { directory: 'pb-hooks' }, collections: { register: 'collections', types: 'types', }, settings: [ { slug: 'example', component: 'settings/example', label: 'Example settings', }, ], sidebar: { component: 'sidebar' }, provider: { component: 'provider' }, seed: { script: 'seed' }, tests: { directory: 'tests' }, server: { package: 'server', module: 'tinycld.org/packages/example' }, cli: { package: 'cli', module: 'tinycld.org/packages/example/cli', scopes: ['example:read', 'example:write'], }, automation: { definitions: 'automation' }, build: { script: 'build' }, // dependencies: ['other-package-slug'], } export default manifest ``` ## What each optional field does `routes.directory` points to the folder of app screens the generator re-exports under `tinycld/app/a/(app)//`, served at `/a//`. See [Screens](/docs/anatomy/screens). `publicRoutes.directory` points to a folder whose files become public routes at `tinycld/app/p//`. Use this for pre-auth entry points such as public share links. The per-slug namespace means two packages never collide on the same path. `nav` adds a rail entry in the org workspace. `icon` is a [lucide-react-native](https://lucide.dev/icons/) name, `order` controls sort priority (lower comes first), and `shortcut` registers a single-letter keyboard shortcut. Omit `nav` entirely for packages that don't belong in the sidebar - a settings-only package has no `nav`, no `routes`, no `publicRoutes`. `migrations.directory` and `hooks.directory` are PocketBase concerns. The generator symlinks their contents into the app shell's `tinycld/server/pb_migrations/` and `tinycld/server/pb_hooks/` so PocketBase discovers them on boot. `collections.register` and `collections.types` are subpaths (without extension) to the `registerCollections` function and schema-type module, respectively. See [Collections](/docs/anatomy/collections). `settings` is an array of panel contributions to Personal Settings. Each entry needs `slug`, `label`, and `component` (a subpath to the `.tsx` panel). See [Settings](/docs/anatomy/settings). `sidebar.component` is a subpath to a component rendered in the secondary sidebar when the package is active. Omit `sidebar` entirely (as `@tinycld/calc` does) and the workspace renders no sidebar container at all - the package's screens get the full viewport width next to the nav rail. `provider.component` wraps the package's routes with a custom provider - use it when a package needs its own context (e.g. Drive's upload state). `seed.script` is a subpath to a module that default-exports an async seed function. `tests.directory` tells the test runners where to find Playwright specs. See [Seed](/docs/anatomy/seed) and [Tests](/docs/anatomy/tests). `server.package` is the subdirectory containing a Go module; `server.module` is the module path it declares. The generator lists each present feature's server module in a generated `tinycld/server/go.work` (no per-package `replace` directive is needed). See [Server](/docs/anatomy/server). `cli.package` and `cli.module` mirror `server`: the subdirectory holding a Go module, and the module path it declares. That module exposes `Register(root *cobra.Command, c *client.Client)`, and the generator wires it into the `tinycld` binary. `cli.scopes` lists the OAuth scopes the package defines, for the scope registry and the consent screen. There is deliberately no command list - Cobra owns the command tree and `--help`, and a hand-maintained copy would only drift. See [tinycld CLI reference](/docs/reference/cli-reference). `automation.definitions` is a subpath to a module that default-exports the package's workflow-rules catalog: the triggers users can build rules on and the actions rules can take, as pure data typed against the package's schema. See [Automation](/docs/anatomy/automation). `build.script` is a subpath (resolved through the package's exports map) to a TS module the generator runs whenever it generates - before route re-exports land. Use it when the package ships an artifact the bundler can't produce on its own (for example, a self-contained webview bundle compiled with esbuild). The script runs from the package directory; one-shot builds gate `packages:generate` and the web export, and the dev launcher can run them in watch mode. Scripts are executed via the workspace's hoisted `tsx`, so they can import dependencies the app shell provides - the member package itself has no `node_modules`. `dependencies` is an array of slugs this package expects to be installed. The generator does not enforce these at build time - it's metadata for humans and for runtime feature gating. :::tip The convention is to name `routes.directory` `'screens'`, `publicRoutes.directory` `'public-screens'`, `migrations.directory` `'pb-migrations'`, and `tests.directory` `'tests'`. Every first-party package follows this layout; deviating only makes your package harder for the next author to read. ::: For the exact TypeScript interface, see [Manifest schema](/docs/reference/manifest-schema). ----------------------------------------- ## Screens Source: /docs/anatomy/screens.md Description: How package screens become Expo Router routes inside the app shell. ----------------------------------------- Packages contribute two kinds of routes: app screens under `/a//...` and (optionally) public pre-auth routes under `/p//...`. Both are plain Expo Router files. The generator re-exports them into the app shell's `tinycld/app/` tree so Expo Router's file-based router picks them up. `/a` is a constant segment that namespaces every app route, keeping them clear of the public share tree (`/p`), the API (`/api`), and the protocol mounts (`/dav`, `/caldav`, `/carddav`). Nothing is interpolated into it — see [Routing](/docs/tasks/routing). ## App screens Point `routes.directory` at the folder containing your screens. The convention is `'screens'`: ```ts filename="manifest.ts" routes: { directory: 'screens' }, ``` Mirror the URL structure you want inside the folder. Package TypeScript lives under a `tinycld//` prefix, so for a package with `slug: 'mail'`: ``` mail/tinycld/mail/screens/ _layout.tsx → tinycld/app/a/(app)/mail/_layout.tsx index.tsx → tinycld/app/a/(app)/mail/index.tsx [id].tsx → tinycld/app/a/(app)/mail/[id].tsx ``` `(app)` is an Expo Router group, so it adds no URL segment: those three files serve `/a/mail`, `/a/mail`, and `/a/mail/`. The group exists so only the authenticated workspace subtree gets the auth-gated layout — pre-auth screens (`/a/connect`, `/a/setup`) sit beside it directly under `app/a/`. The generated re-exports are thin - each one simply forwards `export { default } from '@tinycld/mail/screens/'`. The actual components live in the member repo; the app shell only holds the glue. `_layout.tsx` is the package's route group root. Put layout concerns (navigation, providers, shared UI) here. If you don't declare one, Expo Router falls back to its default stack layout. Screens import core utilities via the `@tinycld/core/...` package paths: ```tsx filename="tinycld/mail/screens/index.tsx" import { useStore } from '@tinycld/core/lib/pocketbase' import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query' import { useAuth } from '@tinycld/core/lib/auth' ``` Do not attempt to install `@tinycld/core` as a dependency - resolution works because the package's `tsconfig.json` extends `@tinycld/core/tsconfig.package-base.json` by package name, and `@tinycld/core/*` resolves by package name through the `node_modules/@tinycld/*` symlink. ## Public top-level routes Some packages need pre-auth entry points that don't sit under `/a/`. Declare `publicRoutes` and place files in the named directory: ```ts filename="manifest.ts" publicRoutes: { directory: 'public-screens' }, ``` The generator emits re-exports at `tinycld/app/p//`. Drive's `tinycld/drive/public-screens/share/[token].tsx` becomes `tinycld/app/p/drive/share/[token].tsx`, served at `/p/drive/share/`. ## package.json exports For the generator and Metro to resolve these files, your `package.json` needs wildcard exports that cover each directory: ```json filename="package.json" { "exports": { "./screens/*": "./tinycld/mail/screens/*.tsx", "./public-screens/*": "./tinycld/mail/public-screens/*.tsx" } } ``` :::warning Use wildcards - not literal bracket subpaths. An entry like `"./screens/[id]": "./tinycld/mail/screens/[id].tsx"` resolves under TypeScript and Node but silently fails under Metro. `"./screens/*": "./tinycld/mail/screens/*.tsx"` matches both `screens/index` and `screens/[id]` cleanly and is the only form that works for dynamic routes. ::: ## What the generator produces Each file under `routes.directory` or `publicRoutes.directory` with a `.tsx`, `.ts`, `.jsx`, or `.js` extension becomes a re-export in `tinycld/app/`. Nested folders and layout files are preserved. Non-route files (helpers, styles) are ignored - put those in `components/`, `hooks/`, or alongside the screens but give them a different extension (e.g. `.helpers.ts`). The re-exports under `tinycld/app/a/(app)//` and `tinycld/app/p//` are gitignored and regenerated on every `pnpm run packages:generate`. Don't edit them by hand. The wrapping `tinycld/app/p/_layout.tsx` is hand-written — a bare `` with no auth gate — and stays in git. ----------------------------------------- ## Collections Source: /docs/anatomy/collections.md Description: How a package declares PocketBase collections and integrates them with pbtsdb's type system. ----------------------------------------- Packages that store data declare a `types.ts` (the TypeScript schema), a `collections.ts` (the pbtsdb registration), and a `pb-migrations/` directory (the PocketBase migrations). The generator merges your schema type into core's `MergedSchema` so every collection is fully typed end-to-end. ## Declaring collections in the manifest ```ts filename="manifest.ts" collections: { register: 'collections', types: 'types', }, migrations: { directory: 'pb-migrations' }, ``` Both `register` and `types` are subpaths (no extension), resolved through the package's `exports` map. By convention they point to `./tinycld//collections.ts` and `./tinycld//types.ts` inside the package. ## types.ts Define one interface per collection and export a schema type that maps collection names to record types and relations: ```ts filename="tinycld/example/types.ts" import type { Orgs, Users } from '@tinycld/core/types/pbSchema' export interface Example { id: string title: string org: string created_by: string created: string updated: string } export type ExampleSchema = { example: { type: Example relations: { org: Orgs created_by: Users } } } ``` The schema type name follows the convention `{PascalSlug}Schema`: slug `example` becomes `ExampleSchema`, slug `task-lists` becomes `TaskListsSchema`. The generator imports this name by convention, so deviating breaks wiring. Each record interface must match the PocketBase collection schema declared in your migration. Keep the two in lock-step - the typecheck does not cross the boundary into PocketBase's runtime schema. ## collections.ts Export a `registerCollections` function. It receives a typed `newCollection` factory (already parameterized by the merged schema) and core's `CoreStores` so you can reference core collections in `expand` configs without circular imports: ```ts filename="tinycld/example/collections.ts" import type { createCollection } from 'pbtsdb/core' import { BasicIndex } from 'pbtsdb/core' import type { Schema } from '@tinycld/core/types/pbSchema' import type { CoreStores } from '@tinycld/core/lib/pocketbase' import type { ExampleSchema } from './types' type MergedSchema = Schema & ExampleSchema export function registerCollections( newCollection: ReturnType>, coreStores: CoreStores, ) { const example = newCollection('example', { omitOnInsert: ['created', 'updated'] as const, expand: { org: coreStores.orgs }, collectionOptions: { autoIndex: 'eager' as const, defaultIndexType: BasicIndex, }, }) return { example } } ``` The keys of the returned object become the names you pass to `useStore()` in components. Returning `{ example }` makes `useStore('example')` work everywhere. ## Reading from a package collection Inside a screen or hook, import `useStore` and `useOrgLiveQuery` from core: ```tsx filename="tinycld/example/screens/index.tsx" import { useStore } from '@tinycld/core/lib/pocketbase' import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query' import { eq } from '@tanstack/db' export default function ExampleList() { const [exampleCollection] = useStore('example') const { data } = useOrgLiveQuery((query, { orgId }) => query .from({ example: exampleCollection }) .where(({ example }) => eq(example.org, orgId)) .orderBy(({ example }) => example.title, 'asc'), ) // render data } ``` `useOrgLiveQuery` auto-scopes to the active org and waits for org context to load, which is what you want for nearly every package query. Reach for raw `useLiveQuery` only in bootstrap hooks or for genuinely user-level data. ## Migrations Place PocketBase migration files in the directory you named in `manifest.migrations.directory`. Use timestamp-prefixed filenames and follow PocketBase's JS migration format: ```js filename="pb-migrations/1712000000_init.js" /// migrate( (app) => { const collection = new Collection({ id: 'example', name: 'example', type: 'base', fields: [ // field definitions ], }) return app.save(collection) }, (app) => { const collection = app.findCollectionByNameOrId('example') return app.delete(collection) }, ) ``` The generator symlinks each migration into the app shell's `tinycld/server/pb_migrations/` so PocketBase applies it on next boot. Prefix your collection names with the package slug (`example_items`, `mail_threads`) to avoid cross-package collisions in the shared PocketBase database. ----------------------------------------- ## Settings Source: /docs/anatomy/settings.md Description: How a package contributes entries to Personal Settings. ----------------------------------------- Packages can add entries to the Personal Settings panel without declaring routes or navigation. Each entry is a link in the settings list; clicking it renders a component the package ships. This is how settings-only packages like `@tinycld/google-takeout-import` integrate - no `routes`, no `nav`, just a `settings` array and a component file. ## Declaring settings entries Add a `settings` array to the manifest. Each entry has three fields: ```ts filename="manifest.ts" settings: [ { slug: 'example', label: 'Example settings', component: 'settings/example', }, ], ``` - `slug` - URL segment appended under `/a/settings/`. Must be unique across all installed packages. - `label` - text shown in the settings sidebar. - `component` - subpath (no extension) to the `.tsx` that renders the panel. A package can declare any number of settings entries. `@tinycld/mail` ships two: one for the mail provider, one for mailboxes. ```ts filename="manifest.ts" settings: [ { slug: 'provider', label: 'Provider', component: 'settings/provider' }, { slug: 'mailboxes', label: 'Mailboxes', component: 'settings/mailboxes' }, ], ``` ## The component A settings component is a plain React component. It **must default-export**. The generator wires the component into the config by default name; a named export will not be picked up. Settings panels render into the settings layout that core already provides — no chrome, no title bar, just the panel body. Use the same hooks and patterns as any other screen: ```tsx filename="tinycld/example/settings/example.tsx" import { eq } from '@tanstack/db' import { useForm } from '@tinycld/core/ui/form' import { useStore } from '@tinycld/core/lib/pocketbase' import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query' import { useMutation } from '@tinycld/core/lib/mutations' export default function ExampleSettings() { const [settingsCollection] = useStore('example_settings') const { data } = useOrgLiveQuery((query, { orgId }) => query.from({ s: settingsCollection }).where(({ s }) => eq(s.org, orgId)), ) // render your form return null } ``` Default-export the component. The generator imports it by default name - a named export will not be picked up. ## How the generator wires it The settings array is one of several manifest fields the generator threads end-to-end. Tracing the flow makes the mental model concrete, and it's the same shape used for [sidebar slots](/docs/anatomy/sidebar-slots) and `sidebar`: 1. **`pnpm install` (or `pnpm run packages:generate`)** runs `tinycld/scripts/generate.ts`, which loads each present member's `manifest.ts`. 2. For every entry in `settings`, `tinycld/scripts/gen-config.ts` emits a lazy import into the generated `tinycld/tinycld.config.ts`: ```ts definePackageEntry()({ manifest: { /* ... */ }, settings: [ { slug: 'provider', label: 'Provider', Component: lazy(() => import('@tinycld/mail/settings/provider')) }, { slug: 'mailboxes', label: 'Mailboxes', Component: lazy(() => import('@tinycld/mail/settings/mailboxes')) }, ], }) ``` 3. At runtime, `core/lib/packages/derive-components.ts` exposes `packageSettings` — a `PackageSettingsGroup[]` with one entry per package, each carrying the list of `{ slug, label, Component }` panels declared by that manifest. 4. The Personal Settings index iterates `packageSettings` to render the navigation list, and the `[...section]` dynamic route looks up the matching panel and renders the `Component` under `` so the module loads on demand. The whole pipeline is static — no runtime registration, no `useEffect`-driven discovery. If a panel doesn't appear, you can step through these four points in order to find the break. ## package.json exports The wildcard export for `settings/*` must be present or the generator can't resolve the component path: ```json filename="package.json" { "exports": { "./settings/*": "./tinycld/example/settings/*.tsx" } } ``` ## Settings-only packages A package with no nav entry, no screens, and no public routes is valid. `@tinycld/google-takeout-import` is the canonical example: ```ts filename="google-takeout-import/manifest.ts" const manifest = { name: 'Google Takeout Import', slug: 'google-takeout-import', version: '0.1.0', description: 'Import data from Google Takeout .zip files.', settings: [ { slug: 'google-takeout', component: 'settings/takeout', label: 'Import from Google', }, ], } export default manifest ``` When this package is a present member, a single "Import from Google" link appears in Personal Settings. Removing the member removes the entry with no trace. ## Runtime gating from a settings panel Settings panels often need to know which other packages are installed - e.g. the takeout importer only offers "Import mail" if `@tinycld/mail` is present. Use `usePackages()` for this: ```tsx filename="tinycld/google-takeout-import/settings/takeout.tsx" import { usePackages } from '@tinycld/core/lib/packages/use-packages' export default function TakeoutSettings() { const installed = new Set(usePackages().map((p) => p.slug)) const canImportMail = installed.has('mail') // conditionally render import options } ``` Do not add a hard import from `@tinycld/mail` into the dependent package. That would turn the dependency into a build-time requirement and break the lean-shell guarantee (a checkout with no feature packages must still typecheck and run). Filter by slug at runtime instead. ## Troubleshooting | Symptom | Likely cause | |---|---| | Panel doesn't appear in Personal Settings | `pnpm install` / `pnpm run packages:generate` hasn't re-run since the manifest change. | | Build error: `Module not found: @tinycld//settings/` | `package.json` is missing the wildcard export `"./settings/*": "./tinycld//settings/*.tsx"`. | | Panel link clicks but renders blank | The component file uses a named export. Switch to `export default`. | | Multiple packages clash on the same slug | `slug` must be unique across **all** installed packages, not just within one manifest. Rename one of them. | ## See also - [Sidebar slots](/docs/anatomy/sidebar-slots) — same lifecycle (manifest-declared, lazy-loaded, generator-validated) for contributing UI into another package's sidebar instead of into Personal Settings. ----------------------------------------- ## Sidebar slots Source: /docs/anatomy/sidebar-slots.md Description: How one package contributes UI into another package's sidebar. ----------------------------------------- A **sidebar slot** is a named insertion point in a host package's sidebar. Other packages declare contributions targeting a slot, and the host renders them inline alongside its own UI. This is the mechanism for extending an existing package — for example, a booking-page package that adds "My Booking Pages" to the calendar sidebar instead of becoming its own top-level nav entry. The contract is intentionally narrow: the host owns the slot's position; the contributor owns what renders inside it. ## When to use a sidebar slot vs. a new package | You want… | Use… | |---|---| | A new top-level feature with its own nav entry and screens | A normal package with `nav` + `routes` | | To add a section inside an existing package's sidebar | A sidebar contribution | | To add a panel to Personal Settings | [`settings`](/docs/anatomy/settings) | Contributions are static and bundled-only — they ship at build time through the same generator pipeline as `sidebar`, `provider`, and `settings`. Runtime-installed packages (from the in-app package registry) cannot contribute slots. ## Host side: exposing a slot A host package does two things: declares the slot in its manifest, and renders `` where contributions should appear. ```ts filename="manifest.ts" const manifest = { name: 'Calendar', slug: 'calendar', version: '0.1.0', description: 'Shared calendar for your organization', routes: { directory: 'screens' }, sidebar: { component: 'sidebar' }, slots: ['sidebar.after-calendars'], // ... } ``` Slot names are free-form strings — by convention, namespace them as `sidebar.` so contributors can read where their content lands without having to open the host's sidebar code. Each name must be unique within the manifest; the generator errors on duplicates. In the host's sidebar component, drop `` at the position the slot's name promises: ```tsx filename="tinycld/calendar/sidebar.tsx" import { SidebarDivider, SidebarItem, SidebarNav, SidebarSlot, } from '@tinycld/core/components/sidebar-primitives' export default function CalendarSidebar() { return ( {/* ... mini calendar, calendar list ... */} ) } ``` When no contributions target the slot, `` renders nothing — no extra divider, no empty container. The host's existing layout is unchanged for users in a lean checkout. ## Contributor side: rendering into a slot A contributor package declares a `sidebarContributions` array in its manifest. Each entry says which host's slot to target, and which component to render. ```ts filename="manifest.ts" const manifest = { name: 'Calendar Slots', slug: 'calendar-slots', version: '0.1.0', description: 'Calendly-style booking pages for the calendar.', sidebarContributions: [ { target: 'calendar', slot: 'sidebar.after-calendars', component: 'sidebar-contributions/booking-pages', }, ], // ... } ``` - `target` — slug of the host package. - `slot` — slot name the host declared. - `component` — subpath (no extension) to the React component, resolved through `package.json` `exports`. - `order` — optional sort priority. Default 0. Lower numbers render first; ties broken by contributor slug for stability. Add the matching wildcard to your `package.json` so the generator can resolve the import: ```json filename="package.json" { "exports": { "./sidebar-contributions/*": "./tinycld/calendar-slots/sidebar-contributions/*.tsx" } } ``` ## The component: contributor owns the chrome `` renders each contribution back-to-back with no wrapper. Your component is responsible for its own structure — heading, items, dividers, collapsible state, action buttons. This is deliberate: a "My Booking Pages" section and a "Quick Filters" section want very different layouts, and the slot stays out of the way. ```tsx filename="tinycld/calendar-slots/sidebar-contributions/booking-pages.tsx" import { SidebarHeading, SidebarItem } from '@tinycld/core/components/sidebar-primitives' import { useOrgHref } from '@tinycld/core/lib/org-routes' import { useRouter } from 'expo-router' import { Calendar } from 'lucide-react-native' import { useBookingPages } from '../hooks/use-booking-pages' export default function BookingPagesContribution() { const router = useRouter() const orgHref = useOrgHref() const { pages } = useBookingPages() return ( <> My Booking Pages {pages.map((p) => ( router.push(orgHref('calendar-slots/[id]', { id: p.id }))} /> ))} ) } ``` The component must default-export. The generator imports by default name — a named export will not be picked up. ## Ordering between contributions When multiple packages target the same slot, contributions sort by `order` ascending (default 0). Ties break alphabetically by contributor slug. Set `order` explicitly only when relative position matters between two contributions you don't control — for most cases the default is fine. ## What the generator validates Validation runs at generate time (`tinycld/scripts/generate.ts`, invoked by `pnpm install` and `pnpm run packages:generate`): - A contribution whose `target` is installed but whose `slot` is unknown → **build error** with the list of slots the target actually declares. - A contribution whose `target` is not in the current workspace → **warning, not an error**. This is normal for a partial checkout — the contribution silently won't appear. When the host is installed, the contribution wakes up automatically. - Duplicate slot names within one manifest's `slots` array → **build error**. Typos surface immediately. There's no silent "contribution declared but never rendered" failure mode for present hosts. ## Runtime gating: "only contribute if the host is present" The generator already handles the absent-host case by skipping registration with a warning. If your contributor needs to vary its own behavior — for example, hide a "Sync with mail" feature when `@tinycld/mail` isn't installed — use the runtime package registry: ```tsx import { usePackages } from '@tinycld/core/lib/packages/use-packages' export default function MyContribution() { const installed = new Set(usePackages().map((p) => p.slug)) const mailAvailable = installed.has('mail') // ... } ``` Do **not** add a hard `import '@tinycld/mail/...'` to your contributor. That turns the host into a build-time dependency and breaks the lean-shell guarantee (a workspace without `mail` must still typecheck and run). Filter by slug at runtime instead. ## Built-in slots | Host | Slot | Position | |---|---|---| | `calendar` | `sidebar.after-calendars` | Below the "My calendars" / "Other calendars" / "Subscribed calendars" group, above "Subscribe to calendar" | | `mail` | `sidebar.after-labels` | Below the Labels section, above the Help link | | `drive` | `sidebar.after-tree` | Below the folder tree, above "Shared with me" | To add a new slot to a host, declare its name in the host's `slots` array and render a `` at the corresponding position. ## Troubleshooting | Symptom | Likely cause | |---|---| | Contribution doesn't appear after `pnpm install` | Generator hasn't re-run, or `package.json` `exports` is missing the wildcard for the `component` subpath. | | Build fails: "sidebarContribution targets unknown slot" | Typo in `slot` or the host hasn't declared that slot in `manifest.slots`. The error lists the host's actual slots. | | Build warns: "not installed in this workspace" | The `target` host isn't a present member — normal in a partial checkout. Install the host or remove the contribution. | | Contribution renders but with no content | Component file exists but uses a named export. The generator only picks up default exports. | ----------------------------------------- ## Event sources Source: /docs/anatomy/event-sources.md Description: How one package contributes a read-only event feed to another package's grid. ----------------------------------------- An **event source** is a live, read-only feed of dated items one package contributes to another package's event grid. The shipped example: with both packages installed, the boards package feeds every card's due date to the calendar, where each shows as an all-day item that opens the card when clicked. Where a [sidebar slot](/docs/anatomy/sidebar-slots) contributes *UI*, an event source contributes *data* — the host owns all rendering, plus a per-source visibility toggle in its sidebar. Contributed items cannot be dragged or edited on the host's grid; a press navigates to the item's own app via its `href`. ## Contributor side Declare the source in the manifest and export a hook module: ```ts filename="manifest.ts" const manifest = { // ... eventSources: [ { target: 'calendar', // host package slug id: 'boards-due', // unique per target, [a-z0-9-] only label: 'Card due dates', // the host's sidebar toggle text module: 'calendar-source', // package-exports subpath color: 'graphite', // optional; host resolves the key }, ], } ``` Add the matching literal entry to `package.json` `exports` (`"./calendar-source": "./tinycld/boards/calendar-source.ts"`). The module exports a single hook: ```ts filename="tinycld/boards/calendar-source.ts" import type { EventSourceItem, EventSourceRange } from '@tinycld/core/lib/event-sources/types' export function useEventSource({ start, end }: EventSourceRange): { items: EventSourceItem[] isLoading: boolean } { // Typically one useOrgLiveQuery over your own collections, mapped to // { id, title, start, end, allDay, href } items within [start, end]. } ``` The host mounts the hook inside a collector component and calls it on every render, so it must obey the rules of hooks — a live query is the expected implementation. Items re-render on the host's grid as your data changes, which is what makes the feed live rather than a snapshot. The contract types live in `@tinycld/core/lib/event-sources/types` — the contributor never imports the host. An absent host leaves the contribution silently inactive, so a partial workspace still typechecks and runs (the same lean-shell rule as every cross-package feature). ## Host side A host declares `eventSourceHost: true` in its manifest and consumes `packageEventSources[]` from `@tinycld/core/lib/event-sources/registry`, resolving each source's module with `loadEventSourceModule` (or the `useEventSourceModule` hook) and mounting one collector component per source. Calendar is the reference implementation: `EventSourcesHost.tsx` (collectors), `useSourceEvents.ts` (merge + press routing), `EventSourceToggles.tsx` (sidebar visibility). ## Validation Generation fails fast on the mistakes that would otherwise surface as a silently empty grid: a contribution targeting a *present* package that doesn't declare `eventSourceHost`, a duplicate `(target, id)` pair, or an id outside `[a-z0-9-]` (the host embeds ids in synthetic event identifiers). A contribution targeting an *absent* package is only a warning — that's the normal partial-checkout case. Like sidebar slots, event sources are static and bundled-only: the generator emits a lazy `load` thunk into `tinycld.config.ts`, so a contributor's module is code-split and never in the host's import graph. Runtime-installed packages cannot contribute event sources. ----------------------------------------- ## Automation Source: /docs/anatomy/automation.md Description: Declare triggers and actions so users can build workflow rules on your package's events. ----------------------------------------- A package plugs into the workflow-rules system by declaring **triggers** (events users can build rules on) and **actions** (things rules can do) as pure data. Core does the rest: the engine binds the hooks, evaluates each user's rules, executes actions, and logs every run. The rule builder renders your catalog with no package-side UI code at all. Mail is the reference implementation — `mail/tinycld/mail/automation.ts` and `mail/server/automation.go`. For the user's view of the feature, see [Automation rules](/docs/automation-rules). ## Declaring definitions Three steps. First, point the manifest at a definitions module: ```ts filename="manifest.ts" automation: { definitions: 'automation' }, ``` Second, add the matching entry to the package's exports map — both are required, and the generator reports clearly when one is missing: ```json filename="package.json" "./automation": "./tinycld//automation.ts", ``` Third, write the module itself as a default-exported, **pure-data** object typed against your generated schema: ```ts filename="tinycld/mail/automation.ts" import type { AutomationDefinitions } from '@tinycld/core/lib/automation/types' import type { MailSchema } from './types' const automation = { triggers: [ { id: 'message-received', label: 'A message arrives', collection: 'mail_messages', on: 'create', fields: ['subject', { key: 'sender_email', label: 'Sender' }], }, ], actions: [ /* … */ ], } satisfies AutomationDefinitions export default automation ``` Two constraints that bite: - **Import your own types relatively** (`./types`), never through the `~/` self-alias. This module is config-reachable — the generated `tinycld.config.ts` imports it under the app shell's tsconfig, where your self-alias does not resolve. Same reason `collections.ts` imports `./types`. - **`import type` only.** The module has to stay JSON-serializable data. The schema generic makes collection and column references compile-checked, so a typo'd field name fails your package's typecheck. Everything is referenced by qualified ref — `:`, as in `mail:message-received` or `core:apply-label`. ## Triggers A record trigger names a collection and an operation: | Field | Meaning | |---|---| | `id`, `label` | Kebab-case id; the human sentence ("A message arrives") | | `collection`, `on` | Which collection, and `create` \| `update` \| `delete` | | `watch` | Update triggers only: fire only when one of these columns changed | | `fields` | Allowlist of columns exposed to conditions, templates, and run summaries. Bare keys or `{ key, label }` overrides. **Omit to expose every column** | | `ownerField` | Overrides owner auto-detection | Exposure is a **security surface**. Hidden and system columns are filtered out everywhere — templates, run summaries, condition evaluation, the client catalog — even if you name them explicitly; autodate timestamps are the one sanctioned exception. Columns with no condition operators (`json`, `file`) are skipped automatically, and conditions on non-exposed fields fail closed at evaluation time. Curate `fields` freely. Synthetic triggers (`core:schedule`, `core:manual`) are core-only; the generator rejects them in feature packages. ### Trigger filters If your collection holds rows the trigger's semantics exclude, register a filter from your Go `Register(app)`. It gates the event for **all** rule scopes: ```go automation.RegisterTriggerFilter("mail:message-received", func(app core.App, record *core.Record) bool { return messageIsInbound(record) // drafts and outbound sends are not "received" }) ``` Mail needs this because `mail_messages` also holds drafts and sent mail — "a message arrives" must not fire on them. Without a filter, every matching write dispatches. ### Personal-rule scoping A personal rule fires only on events belonging to its owner. The engine resolves owners in order: a registered resolver, then the declared `ownerField`, then auto-detection of the first `user`/`owner`/`author` relation to `users`. If nothing resolves, **personal rules never fire for that trigger** — org rules are unaffected. Collections with no direct user foreign key register a resolver: ```go automation.RegisterOwnerResolver("mail:message-received", func(app core.App, record *core.Record) []string { return mailboxMemberIDs(app, record) // thread → mailbox → members }) ``` Return every user id the event belongs to. **Treat resolvers as security code** — returning the wrong owner fires other users' personal rules on data they should not see. ## Actions Two kinds. **Record-ops** are declarative writes the engine performs itself, with no handler to register: ```ts { id: 'move-to-folder', label: 'Move to folder', kind: 'record-op', collection: 'drive_items', op: { type: 'update', target: 'trigger-record', set: { parent: { param: 'parent' } } }, params: [{ key: 'parent', field: 'parent', label: 'Destination folder' }], } ``` - `op.type` is `create`, or `update`/`delete` with `target: 'trigger-record'`. Trigger-record ops are offered only for triggers on the same collection, so cross-package compatibility is structural rather than enumerated. - `op.set` values are `{ param: '' }`, `{ context: 'record-id' | 'collection' | 'owner' }`, or a literal. - Params declaring `field: ''` inherit that column's type, relation target, and select options. Novel params declare `type` instead, and a novel `relation` param must also declare `relationTarget` — the generator rejects one without it, since there is no column to inherit a target from. - Every text param accepts `{{field}}` placeholders. Relation params are **never** template-substituted: ids come from a picker, and letting trigger content choose one would be an injection channel. **Native actions** dispatch to a Go handler registered in `Register(app)`, which must run before hooks load: ```go automation.RegisterAction("mail:send-message", func(app core.App, req automation.ActionRequest) error { // req.Params are template-substituted; req.Record is the trigger record // (nil for scheduled/manual rules); req.OwnerID is the rule's owner. return sendFrom(app, req.OwnerID, req.Params) }) ``` Returning an error records that action as failed and **continues to the rule's later actions** — mail-filter semantics. An action whose handler is absent (package not installed) is marked unavailable in the catalog and greyed out; declared-but-unregistered is a supported state, not an error. ## Authorization The engine runs actions with system authority: record-ops write as superuser, and native handlers receive a superuser-powered `app`. **PocketBase collection rules therefore protect nothing on this path.** The engine gates exactly two things; everything else is the handler's job. **Relation params.** A relation param's value is a caller-supplied record id — the rule JSON is client-authored, so the picker is a convenience, not a boundary. Every non-empty relation param passes two fail-closed layers: an engine-owned floor (the rule owner must pass the target collection's `viewRule` for that record), then your registered authorizer, which answers the write-level question the engine cannot know: ```go automation.RegisterRelationAuthorizer("drive:move-to-folder", "parent", func(app core.App, req automation.ActionRequest, id string) error { return destinationWritableBy(app, req.OwnerID, id) }) ``` Declaring a relation param **without** registering its authorizer is refused at execution and greys the action out. A deliberate pass is fine when the model is genuinely org-wide — core's `apply-label` authorizer returns nil with a comment saying why — the point is that the decision is written down where review can see it. **Trigger-record writes.** A record-op targeting the trigger record still writes as superuser, so the engine requires the rule owner to pass the collection's `updateRule` or `deleteRule` for that record. A nil rule means superuser-only in PocketBase and is refused rather than waved through. This is what stops an owner who can merely *see* a record from moving or deleting it through automation when the same edit through the UI would be refused. Register a `TriggerRecordAuthorizer` (optional here) when the collection's rules are looser than its write semantics. Everything else stays yours: records your handler looks up itself, text params naming recipients or addresses, and who an action acts *as*. ## Execution semantics you inherit You implement none of this, but should design against it: - Org rules run before personal rules, each in the user's `order`; `stop_processing` halts downstream rules. - Engine writes carry provenance, so a rule never re-fires on its own write. Action→trigger cascades cap at depth 3, logged as `chain-depth-exceeded`. - Actions time out at 30s — **abandoned, not killed**. Don't write handlers that hold locks past that. - Every dispatch writes a run row, matched or not. Runs are pruned to 200 per rule, and around 20 consecutive fully-failed runs auto-disable the rule and notify its owner. - Text condition operators are case-insensitive; `is`/`is_not` match any element of a multi-value field; dates accept PocketBase datetimes, bare dates, or RFC 3339. ### Native handlers that write trigger-bound collections A record-op's writes are stamped with provenance automatically. The engine cannot do that for a native handler — it hands you an `app` and never sees your `Save` — so an unstamped write reads as an ordinary user edit and **re-fires the trigger at depth 0, forever**. If your handler writes to a collection any installed trigger watches, go through `MarkEngineWrite`: ```go record.Set("id", core.GenerateDefaultRandomId()) // the sentinel is keyed by id return automation.MarkEngineWrite(req, record.Id, func() error { return app.Save(record) }) ``` It stamps the rule id and depth, runs the write, and removes the stamp if the write fails, so a failed attempt cannot suppress the next genuine user edit. Calendar's `actionCreateEvent` is the reference: `calendar:event-added` watches the very collection it creates into. ## Your ingress must finish before it fires a trigger **If your package writes records around the one a trigger watches, wrap that write path in `app.RunInTransaction`.** This is the one execution detail that is genuinely your responsibility, and getting it wrong produces a rule that reports success and does nothing visible. Dispatch binds to `OnRecordAfterCreateSuccess` and its update/delete equivalents. Outside a transaction, that hook fires *the instant the record is saved* — while the rest of your ingress function is still running, with rule actions executing concurrently on a worker goroutine. Mail hit this. Its inbound path stored the message (firing the trigger), then wrote each recipient's thread state with `folder: "inbox"`. A move-to-folder action archived the thread, and delivery's own write landed afterwards and put it back: ```go storeMessage(app, thread.Id, stored) // fires the trigger; actions start ... ensureThreadState(app, thread.Id, userID, "inbox", false) // clobbers the action ``` The run logged `matched: true` with `status: "ok"`. Nothing was red, and the message simply stayed in the inbox. The fix is structural, because the after-success hooks are delayed until the transaction commits and are not triggered on rollback: ```go return app.RunInTransaction(func(txApp core.App) error { // every write for this delivery, using txApp return nil }) // hook fires here, once, over settled state ``` You get two properties free: rules fire exactly once with all related rows in place, and a failure partway through means rules never fire at all rather than firing against a half-written record. Reordering so the triggering save comes last also works, but leaves the ordering load-bearing — the next person to add a write reintroduces the bug. Prefer the transaction. Worth auditing in your package: bulk importers that save in a loop, and any path writing a parent record after its children. ## The catalog At boot the engine resolves every declared trigger and action against live collection metadata — field types, relation targets, select options, native handler availability — and materializes rows into a read-only catalog collection the builder live-queries. You never write catalog code. Two consequences: relation params get a record picker automatically, with the display field chosen from `name`, `title`, `label`, `subject`, `display_name`, `email`, or `username`; and an action referencing a collection that doesn't exist in a deployment shows as unavailable rather than vanishing. ## Verifying your declarations - `pnpm exec tinycld-pkg typecheck` in your member catches bad collection/field references. `pnpm run packages:generate` from `tinycld/` runs the structural validation — kebab-case ids, unique ids, param references, no synthetic triggers outside core — and **fails the build** on a violation. See [Generated files](/docs/reference/generated-files) for what it emits. - Go registrations are unit-testable against real migrations; see `mail/server/automation_test.go`. - For e2e, drive the builder UI and use your package's real ingress, never raw PocketBase writes. `mail/tests/rules.spec.ts` is the reference. - **Assert the visible effect, not the run row.** A run saying `matched: true` / `status: "ok"` only proves the action was called — it survives the ingress race above, and it survives an action that writes somewhere no view reads. ## Gotchas - Never import another sibling package from `automation.ts`. The lean-shell rule applies; cross-package reach happens through record-ops on your own collections and core's built-ins. - An action writing rows no UI ever reads is worse than no action: the rule "succeeds" and the user sees nothing. Verify the whole loop — trigger, action, visible effect — before declaring it. - Definitions are data, not migrations, so you may evolve them freely within a version. But **renaming an `id` orphans existing rules**, which surface as unknown references. Treat published trigger and action ids as API. ----------------------------------------- ## Server Source: /docs/anatomy/server.md Description: How a package ships a Go server extension that the app shell's PocketBase loads on boot. ----------------------------------------- A package can extend the app shell's Go server - add custom API endpoints, register PocketBase record hooks in Go, run background tickers, or bind IMAP/SMTP services. The app shell loads each present package's server module at startup via a single generated entry point. Most packages do not need this: if all your server-side logic fits in PocketBase JS hooks, stick with `pb-hooks/` instead. For the Go module plumbing (the generated `go.work`, how the build picks up each member's module), see [Go server integration](/docs/go-server). This page covers the layout a package author owns. ## Declaring a server module Add a `server` field to the manifest with two strings: ```ts filename="manifest.ts" server: { package: 'server', module: 'tinycld.org/packages/example' }, ``` - `package` - subdirectory name containing the Go module. Convention is `'server'`. - `module` - Go module path declared in that subdirectory's `go.mod`. Use the `tinycld.org/packages/` namespace so nothing collides with third-party modules. The generator lists each present feature's server module in a generated `tinycld/server/go.work`, so the build resolves your module's location automatically. On `pnpm run packages:generate` the wiring stays in sync with the set of present members. ## Directory layout A minimal server package looks like this: ``` example/ server/ go.mod go.sum register.go endpoints.go ``` `go.mod` declares the module path you put in the manifest, and requires core's module: ```go filename="server/go.mod" module tinycld.org/packages/example go 1.25.0 require ( github.com/pocketbase/pocketbase v0.38.1 tinycld.org/core v0.0.0 ) ``` Keep the PocketBase version aligned with the app shell's (`tinycld/server/go.mod`) - the app's `go.mod` is the source of truth, and `tinycld/server/go.work` lashes every module to the same PB build. Bumping the shell is what drags every package's `go.mod` along. Depend only on what your package actually uses. `@tinycld/core`'s Go module (`tinycld.org/core`, nested at `tinycld/core/server/`) carries the shared dependencies (audit logging, auth helpers, the PocketBase runtime itself) and exposes them under `tinycld.org/core/...` subpackages you can import. You do **not** add a `replace` directive for it - the generated `tinycld/server/go.work` resolves every member module's location. ## The Register function Each package server must export `func Register(app *pocketbase.PocketBase)`. The app shell's generated `tinycld/server/package_extensions.go` calls it once (via `registerPackageExtensions(app)`), before the server starts. This is where you bind endpoints, register hooks, and kick off any long-lived goroutines: ```go filename="server/register.go" package example import ( "net/http" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" "tinycld.org/core/audit" ) func Register(app *pocketbase.PocketBase) { audit.RegisterCollection(app, "example_items", &audit.CollectionConfig{ ExtractLabel: audit.LabelFromField("title"), }) app.OnServe().BindFunc(func(e *core.ServeEvent) error { e.Router.GET("/api/example/ping", func(c *core.RequestEvent) error { return c.JSON(http.StatusOK, map[string]string{"ok": "pong"}) }) return e.Next() }) } ``` `Register` runs whether or not the package has any migrations or collections - it's wired by manifest alone. If you need access to app state across requests, hold it in package-level variables (as `@tinycld/mail` does with its settings cache) or attach it to a struct you construct inside `Register`. ## When you do not need server code Skip the `server` field entirely if your package only needs: - PocketBase record hooks expressible in JS → put a `.pb.js` file in `pb-hooks/` and declare `hooks: { directory: 'pb-hooks' }` in the manifest. - Schema changes → migrations in `pb-migrations/` handle those. - Client-only behavior → nothing server-side is required at all. Go server extensions exist for the cases where JS hooks aren't enough: network servers (IMAP, SMTP, WebRTC signalling), background workers, HTTP endpoints that need streaming or binary handling, or logic that has to run outside a record event. ## Testing server code Standard Go test files (`*_test.go`) live next to the package source inside `server/`. Run them from inside the member: ```sh cd ~/code/tinycld/example/server go test ./... ``` The member's Go module is self-contained for testing - it pins the same PocketBase version core uses, so test code hits the same API surface it will in production. Within an assembled workspace, the generated `tinycld/server/go.work` ties this module, `core`, and the app together, so a build from `tinycld/server/` reflects exactly what the app ships. ----------------------------------------- ## Seed Source: /docs/anatomy/seed.md Description: How a package ships sample data for local development. ----------------------------------------- A seed script populates sample records when a developer runs `pnpm run db:seed` from `tinycld/`. The seeder creates the test user and org, then calls each present package's seed function in turn. If your package has useful dev data (example contacts, a starter mailbox, a demo calendar), ship a seed. ## Declaring a seed Point `seed.script` at a subpath (no extension) inside the package: ```ts filename="manifest.ts" seed: { script: 'seed' }, ``` And expose the module in `package.json`: ```json filename="package.json" { "exports": { "./seed": "./tinycld/example/seed.ts" } } ``` ## The seed function Default-export an async function that takes a PocketBase client and a context object: ```ts filename="tinycld/example/seed.ts" import type PocketBase from 'pocketbase' interface SeedContext { user: { id: string; email: string; name: string } org: { id: string } userOrg: { id: string } } export default async function seed(pb: PocketBase, { org, userOrg }: SeedContext) { await pb.collection('example_items').create({ title: 'Sample item', org: org.id, created_by: userOrg.id, }) } ``` `pb` is already authenticated as the superuser, so you can write to any collection. The context exposes the three IDs you'll almost always need: - `user.id` - the test user record. - `org.id` - the test org. Use this for any `org` relation field. - `userOrg.id` - the membership record linking the user to the org. Use this for `created_by` fields in packages that track per-org authorship. The seeder runs your seed once per invocation. The database is wiped before seeding, so you don't need idempotency guards - write records straight through. ## Keep seeds small A seed exists so a developer can open the UI and see something. A handful of rows per collection is plenty - enough to exercise list views, navigation, and empty-state transitions. Avoid pulling in large fixture files or generating thousands of records; that's what Playwright factory helpers are for. If you need multiple related records, create parents first and feed their IDs into children. Wrap unrelated groups in functions so the seed file stays readable: ```ts filename="tinycld/example/seed.ts" export default async function seed(pb: PocketBase, ctx: SeedContext) { await seedLabels(pb, ctx) await seedExampleItems(pb, ctx) } ``` The generator emits `tinycld/tinycld.seeds.ts`, a Node-only list mapping each present package's slug (and its `dependencies`) to its default-exported seed function. `tinycld/scripts/seed-db.ts` imports that list and iterates it after creating the test user and org. You don't interact with the generated file directly. ----------------------------------------- ## Tests Source: /docs/anatomy/tests.md Description: How a package's unit and e2e tests are discovered and run via the tinycld-pkg CLI. ----------------------------------------- A package can ship unit tests (vitest) and end-to-end tests (Playwright). Each member carries its own `vitest.config.ts` and `playwright.config.ts` that inherit the app shell's canonical config, and the `tinycld-pkg` CLI runs them - scoped to one member, or across every present member with `--all`. You don't need to wire global config; place files in the right location and follow the naming conventions below. ## Directory and naming Put tests under a `tests/` folder at the root of the package. Unit tests end in `.test.ts`; e2e specs live under `tests/e2e/` and end in `.spec.ts`: ``` example/ tests/ manifest.test.ts e2e/ example.spec.ts ``` Declare the directory in the manifest: ```ts filename="manifest.ts" tests: { directory: 'tests' }, ``` ## How discovery works Each member ships two small config files that defer to the app shell's canonical setup: - **`vitest.config.ts`** merges the app's `vitest.config` (so `@tinycld/core/*` and other aliases resolve identically), adds the package's own `~/*` source alias, and scopes the run to this package's `tests/**/*.test.{ts,tsx}`. - **`playwright.config.ts`** spreads the app's `playwright.config` (its webServer + browser setup) and points `testDir` at this package's `tests/e2e` (routed through the workspace `node_modules/@tinycld/` symlink so node resolution finds `@playwright/test` in the hoisted install). The `tinycld-pkg` CLI runs the right config for whichever member you're in: ```sh # from any member directory pnpm exec tinycld-pkg test # vitest for this member pnpm exec tinycld-pkg test:e2e # playwright for this member pnpm exec tinycld-pkg check # typecheck + unit for this member ``` From anywhere, add `--all` to run across every present member: ```sh pnpm exec tinycld-pkg test --all pnpm exec tinycld-pkg test:e2e --all pnpm exec tinycld-pkg check --all ``` The app shell also exposes `pnpm run test`, `pnpm run test:e2e`, and `pnpm run check` (single member = the app), plus `pkg:test:unit` / `pkg:check` / `pkg:test:e2e` wrappers that run `--all`. ## What you can import Package tests inherit the app shell's resolver context, so the same aliases work: - `~/...` resolves to your package's own nested source (`~/tinycld//...`). - `@tinycld/core/...` resolves to the `core` member. Import `@tinycld/core/lib/pocketbase`, `@tinycld/core/ui/form`, etc. - `@tinycld//...` resolves to another present feature member - useful only if your package legitimately depends on another (it usually shouldn't). - Relative imports resolve within your package. Use the app shell's test helpers (`@tinycld/core` test helpers, Playwright page objects under the app's `tests/`) rather than reinventing fixtures. If a helper you need doesn't exist yet and is broadly reusable, it belongs in `@tinycld/core`; package-specific helpers belong in the package. ## A minimal e2e spec ```ts filename="tests/e2e/example.spec.ts" import { test, expect } from '@playwright/test' test.describe('Example', () => { test('list screen renders', async ({ page }) => { await page.goto('/a/example') await expect(page.getByText('Example')).toBeVisible() }) }) ``` Playwright boots the app shell against the seeded dev org, so the URL above works if the package is a present member and has a screen at `tinycld/example/screens/index.tsx`. ## Tests for packages that aren't present `tinycld-pkg --all` only runs the members that are present in the workspace. A feature that isn't cloned in simply contributes no tests - cloning or removing a member flips its tests in and out of the run with no config changes on your part. ========================================= # Tasks ========================================= ----------------------------------------- ## Routing Source: /docs/tasks/routing.md Description: Navigate between screens with useOrgHref instead of literal paths, so the app's route prefix has one definition. ----------------------------------------- App routes live under `/a/...`. The `/a` segment is a constant — it namespaces the app's own routes so they can never collide with a protocol mount (`/dav`, `/caldav`), the public share tree (`/p`), or a marketing path. It is **not** an org slug: each org is served on its own host, so nothing is interpolated into it. Use `useOrgHref()` from `@tinycld/core/lib/org-routes` for every push, replace, and `` inside the app. Hard-coding `/todo/123` skips the prefix and lands on `+not-found`; hard-coding `/a/todo/123` works until the prefix ever moves, and then every literal has to be found by hand. ## The pattern ```tsx import { useOrgHref } from '@tinycld/core/lib/org-routes' import { Link, router } from 'expo-router' export default function TodoIndex() { const orgHref = useOrgHref() return ( router.push(orgHref('todo/new'))}> New todo View todo ) } ``` `orgHref()` takes a short path **relative to the app root** — no leading `/a` — plus optional dynamic params, and returns an Expo Router `Href`. Outside a component (a route resolver, a redirect helper) use the plain `appHref(path)` from the same module. `useOrgHref` delegates to it, so both share one definition of the prefix. ## What NOT to do ```tsx // ❌ Literal path, misses the app prefix — resolves to +not-found router.push('/todo/new') // ❌ Hardcodes the prefix; won't follow if it ever changes router.push('/a/todo/new') // ❌ Manual concatenation — easy to typo, no compile-time check router.push(`/a/${'todo'}/new`) ``` ## Dynamic params Wrap the param name in `[brackets]` in the path argument and pass the value through the second argument: ```tsx router.push(orgHref('todo/[id]', { id: todoId })) router.push(orgHref('mail/[folder]/[id]', { folder: 'inbox', id: threadId })) router.push(orgHref('settings/[...section]', { section: ['mail', 'provider'] })) ``` Catch-all params (`[...section]`) take an array. Plain query params (no bracket in the path) work too: ```tsx router.push(orgHref('mail', { folder: 'sent' })) // → /a/mail?folder=sent ``` `orgHref` returns a plain **string** when there are no params and an object only when params are present. That distinction is deliberate: an object href is a new identity on every render, which makes `` re-navigate forever. Don't "simplify" it into always returning an object. ## When to use literal paths Public routes — declared via the manifest's `publicRoutes` field — live outside the app tree, namespaced under `/p//`. Drive's share-link landing page is the canonical example: ```tsx // Public page; reachable without a session router.push(`/p/drive/share/${token}`) ``` Protocol mounts (`/dav`, `/caldav`, `/carddav`) and the API (`/api`) are likewise outside the app prefix. Pre-auth screens (`/a/connect`, `/a/pick-org`, `/a/setup`, `/a/accept-invite/[token]`, `/a/reset-password/[token]`) **are** under `/a` and should be reached via the exported `CONNECT_HREF` / `PICK_ORG_HREF` constants or `appHref`, not written out by hand. ## Switching servers or orgs Moving a user to another org isn't routing — each org has its own host, so it's an origin change, not a path change. See `@tinycld/core/lib/org-url`. ## Testing `useOrgHref` needs no context or mocking — call it and assert on what it returns. If a test does stub it, have the stub delegate to the real `appHref` rather than inlining the prefix, so the fake can't drift from the app's actual route shape. ----------------------------------------- ## PocketBase auth rules Source: /docs/tasks/auth-rules.md Description: Add listRule, viewRule, createRule, updateRule, and deleteRule to your collection migrations so non-superuser inserts and queries don't fail. ----------------------------------------- Every collection in TinyCld needs auth rules. Without them, PocketBase falls back to "superusers only" and every insert, list, view, update, and delete from a non-superuser session fails with: > Only superusers can perform this action. You add rules at collection-creation time inside your `pb-migrations/_create_.js` file. ## The five rules PocketBase collections accept five auth rules: | Rule | When it fires | Default if omitted | |---|---|---| | `listRule` | filtered list / search | superuser-only | | `viewRule` | single-record fetch by id | superuser-only | | `createRule` | record creation | superuser-only | | `updateRule` | record update | superuser-only | | `deleteRule` | record delete | superuser-only | Each rule is a string in PocketBase's filter language. It evaluates against the **record being accessed** plus the special `@request.auth` and `@request.data` namespaces. When the expression is truthy, the action is allowed. ## Org-scoped (most common) Every TinyCld org-scoped collection has an `owner` relation pointing at a `user_org` row. The user-facing rule is "the calling user owns the user_org that owns this row": ```js new Collection({ type: 'base', name: 'todo_items', listRule: 'owner.user = @request.auth.id', viewRule: 'owner.user = @request.auth.id', createRule: 'owner.user = @request.auth.id', updateRule: 'owner.user = @request.auth.id', deleteRule: 'owner.user = @request.auth.id', fields: [ { name: 'name', type: 'text', required: true, max: 200 }, { name: 'owner', type: 'relation', required: true, collectionId: 'pbc_user_org_01', cascadeDelete: true, maxSelect: 1, }, // ... ], }) ``` `pbc_user_org_01` is the stable id of the bundled `user_org` collection. The dot-traversal `owner.user` walks the `owner` relation to the `user_org` row, then reads its `user` field — a relation to the user record — and compares it to the calling auth id. This is the pattern used by `@tinycld/contacts`, `@tinycld/calendar`, and friends. ## User-scoped (no org) If your data isn't org-scoped — user preferences, theme settings, anything tied to a single user — the rule becomes simpler. You'd typically add an `owner` relation directly to the `users` collection (id `_pb_users_auth_`): ```js listRule: 'owner = @request.auth.id', viewRule: 'owner = @request.auth.id', createRule: 'owner = @request.auth.id', updateRule: 'owner = @request.auth.id', deleteRule: 'owner = @request.auth.id', ``` ## Public read, owner write Public share-link content, blog posts, anything where read is open but writes are gated: ```js listRule: '', // empty string = anyone viewRule: '', createRule: 'owner = @request.auth.id', updateRule: 'owner = @request.auth.id', deleteRule: 'owner = @request.auth.id', ``` Empty string `''` means the rule allows everyone. `null` (or omitting the rule) means superusers only — which is rarely what you want at the API level. ## Locked down If a collection is only ever written by Go server hooks (audit logs, system events), set every rule to `null`: ```js listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, ``` The Go side bypasses rules with `app.Save(record)` calls in hooks; `null` rules at the API level enforce that no client can write directly. ## Common patterns - **Read-only after creation**: set `updateRule: null` (or just omit it). - **Org admins only**: walk to the user_org row and check the role: `owner.user = @request.auth.id && owner.role = 'admin'`. - **Time-limited access**: `expires_at > @now` (PocketBase fills `@now` automatically). ## Where to find existing examples Every present feature package's `pb-migrations/` is a working reference: - Contacts: `~/code/tinycld/contacts/pb-migrations/1712000000_create_contacts.js` - Mail: `~/code/tinycld/mail/pb-migrations/1713000000_create_mail_collections.js` - Calendar: `~/code/tinycld/calendar/pb-migrations/1715000000_create_calendar_collections.js` - Drive: `~/code/tinycld/drive/pb-migrations/1716000000_create_drive_collections.js` The `@tinycld/bootstrap` scaffolder writes a starter migration that already includes the org-scoped rules and an `owner` field; rename or remove the field as your data model evolves. ## Diagnostics If you see "Only superusers can perform this action" at runtime, the rule for the action you tried (insert → `createRule`, list → `listRule`, etc.) is `null` or missing. Run `pnpm run db:reset` from `tinycld/` after editing the migration so the new rules take effect — PocketBase doesn't hot-reload rule changes from a previously-applied migration. ----------------------------------------- ## Query data Source: /docs/tasks/query-data.md Description: Read from PocketBase inside a package using useOrgLiveQuery and pbtsdb collections. ----------------------------------------- Every read in a package goes through pbtsdb and TanStack DB. You grab a collection handle with `useStore`, describe what you want with TanStack DB operators, and wrap the whole thing in `useOrgLiveQuery` so the query auto-scopes to the active organization. ## Before you query Your package must declare its collections (see [Collections](/docs/anatomy/collections)). Once the generator has wired them into `MergedSchema`, the name you return from `registerCollections` is the name you pass to `useStore`. ## The pattern ```tsx filename="screens/index.tsx" import { useStore } from '@tinycld/core/lib/pocketbase' import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query' import { eq } from '@tanstack/db' export default function ExampleList() { const [exampleCollection] = useStore('example') const { data } = useOrgLiveQuery((query, { orgId }) => query .from({ example: exampleCollection }) .where(({ example }) => eq(example.org, orgId)) .orderBy(({ example }) => example.title, 'asc') ) return } ``` `useStore` accepts variadic collection names and returns a tuple. Destructure it positionally: ```ts const [tagsCollection] = useStore('tags') const [jobsCollection, addressesCollection] = useStore('jobs', 'addresses') ``` ## The query DSL TanStack DB's builder mirrors SQL. All operators are imported from `@tanstack/db`: ```ts import { and, eq, gt, inArray, like, or } from '@tanstack/db' query .from({ item: itemsCollection }) .join( { user: usersCollection }, ({ item, user }) => eq(item.owner, user.id), 'left' ) .where(({ item }) => and( eq(item.org, orgId), or(eq(item.status, 'active'), gt(item.updated, lastWeek)) ) ) .orderBy(({ item }) => item.updated, 'desc') .select(({ item, user }) => ({ ...item, ownerName: user.name })) ``` Use `.select()` when you want to compute a derived shape - it runs in the reactive pipeline, so downstream re-renders only happen when the computed value changes. ## Org scoping :::warning Never call raw `useLiveQuery` from `@tanstack/react-db` directly. Use `useOrgLiveQuery` from `@tinycld/core/lib/use-org-live-query`. It provides the scope to your callback, holds the query disabled until that scope is known, and auto-includes it in the dependency array. Using raw `useLiveQuery` flashes the previous org's data into the new org for a frame and is one of the most reported bugs in packages that reinvent it. ::: The only exceptions are bootstrap hooks that org-scoping itself depends on - `@tinycld/core`'s `use-org-info`, `use-current-role`, `use-current-user-org` - and genuinely user-level queries (theme preferences, notification settings). Packages should use `useOrgLiveQuery` everywhere. ## Where queries live Prefer inline queries in the screen or component that uses them. A hook per query makes the data flow invisible to future readers and encourages accidental duplication. Extract a shared hook only when the exact same query is called in three or more places - until then, the inline form is the honest one. ```tsx // good - data flow is visible at the point of use export default function ExampleList() { const [exampleCollection] = useStore('example') const { data } = useOrgLiveQuery((query, { orgId }) => query.from({ example: exampleCollection }).where(({ example }) => eq(example.org, orgId)) ) return } ``` The `eq` operator (and `and`, `or`, `gt`, etc.) is re-exported from `@tinycld/core/lib/pocketbase` for convenience; you can also import it directly from `@tanstack/db`. Either works — pick one per file and stay consistent. ## Common mistakes - **Forgetting the `org` filter.** Every org-scoped record has an `org` column; every query of that collection needs `eq(example.org, orgId)` in its `where`. `useOrgLiveQuery` hands you `orgId` for exactly this reason. - **Calling `useStore` inside a loop or callback.** `useStore` is a React hook - top-level of a component only, never conditional. - **Over-specifying types.** TanStack DB infers record types from `MergedSchema`. Resist the urge to annotate `data` or the callback parameters; let inference do its job. For writes, see [Mutate data](/docs/tasks/mutate-data). ----------------------------------------- ## Mutate data Source: /docs/tasks/mutate-data.md Description: Write to PocketBase with generator-based mutations that await pbtsdb transactions. ----------------------------------------- Writes go through `useMutation` from `@tinycld/core/lib/mutations` - not directly from `@tanstack/react-query`. The wrapper accepts a generator function as `mutationFn` and awaits each yielded pbtsdb `Transaction` automatically. You describe what should change in the order it should change; the wrapper handles the optimistic update, persistence, and rollback-on-failure machinery. ## Before you mutate Your screen needs a collection handle (from `useStore`) and, usually, a form. See [Forms](/docs/tasks/forms) for the standard react-hook-form + zod setup this page pairs with. ## The pattern ```tsx filename="screens/new.tsx" import { useStore } from '@tinycld/core/lib/pocketbase' import { useMutation } from '@tinycld/core/lib/mutations' import { handleMutationErrorsWithForm } from '@tinycld/core/lib/errors' import { newRecordId } from 'pbtsdb' import { useRouter } from 'expo-router' import { useForm } from 'react-hook-form' export default function NewExample() { const router = useRouter() const [exampleCollection] = useStore('example') const { handleSubmit, setError, getValues } = useForm() const create = useMutation({ mutationFn: function* (data: FormData) { yield exampleCollection.insert({ id: newRecordId(), ...data }) }, onSuccess: () => router.back(), onError: handleMutationErrorsWithForm({ setError, getValues }), }) return } ``` :::tip Use `useMutation` from `@tinycld/core/lib/mutations`, not the one from `@tanstack/react-query` directly. The `@tinycld/core` wrapper understands generator `mutationFn`s - the upstream one does not. ::: `@tinycld/core/lib/mutations` also exports a `mutation()` helper that wraps a generator into a plain async function. This is the form most shipped feature code uses, because it makes the type of `mutationFn` look ordinary (a `(vars) => Promise`) and plays nicely with editor go-to-definition: ```tsx import { mutation, useMutation } from '@tinycld/core/lib/mutations' const create = useMutation({ mutationFn: mutation(function* (data: FormData) { yield exampleCollection.insert({ id: newRecordId(), ...data }) }), onSuccess: () => router.back(), onError: handleMutationErrorsWithForm({ setError, getValues }), }) ``` The two forms are interchangeable — `useMutation` detects a bare generator function and applies the same `performMutations` wrapper internally. Pick one per file. ## Yielded transactions Every pbtsdb collection method (`insert`, `update`, `delete`) returns a `Transaction`. When you `yield` one inside the generator, core awaits its `isPersisted` promise before advancing to the next statement. You write the code as if it were synchronous: ```ts mutationFn: function* (data: FormData) { yield exampleCollection.insert({ id: newRecordId(), ...data }) yield tagsCollection.update(data.tagId, { last_used: new Date().toISOString() }) } ``` :::note Generator-based mutation functions automatically await pbtsdb `Transaction` objects when yielded. You never have to write `await` inside the generator - just `yield`. ::: Sequential writes above; parallel writes by yielding an array: ```ts mutationFn: function* (data: FormData) { yield [ exampleCollection.insert({ id: newRecordId(), ...data }), auditCollection.insert({ id: newRecordId(), action: 'create' }), ] } ``` Core awaits all transactions in the array in parallel, then advances. ## Outside of a component If you need to perform a mutation from a plain async function (a seed script, an event handler outside the render tree), use `performMutations`: ```ts import { performMutations } from '@tinycld/core/lib/mutations' await performMutations(function* () { yield exampleCollection.insert({ id: newRecordId(), title: 'Seed' }) }) ``` Same generator semantics; no React involvement. ## Error handling `handleMutationErrorsWithForm` maps PocketBase field-level validation errors back onto the matching react-hook-form fields via `setError`. Form-wide errors (network failures, permission denials) become a root-level error you can render with ``. Together they cover the normal shape of a form submit - you usually don't need a custom `onError`. For non-form mutations, pass a plain `onError: (err) => captureException('.', err)` (from `@tinycld/core/lib/errors` — see [Logging](/docs/tasks/logging)) and handle the display however the surface needs. ## Common mistakes - **Importing `useMutation` from `@tanstack/react-query`.** The upstream hook doesn't understand generator functions - it will call yours once, get back a `Generator` object, and immediately report success. Always import from `@tinycld/core/lib/mutations`. - **Forgetting `newRecordId()`.** pbtsdb requires you to assign the id client-side so optimistic updates can reference it. PocketBase accepts any 15-char alphanumeric id. - **Using `await` inside the generator.** `yield` a transaction, don't `await` it. The generator shape is what makes the await implicit - awaiting explicitly double-resolves the promise. For the form side of this pattern, see [Forms](/docs/tasks/forms). ----------------------------------------- ## Forms Source: /docs/tasks/forms.md Description: Build forms with react-hook-form, zod validation, and the themed input components from @tinycld/core. ----------------------------------------- Every form in TinyCld uses react-hook-form for state, zod for validation, and the themed input components from `@tinycld/core/ui/form` (`TextInput`, `SelectInput`, and friends) for rendering. The three layer neatly: zod describes the shape, react-hook-form owns the state and submit lifecycle, and the inputs wire themselves to `Controller` without ceremony. ## Why not useState Form fields held in `useState` force you to reimplement the parts react-hook-form already solves: per-field error state, dirty tracking, submit-time validation, reset on success. Every hand-rolled form grows into a small buggy copy of react-hook-form. Use the real thing from the start. ## The pattern ```tsx filename="screens/new.tsx" import { useForm, zodResolver, z, TextInput, FormErrorSummary } from '@tinycld/core/ui/form' import { useStore } from '@tinycld/core/lib/pocketbase' import { useMutation } from '@tinycld/core/lib/mutations' import { handleMutationErrorsWithForm } from '@tinycld/core/lib/errors' import { newRecordId } from 'pbtsdb' import { useRouter } from 'expo-router' import { Button } from '@tinycld/core/ui/button' const schema = z.object({ title: z.string().min(1, 'Title is required'), notes: z.string().max(1000).optional(), }) type FormData = z.infer export default function NewExample() { const router = useRouter() const [exampleCollection] = useStore('example') const { control, handleSubmit, setError, getValues, formState } = useForm({ resolver: zodResolver(schema), defaultValues: { title: '', notes: '' }, }) const create = useMutation({ mutationFn: function* (data: FormData) { yield exampleCollection.insert({ id: newRecordId(), ...data }) }, onSuccess: () => router.back(), onError: handleMutationErrorsWithForm({ setError, getValues }), }) return ( <> ) } ``` Everything you need is re-exported from `@tinycld/core/ui/form`: `useForm`, `Controller`, `zodResolver`, `z`, and the input components. You don't import from `react-hook-form` or `@hookform/resolvers/zod` directly - the `@tinycld/core/ui/form` barrel is the canonical entry point. ## Input components - **`TextInput`** - single-line or multiline text. `label`, `placeholder`, `multiline`, and the usual RN text input props. - **`NumberInput`** - numeric input with locale-aware parsing. - **`SelectInput`** - a dropdown that opens a themed actionsheet. Pass an `options: SelectOption[]` array. - **`TextAreaInput`** - a taller multiline with auto-grow. - **`Toggle`** - boolean switch. Every component takes `control` and `name` and wires itself through `` internally. You don't manage refs, state, or `onChangeText` by hand. They all render field-level errors from `formState.errors[name]` in a consistent style - no separate error markup needed. ## FormErrorSummary Render `` once at the top of the form for form-wide errors (anything `setError('root', ...)` produces) and cross-field zod errors. It renders nothing when there are no top-level errors, so it's safe to always include. ## Submitting through useMutation Always pass `handleSubmit(mutation.mutate)` to your submit button. `handleSubmit` validates first and only calls your handler with typed, valid data; the mutation layer then handles the pbtsdb transactions and optimistic updates. The `handleMutationErrorsWithForm` helper mentioned above translates PocketBase validation errors back onto specific fields - you don't have to wire field errors by hand. ## Common mistakes - **Using `useState` for field values.** Don't. See above. - **Importing `useForm` from `react-hook-form` directly.** Use the `@tinycld/core/ui/form` barrel so the package stays in sync with `@tinycld/core`'s resolver and zod versions. - **Calling `mutation.mutate` without `handleSubmit`.** You'll submit invalid data and skip validation entirely. For the data-write side of this pattern, see [Mutate data](/docs/tasks/mutate-data). ----------------------------------------- ## UI state Source: /docs/tasks/ui-state.md Description: Share UI state across components with Zustand stores, with optional AsyncStorage persistence. ----------------------------------------- Shared UI state - sidebar open/closed, dialog targets, compose mode, visible calendar IDs, the currently-active section - lives in Zustand stores. Each store is a tiny module exporting a hook; components subscribe to exactly the fields they need. You don't thread props through layouts and you don't stand up a React context for every new piece of state. ## When to reach for Zustand Use a store when two or more components need to read or write the same piece of UI state without a direct parent-child relationship. Examples from shipped packages: - Mail: compose window open state, selected thread list. - Drive: upload queue visibility, search bar state, rename-dialog target. - Calendar: popover state, visible calendar IDs. ## When not to Zustand is for UI state. It is not the right tool for: - **Server data** - use `useOrgLiveQuery` (see [Query data](/docs/tasks/query-data)). - **Form state** - use `useForm` (see [Forms](/docs/tasks/forms)). - **Mutation state** - `useMutation` tracks `isPending` / `isError` for you. - **URL state** - Expo Router params are the source of truth for anything that should survive a refresh in the URL. :::warning Don't use React Context for new shared UI state. Context re-renders every consumer on every change - Zustand with selectors only re-renders components that read the specific field that changed. For anything that might be touched by more than one component, the store is the right tool. ::: ## Where stores live Store files live under `stores/` inside the package's nested source tree, alongside `screens/`, `components/`, and `hooks/`: ``` mail/ tinycld/mail/ stores/ compose-store.ts thread-list-store.ts ``` `@tinycld/core`'s own stores are under `@tinycld/core/lib/stores/`. Don't add package-specific state to those stores - keep each store co-located with the feature it serves. ## The pattern ```ts filename="tinycld/mail/stores/compose-store.ts" import { create, persist, asyncStorage } from '@tinycld/core/lib/store' interface ComposeState { isOpen: boolean draftId: string | null recentSubjects: string[] open: (draftId: string) => void close: () => void rememberSubject: (subject: string) => void } export const useComposeStore = create()( persist( (set) => ({ isOpen: false, draftId: null, recentSubjects: [], open: (draftId) => set({ isOpen: true, draftId }), close: () => set({ isOpen: false, draftId: null }), rememberSubject: (subject) => set((s) => ({ recentSubjects: [subject, ...s.recentSubjects].slice(0, 10), })), }), { name: 'tinycld_mail_compose', storage: asyncStorage, partialize: (s) => ({ recentSubjects: s.recentSubjects }), } ) ) ``` `create`, `persist`, and `asyncStorage` are all re-exported from `@tinycld/core/lib/store`. Import them from there rather than reaching into `zustand` directly so the package stays pinned to `@tinycld/core`'s versions. ## Selective persistence `partialize` decides which slice of state is persisted. Above, `isOpen` and `draftId` reset on app restart (the compose window shouldn't reopen itself), but `recentSubjects` survives so autocomplete stays useful. If you skip `partialize`, the whole store persists - often not what you want. Pick a unique `name` per store. Convention: `tinycld__`. The `asyncStorage` adapter handles the React Native side of AsyncStorage for you. ## Reading from components Always use a selector - never pull the whole store: ```tsx // good - only re-renders when isOpen changes const isOpen = useComposeStore((s) => s.isOpen) // also good - destructured selector for multiple fields const { isOpen, close } = useComposeStore((s) => ({ isOpen: s.isOpen, close: s.close })) // bad - re-renders on every store change const store = useComposeStore() ``` Selectors are Zustand's whole reason for being faster than Context. Use them. ## Mutations stay out of stores Do not put TanStack mutations inside a Zustand store. Mutations need reactive data from `useLiveQuery` and the `isPending` / `isError` tracking that `useMutation` provides - neither works cleanly from inside a store. Compose the two in a small feature hook instead: ```ts filename="tinycld/mail/hooks/use-compose.ts" import { useComposeStore } from '../stores/compose-store' import { useSendEmail } from './use-send-email' export function useCompose() { const { isOpen, draftId, open, close } = useComposeStore((s) => ({ isOpen: s.isOpen, draftId: s.draftId, open: s.open, close: s.close, })) const send = useSendEmail() return { isOpen, draftId, open, close, send } } ``` The store holds UI state; the hook holds the mutation; the component consumes both through a single entry point. ----------------------------------------- ## Logging Source: /docs/tasks/logging.md Description: Report errors to Sentry with captureException, and use plain console for dev-only output. ----------------------------------------- There are two distinct concerns here. Errors that should reach Sentry go through `captureException` from `@tinycld/core/lib/errors`. Dev-only output — anything you wouldn't ship to production telemetry — is plain `console.warn` / `console.error`, the same as any other React Native app. There is no separate `log` helper. If you wrote some `log.info(...)` in a draft, replace it with `console.info` (dev tracing) or with a Sentry breadcrumb-bearing `captureException` (real failure). ## Reporting errors to Sentry `captureException(context, error, extra?)` is the canonical entry point. `context` is a short stable string (Sentry uses it to group events), `error` is whatever you caught, and `extra` is arbitrary structured context attached to the event: ```ts filename="tinycld/example/hooks/useImport.ts" import { captureException } from '@tinycld/core/lib/errors' export function useImport() { const run = async (filename: string) => { try { await pb.collection('example').create({ filename }) } catch (err) { captureException('example.import.create', err, { filename }) throw err } } return { run } } ``` The `context` string is the thing you'll grep for in Sentry — pick something specific (`mail.openDraft.fetchBody`, not `mail.error`) and stable (don't interpolate user data into it). The `extra` bag is the place for variable detail. Rethrow when the caller still needs to handle the failure; swallow only when the surface can recover without the data. ## Form validation errors When a `useMutation` fails because PocketBase rejected a field, the right handler is `handleMutationErrorsWithForm({ setError, getValues })` (also from `@tinycld/core/lib/errors`). It maps PocketBase validation errors back onto the react-hook-form fields and routes everything else into a `root` error you can render with ``. See [Forms](/docs/tasks/forms). You generally don't combine `handleMutationErrorsWithForm` with `captureException` — validation failures aren't bugs. ## Dev-only output For the `console.log`-shaped use case (printf-debug a render, trace a state machine in dev), use `console.*` directly. There's no central wrapper. React Native and the web bundler both strip aggressive `console.*` chatter in production builds, and Sentry's React Native SDK captures unhandled `console.error` lines on its own when configured to. ```ts if (__DEV__) { console.debug('[mail.compose] draft id', draftId) } ``` Wrap deliberately noisy output in `if (__DEV__)` so it doesn't survive a release build. Don't ship `console.log` calls without that guard. ## What not to do - Don't import from `@tinycld/core/lib/logger` — it doesn't exist. Some older code samples reference it; treat them as out of date. - Don't `captureException` for control flow. Sentry events cost money and dilute the signal — only fire one when something has actually gone wrong. - Don't include unscrubbed user input in the `context` string. Put it in `extra` so Sentry's PII scrubbing can do its job. ## Caught exceptions you want reported but not rethrown The same `captureException` covers this — just don't rethrow: ```ts import { captureException } from '@tinycld/core/lib/errors' try { await pb.collection('example').create(data) } catch (err) { captureException('example.create.silent', err, { data }) // intentionally swallowed: caller has nothing actionable to do } ``` The comment is the important part. Swallowing without one is a smell — six months from now no one will remember whether the silence was intentional. ----------------------------------------- ## Theming Source: /docs/tasks/theming.md Description: Use semantic tokens instead of raw colors so your package works in both light and dark mode. ----------------------------------------- TinyCld ships a light and a dark theme, and users can flip between them at runtime (and a separate "color theme" picker tints the accent palette on top of that). Every color in a package needs to come from a semantic token — never a raw hex. Two APIs cover every case: Tailwind class names with semantic tokens for JSX, and `useThemeColor` from `@tinycld/core/lib/use-app-theme` for places JSX isn't an option. ## Semantic tokens in JSX Prefer Tailwind class names with semantic tokens: ```tsx Title Subtitle ``` The token surface lives in `@tinycld/core/lib/use-app-theme.ts` as the `AppThemeColor` union. Both light and dark themes define every token, so JSX written against tokens renders correctly under either. The names you'll reach for most often: - `background`, `foreground` — the base surface and its text. - `surface`, `surface-foreground`, `surface-hover` — a card-like raised surface. - `surface-secondary`, `surface-tertiary` — additional depth steps for grouped panels. - `muted`, `muted-foreground` — a subordinate surface and its de-emphasized text (sidebar hover, captions). - `accent`, `accent-foreground`, `accent-soft`, `accent-soft-foreground` — the highlight color; the `-soft` variants are for low-emphasis backgrounds. - `default`, `default-foreground`, `default-hover` — neutral filled affordances (default buttons). - `success`, `warning`, `danger` (each with `-foreground`, `-hover`, and `-soft` / `-soft-foreground` / `-soft-hover` variants) — status colors. Note: `danger`, not `destructive`. - `field`, `field-foreground`, `field-placeholder`, `field-border`, `field-border-hover`, `field-border-focus`, `field-hover`, `field-focus` — form input affordances. - `border`, `separator`, `focus`, `link` — outlines, dividers, focus rings, link text. - `overlay`, `overlay-foreground`, `overlay-backdrop` — modal/popover surfaces and their backdrop. - `on-surface`, `on-surface-foreground`, `on-surface-secondary`, `on-surface-tertiary` (each with hover/focus variants) — for nesting content on top of an already-raised surface. A handful of custom tokens are reserved for chrome that doesn't map to the standard system (`rail-background`, `rail-text`, `rail-active-text`, `sidebar-background`, `active-indicator`, `hover-background`, `info`, `info-foreground`, `primary`, `primary-foreground`). Most package code doesn't touch these. If a design requires a color outside this union, the fix is to add a token to `@tinycld/core/lib/use-app-theme.ts` and `tinycld/global.css`, not to inline a hex. ## Colors outside of className Some APIs don't accept a `className`: Lucide icons take a `color` prop, React Native `Pressable`'s `style` function is imperative, and a handful of third-party components want a literal hex. For those, use `useThemeColor`: ```tsx import { useThemeColor } from '@tinycld/core/lib/use-app-theme' import { Search } from 'lucide-react-native' export function SearchIcon() { const fg = useThemeColor('foreground') return } ``` `useThemeColor` reads the CSS variable for the named token and re-renders the component when the theme switches. It accepts any name from the `AppThemeColor` union. ## Prefer className when it works `useThemeColor` is a fallback, not the default. When both styles produce the same output, the className form is shorter, declarative, and survives token renames automatically. Use the hook only for the cases that genuinely need a string color value: - Props that take a literal color: ``, ``, `shadowColor`, gradient stops. - Style-callback APIs that don't accept `className` (e.g. `Pressable`'s `style={({ pressed }) => …}`). - Reanimated styles where the worklet needs a JS string. The thing to avoid is plumbing a hook value straight into an inline `style={{ color: fg }}` when the className form would have worked: ```tsx // bad - verbose, drifts when tokens are renamed const fg = useThemeColor('foreground') const bg = useThemeColor('surface-secondary') return ( ) // good - same result, half the code return ( ) ``` If you find yourself calling `useThemeColor` only to feed the result into a `` or ``, convert it to a className. ## What not to do Never hardcode a hex: ```tsx // bad - breaks dark mode, breaks accessibility tweaks, // drifts out of sync with the design system ``` If a design demands a color that isn't in the token set, add a token — don't inline a hex. ## Don't hardcode the mode Both themes are first-class. Don't write components that assume dark mode, and don't build a "white-on-white" hack that only looks right in one theme. If you're wiring a preview surface that must always be one mode regardless of the user's pref, wrap it explicitly — but that's rare enough that you should ask before doing it. ## Reading the user's choice If you genuinely need to branch on the active theme (say, to pick an asset variant), read it through `useThemePreference` from `@tinycld/core/lib/use-theme-preference`: ```tsx import { useThemePreference } from '@tinycld/core/lib/use-theme-preference' const { preference, resolved } = useThemePreference() // preference: 'system' | 'light' | 'dark' ← the user's stored choice // resolved: 'light' | 'dark' ← the mode actually rendering right now ``` Use `resolved` when you need to know which theme is on screen, and `preference` when you're rendering the theme-picker UI itself. Almost all theming concerns are solved by tokens though — reach for the mode only when you've confirmed tokens alone can't express it. ----------------------------------------- ## In-app help Source: /docs/tasks/in-app-help.md Description: Ship help topics with your package and surface them from the UI so users can find them. ----------------------------------------- A feature is not "done" until a user inside the app can find out how to use it. TinyCld packages contribute their own help topics and choose where to surface them from the UI. The help system handles indexing, the drawer, search, and permalinks — you write markdown and add entry points. ## Authoring topics Drop a `help/` directory at the root of your package and declare it in `manifest.ts`: ```ts filename="manifest.ts" const manifest = { name: 'Example', slug: 'example', // ... help: { directory: 'help' }, } ``` Each topic is a single markdown file. The filename (without `.md`) becomes the topic ID: ```md filename="help/getting-started.md" --- title: Getting started summary: Quick orientation for new users tags: [intro, onboarding] order: 10 --- ## What this package does ... ``` `title` and `summary` are required. `tags` (string array) and `order` (number, lower sorts first) are optional. Lead with task-oriented prose ("To do X, …"), not API documentation. Run `pnpm run packages:generate` from `tinycld/` after adding or renaming topics. Topics surface in the global help hub at `/a/help`, on a per-package help screen, and through the right-slide drawer. ## Surfacing entry points Three patterns, layered. Pick what fits your package. ### Sidebar Help item A persistent entry point in your package's sidebar. Add it as the last item, separated by a divider, so it doesn't compete with primary nav: ```tsx filename="sidebar.tsx" import { openHelpPackage } from '@tinycld/core/lib/help/open-help' import { SidebarDivider, SidebarItem } from '@tinycld/core/components/sidebar-primitives' import { HelpCircle } from 'lucide-react-native' // ...inside your SidebarNav children: openHelpPackage('example')} /> ``` `openHelpPackage(slug)` opens the drawer to your package's topic index. Use this when you want one always-visible entry that doesn't presume *which* topic the user wants. ### Contextual HelpIcon next to a title For screens with a clear concept attached, drop a `HelpIcon` next to the page or section title. It opens the drawer to that specific topic: ```tsx filename="screens/index.tsx" import { HelpIcon } from '@tinycld/core/components/help/HelpIcon' My Items ``` `HelpIcon` accepts `topic` (a `:` literal type, so misspellings fail typecheck), `size`, and `tone` (`'muted'` | `'foreground'` | `'accent'`). ### Toolbar HelpIcon with computed topic If your package's main screen is a single view that changes meaning by state (a folder browser, a multi-section list), put a `HelpIcon` in the toolbar's right cluster and pick the topic from current state. Extract the mapping into a helper, not inline: ```tsx filename="components/Toolbar.tsx" import type { HelpTopicId } from '@tinycld/core/lib/help/types' function helpTopicForSection(section: string, isSearchActive: boolean): HelpTopicId { if (isSearchActive) return 'example:search' if (section === 'archive') return 'example:archive' return 'example:getting-started' } // ...inside the component: const helpTopic = helpTopicForSection(activeSection, isSearchActive) // ...inside the toolbar's right-side cluster: ``` This gives one always-present help affordance whose target adapts to context — better than scattering icons across only some title states. ### Inside dialogs For dialogs that explain a feature concept (sharing, importing, exporting), add a `HelpIcon` next to the dialog title. This is the moment a user is most likely to wonder "how does this work" — meet them there: ```tsx filename="components/ShareDialog.tsx" Share “{itemName}” ``` The `flex-1` + `numberOfLines={1}` on the title keeps long values from pushing the icon off-screen. ## Cross-linking between topics Inside a topic body, use `help://` URLs to link to other topics. The markdown renderer intercepts them and opens the drawer instead of navigating away: ```md For details on permissions, see [sharing](help://example:sharing). ``` ## Opening help imperatively From any component, `openHelp(':')` opens the drawer to a specific topic; `openHelpPackage('')` opens it to the package index. Both come from `@tinycld/core/lib/help/open-help`. Use the imperative form for things like keyboard shortcut handlers or "Learn more" links inside form errors. ## Permalinks `/a/help/[pkg]/[topic]` is a real route — shareable in chat or docs. The global hub at `/a/help` has full-text search across every present package's topics (weighted: title > tags > summary > body). ========================================= # Reference ========================================= ----------------------------------------- ## Manifest schema Source: /docs/reference/manifest-schema.md Description: Every field on a package manifest, with types and requirements. ----------------------------------------- This page is the exhaustive reference for the manifest object every package default-exports from `manifest.ts`. For a task-oriented walkthrough, see [Manifest](/docs/anatomy/manifest); for the narrative around individual fields, see the other pages under [Anatomy](/docs/anatomy/manifest). ## Fields | Field | Type | Required? | Description | |---|---|---|---| | `name` | `string` | yes | Human-readable name shown in navigation and the package registry. | | `slug` | `string` | yes | URL segment and collection-name prefix. Must match the last segment of the npm package name. | | `version` | `string` | yes | Informational. Keep in sync with `package.json`. | | `description` | `string` | yes | One-sentence summary shown in the package registry. | | `routes.directory` | `string` | no | Folder of app screens, re-exported under `tinycld/app/a/(app)//` (served at `/a//`). Convention: `'screens'`. | | `publicRoutes.directory` | `string` | no | Folder of public screens, re-exported under `tinycld/app/p//`. Convention: `'public-screens'`. | | `nav.label` | `string` | if `nav` set | Text for the rail entry. | | `nav.icon` | `string` | if `nav` set | Lucide icon name. | | `nav.order` | `number` | no | Sort priority; lower comes first. | | `nav.shortcut` | `string` | no | Single-letter keyboard shortcut. Must be unique across installed packages. | | `migrations.directory` | `string` | no | Folder of PocketBase migration JS files. Convention: `'pb-migrations'`. | | `hooks.directory` | `string` | no | Folder of PocketBase JS hooks. Convention: `'pb-hooks'`. | | `collections.register` | `string` | no | Subpath (no extension) to the module exporting `registerCollections`. | | `collections.types` | `string` | no | Subpath (no extension) to the module exporting `{PascalSlug}Schema`. | | `sidebar.component` | `string` | no | Subpath to a component rendered in the secondary sidebar when this package is active. Omit `sidebar` entirely and the workspace renders no sidebar container - the package's screens get the full viewport width next to the nav rail (`@tinycld/calc` ships this way). | | `provider.component` | `string` | no | Subpath to a provider component wrapping the package's routes. | | `settings` | `Array<{slug, component, label}>` | no | Personal Settings panel contributions. Each entry is a link + component pair. | | `settings[].slug` | `string` | if `settings` set | URL segment under `/a/settings/`. Must be unique across installed packages. | | `settings[].component` | `string` | if `settings` set | Subpath to the panel component. | | `settings[].label` | `string` | if `settings` set | Link text in the settings sidebar. | | `slots` | `string[]` | no | Names of sidebar slots this package exposes for other packages to contribute into. Each name must be unique within the manifest; the generator errors on duplicates. See [Sidebar slots](/docs/anatomy/sidebar-slots). | | `sidebarContributions` | `Array<{target, slot, component, order?}>` | no | UI contributions this package injects into another package's sidebar slot. | | `sidebarContributions[].target` | `string` | if `sidebarContributions` set | Slug of the host package whose slot is being targeted. Tolerated (warning, not error) when the host isn't installed. | | `sidebarContributions[].slot` | `string` | if `sidebarContributions` set | Name of the slot to render into. Must match a slot declared in the target's `slots` array; the generator errors otherwise. | | `sidebarContributions[].component` | `string` | if `sidebarContributions` set | Subpath to the React component. Same resolution as `settings[].component`. Must default-export. | | `sidebarContributions[].order` | `number` | no | Sort priority among contributions to the same slot. Default `0`; ties broken alphabetically by contributor slug. | | `seed.script` | `string` | no | Subpath to a module default-exporting an async seed function. | | `tests.directory` | `string` | no | Folder of Playwright specs. Convention: `'tests'`. Vitest globs tests automatically. | | `server.package` | `string` | no | Subdirectory containing a Go module. Convention: `'server'`. | | `server.module` | `string` | no | Go module path declared in that subdirectory's `go.mod`. Namespace as `tinycld.org/packages/`. | | `help.directory` | `string` | no | Folder of `.md` help topics. Convention: `'help'`. | | `cli.package` | `string` | no | Subdirectory containing a Go module exposing `Register(root *cobra.Command, c *client.Client)`. Convention: `'cli'`. See [tinycld CLI reference](/docs/reference/cli-reference). | | `cli.module` | `string` | if `cli` set | Go module path declared in that subdirectory's `go.mod`. Namespace as `tinycld.org/packages//cli`. | | `cli.scopes` | `string[]` | no | OAuth scopes this package defines (e.g. `'mail:read'`), for the scope registry and consent screen. Never interpolated into generated Go. There is deliberately no command list — Cobra owns the command tree and `--help`. | | `automation.definitions` | `string` | no | Subpath to a TS module default-exporting an `AutomationDefinitions` object — pure data, typed against the package's schema. Declares the triggers and actions users can build rules on. See [Automation](/docs/anatomy/automation). | | `build.script` | `string` | no | Subpath to a TS module the generator runs before emitting route re-exports. Use for artifacts the bundler can't produce on its own (e.g. an embedded webview bundle). The dev launcher re-runs it in watch mode for the duration of the session. | | `dependencies` | `string[]` | no | Slugs of other packages this one expects to be installed. Metadata only; not enforced at build time. | All the `*.directory` and `*.component` / `*.script` fields are paths relative to the package root. `directory` values point at a folder; `component` and `script` values are subpaths without the file extension. ## TypeScript interface The source-of-truth interface lives in the app shell's `tinycld/scripts/load-manifest.ts`: ```ts interface PackageManifest { name: string slug: string version: string description: string routes?: { directory: string } publicRoutes?: { directory: string } nav?: { label: string; icon: string; order?: number; shortcut?: string } migrations?: { directory: string } hooks?: { directory: string } collections?: { register: string; types: string } sidebar?: { component: string } provider?: { component: string } settings?: { slug: string; component: string; label: string }[] slots?: string[] sidebarContributions?: { target: string slot: string component: string order?: number }[] seed?: { script: string } tests?: { directory: string } server?: { package: string; module: string } help?: { directory: string } cli?: { package: string; module: string; scopes?: string[] } automation?: { definitions: string } build?: { script: string } dependencies?: string[] } ``` ## Annotated example ```ts filename="manifest.ts" const manifest = { name: 'Example', slug: 'example', version: '0.1.0', description: 'An example package', routes: { directory: 'screens' }, publicRoutes: { directory: 'public-screens' }, nav: { label: 'Example', icon: 'box', order: 20, shortcut: 'e', }, migrations: { directory: 'pb-migrations' }, hooks: { directory: 'pb-hooks' }, collections: { register: 'collections', types: 'types', }, settings: [ { slug: 'example', component: 'settings/example', label: 'Example settings' }, ], sidebar: { component: 'sidebar' }, provider: { component: 'provider' }, seed: { script: 'seed' }, tests: { directory: 'tests' }, server: { package: 'server', module: 'tinycld.org/packages/example' }, build: { script: 'build' }, // dependencies: ['other-package-slug'], } export default manifest ``` ----------------------------------------- ## bootstrap CLI Source: /docs/reference/cli.md Description: Reference for npx @tinycld/bootstrap, the package scaffolder and workspace assembler. ----------------------------------------- `@tinycld/bootstrap` does two jobs. In **scaffold mode** (`--new`) it produces a new feature package repo that already passes the workspace's generator checks, CI, and typecheck. In **assemble-only mode** (`--assemble-only`) it assembles a workspace — writing the root coordination files from embedded templates and cloning `app` + `core` (plus any features you name with `--with`) into the current directory. One of the two mode flags is required on every invocation. Running `npx @tinycld/bootstrap` with no mode flag prints a usage summary and exits 2. ## Scaffold mode (`--new`) ```sh npx @tinycld/bootstrap --new ``` The positional argument paired with `--new` is the package slug — kebab-case, 3–40 characters. It becomes: - The npm name: `@tinycld/` - The URL segment: `/a//` - The Go module path: `tinycld.org/packages/` Omit the positional to be prompted for it. ### Flags Every prompt has a matching flag. Pass `--yes` to take defaults for everything else. | Flag | Type | Description | |---|---|---| | `--new` | boolean | Selects scaffold mode. Mutually exclusive with `--assemble-only`. | | `` | positional | Package slug. Required when `--yes` is set; otherwise prompted. | | `--yes`, `-y` | boolean | Skip all prompts and use defaults. Requires the positional slug. | | `--name` | string | Human-readable name. Defaults to title-cased slug. | | `--description` | string | One-sentence description. | | `--preset` | `full` \| `settings-only` | Defaults to `full` under `--yes`. | | `--icon` | string | Lucide icon name. *Full preset only.* Default `box`. | | `--nav-order` | number | Integer 0–99. *Full preset only.* Default `20`. | | `--shortcut` | string | Single lowercase letter, or empty. *Full preset only.* | | `--server`, `--no-server` | boolean | Include the Go server stub. *Full preset only.* Default `true`. | | `--target` | string | Output directory. Default depends on the cwd: if it's a workspace root, defaults to `.//`; otherwise to `./tinycld-//` (bootstrap mode — see below). | | `--link`, `--no-link` | boolean | Link the new package into the workspace (assemble/attach + `pnpm install` at the root). Without either, you'll be prompted; under `--yes`, defaults to linking. | `--no-link` always wins over `--yes` — pass both when you want to scaffold without touching a workspace. ### Non-interactive example ```sh npx @tinycld/bootstrap --new my-feature \ --yes \ --preset full \ --icon check-square \ --no-server \ --no-link ``` This scaffolds with all defaults, no Go server, and no workspace linking. Suitable for CI, scripted setups, and autonomous coding agents. ## Assemble-only mode (`--assemble-only`) `--assemble-only` assembles a workspace in the current directory instead of scaffolding a package. It writes the workspace coordination files (`package.json`, `pnpm-workspace.yaml`, `tinycld.packages.ts`, `vitest.config.ts`, shared test stubs under `tests/`, `.node-version`, `.go-version`) from embedded templates inside bootstrap itself — there is no separate meta-repo to clone. It then clones the members you need: the `tinycld` shell (which carries `@tinycld/core` nested inside it) is always cloned, and each `--with ` adds one feature. Directories that already exist are skipped (and files are never overwritten), so it's safe to re-run. ```sh # in an empty dir — assembles the workspace (root + app + core + features): mkdir ~/code/tinycld && cd ~/code/tinycld npx @tinycld/bootstrap@latest --assemble-only --with mail --with contacts pnpm install # links members + runs the generator (postinstall) ``` | Flag | Type | Description | |---|---|---| | `--assemble-only` | boolean | Selects assemble-only mode. Mutually exclusive with `--new`. | | `--with ` | string (repeatable) | Also clone this feature member. Pass once per feature. Accepts `--with name@ref` to pin to a tag/branch/commit. Unknown feature names are rejected. | Assemble-only honors `TINYCLD_REPO_BASE` to pick the git host (CI uses `https://github.com/tinycld`; the default is `git@github.com:tinycld`). It does **not** run `pnpm install` itself — the caller controls that (so CI can choose `pnpm install --frozen-lockfile`). A partial checkout is fully supported: npm tolerates members whose directories are absent, and the generator scans only the present ones. ## Workspace detection (scaffold mode) When `--target` is not set, the scaffolder picks one of two layouts based on the current directory: - **Attach** — if `/package.json` declares `"name": "@tinycld/workspace"`, the new package goes to `//` as a member, and (with `--link`) the existing workspace `package.json` gets the member added and `pnpm install` runs at the root. - **Bootstrap** — otherwise, the scaffolder creates `/tinycld-/`, places the package at `/tinycld-//`, and (with `--link`) assembles a workspace *around* it — writing the workspace `package.json` and cloning `app` + `core` via the same assemble-only logic — before installing. This means `npx @tinycld/bootstrap --new my-todo` from an empty directory produces a self-contained `./tinycld-my-todo/` workspace with everything you need to run the app, while running it from inside a workspace root just adds another member next to `app/` and `core/`. The detection check is strict: it reads `/package.json` and matches `name === "@tinycld/workspace"`. A coincidentally-named directory won't false-match. ## Prompts (interactive scaffold mode) | Prompt | Example | Notes | |---|---|---| | Package slug | `my-feature` | Skipped if passed as argv. Validates kebab-case; minimum 3 chars. | | Human-readable name | `My Feature` | Defaults to title-cased slug. Used in manifest `name` and nav label. | | One-sentence description | `Does a thing well.` | Used in manifest `description`, `package.json`, and `README.md`. | | Preset | `full` or `settings-only` | See [Presets](#presets). | | Lucide icon | `box` | *Full preset only.* Any [lucide-react-native](https://lucide.dev/icons) name. Default `box`. | | Nav order | `20` | *Full preset only.* Integer 0–99; controls sidebar position. | | Keyboard shortcut | `f` | *Full preset only.* Single lowercase letter, or blank. | | Include Go server? | `y` / `n` | *Full preset only.* If `n`, `server/` and the manifest's `server` field are omitted. | | Target directory | `./my-feature` | Must not exist or must be empty. | | Link into workspace? | `y` / `n` | If yes, the scaffolder assembles (or attaches to) the workspace and runs `pnpm install` at the root. | ## Presets ### `full` — data package The shape of `@tinycld/contacts`, `@tinycld/mail`, `@tinycld/calendar`, `@tinycld/drive`. You get routes, a sidebar, an optional provider, pbtsdb collections, PocketBase migrations, seed data, and (optionally) a Go server stub. Package TypeScript lives under a `tinycld//` prefix. ### `settings-only` — service package The shape of `@tinycld/google-takeout-import`. The package contributes a single Personal Settings panel — no routes, no nav entry, no collections, no server. Use this for integrations and admin-style tools. ## Generated files Both presets produce: - `manifest.ts`, `package.json`, `tsconfig.json` — lint config is not duplicated; the canonical `tinycld/biome.json` covers every member - `README.md`, `.gitignore` - `.github/workflows/ci.yml` — assembles the workspace via `bootstrap --assemble-only`, installs at the root, and runs `tinycld-pkg check` (+ e2e) - `tests/manifest.test.ts` — smoke test asserting the manifest shape The full preset additionally produces: - `pb-migrations/_create_.js` - `server/go.mod`, `server/register.go` (if the Go server prompt is yes) - `tinycld//{collections,provider,seed,sidebar,types}.ts` - `tinycld//screens/{_layout,[id],index}.tsx` The settings-only preset additionally produces: - `tinycld//types.ts` - `tinycld//settings/main.tsx` ## After scaffolding The CLI prints the steps it didn't run — it never touches git or `gh`. Typical next steps: ```sh cd my-feature git init git add . git commit -m 'chore: initial scaffold' gh repo create tinycld/my-feature --public --source=. --push ``` If you didn't pass `--link` (or said no to the prompt), bring the package into a workspace now: ```sh # from a workspace root that already has the tinycld shell cd ~/code/tinycld pnpm install # links the new member + runs the generator cd tinycld && pnpm run checks ``` Adding the new package as a present member is enough to see it in navigation; the generator wires routes, collections, migrations, and settings panels automatically. ## Import conventions in scaffolded code Templates use the scoped `@tinycld/core` path: ```ts import { useOrgLiveQuery } from '@tinycld/core/lib/use-org-live-query' import { Modal } from '@tinycld/core/ui/modal' ``` `@tinycld/core` is a nested member inside the `tinycld` repo (at `tinycld/core/`). The scaffolded `tsconfig.json` extends `@tinycld/core/tsconfig.package-base.json` by package name, and `@tinycld/core/*` resolves by package name through the `node_modules/@tinycld/*` symlink, so resolution works as soon as the package is a present member. Intra-package imports use relative paths; `~/tinycld//*` is also aliased to the package's own nested source if you want an absolute form. For the published source and template internals, see [the bootstrap repo](https://github.com/tinycld/bootstrap). ----------------------------------------- ## tinycld CLI reference Source: /docs/reference/cli-reference.md Description: Every command, flag, and scope in the tinycld command line tool, plus how a package contributes its own command group. ----------------------------------------- The complete surface of the `tinycld` binary. For a task-oriented introduction — downloading it, logging in, and typical usage — see [Command line tool](/docs/command-line-tool). This page documents the **user-facing** `tinycld` binary. It is unrelated to `tinycld-pkg`, the per-member developer tool that runs biome, tsc, and vitest, and to `@tinycld/bootstrap`, the [scaffolder](/docs/reference/cli). ## Global flags Available on every command: | Flag | Default | Meaning | |---|---|---| | `--output table\|json\|csv` | `table` | Output format | | `--json` | — | Shorthand for `--output json` | | `--context ` | current context | Run against a different saved server | | `--quiet` | — | Suppress informational messages | | `--no-color` | — | Disable colored output | | `--yes` | — | Answer yes to prompts and skip interactive input | | `-h`, `--help` | — | Help for the command | | `-v`, `--version` | — | Version (root command only) | Without a TTY the tool disables color and never blocks on input, so no extra flags are needed to make it CI-safe. Table output is column-aligned with a header row; CSV writes a header row then records; JSON is indented. **JSON is a separate payload from the table**, not a serialization of it — some commands deliberately expose more (or differently-named) fields than they print. Verify a command's actual JSON before depending on a key. ## Exit codes `0` on success, `1` on any error. There is **no** differentiated exit-code scheme — do not branch on specific non-zero values. Errors are written to stderr as `Error: `. Server-side failures render as `server error (HTTP ): `. ## Core commands Present in every build regardless of which packages the server has. | Command | Arguments | Notes | |---|---|---| | `auth login` | `` | Device-grant login; saves a context and credentials | | `auth status` | | Current context, origin, user, and granted scopes | | `auth logout` | | Revokes the grant server-side, then clears the credential | | `context list` | | Saved contexts; the current one is marked | | `context use` | `` | Switch the current context | | `context add` | ` ` | Save a context without logging in | | `context remove` | `` | Remove a context and its stored credentials | | `search` | `` | Federated search across installed packages | | `version` | | Version, Go version, OS, and architecture | | `completion` | `bash\|zsh\|fish\|powershell` | Shell completion script | | `help` | `[command]` | Help for any command | `search` accepts `--pkg ` (repeatable), `--not `, and `--limit `. It understands the same query grammar as the in-app palette: `pkg:` prefixes scope the search and a leading hyphen excludes a term. Quote the query so the shell passes it as one argument; a query beginning with a hyphen needs a `--` separator first. Counts and partial-result warnings go to **stderr**, keeping stdout a clean document under `--json`. A bare host in `auth login` or `context add` is normalized to `https://`, except `localhost`, `127.0.0.1`, and `::1`, which get `http://`. ## Drive | Command | Arguments | Flags | |---|---|---| | `drive ls` | `[path]` | `-l/--long`, `-a/--all` | | `drive tree` | `[path]` | `--depth ` (3), `-a/--all` | | `drive search` | `` | `--limit`, `--offset` | | `drive cat` | `` | | | `drive get` | ` [dest]` | Folders download as a zip | | `drive put` | ` [dest]` | `-p/--parents`, `-r/--recursive` | | `drive mkdir` | `` | `-p/--parents` | | `drive mv` | ` ` | | | `drive cp` | ` ` | | | `drive rm` | `` | `--permanent` (default is to trash) | | `drive trash` | | Lists trashed items | | `drive restore` | `` | | | `drive share` | `` | `--user` (repeatable), `--role viewer\|editor`, `--message` | | `drive link create` | `` | `--role viewer\|commentor\|editor`, `--expires ` | | `drive link list` | `` | | | `drive link revoke` | `` | | | `drive versions` | `` | `--restore `, `--snapshot`, `--label` | | `drive export` | ` [dest]` | `--to pdf` | | `drive usage` | | Storage usage | Every path argument also accepts `id:` to bypass path resolution. ## Mail | Command | Arguments | Flags | |---|---|---| | `mail search` | `[query]` | `--mailbox`, `--limit`, `--offset`, `--from`, `--to`, `--subject`, `--has-words`, `--date-after`, `--date-before`, `--folder`, `--has-attachment`, `--not` | | `mail list` | | `--folder` (inbox), `--mailbox`, `--limit` (25), `--page` (1) | | `mail read` | `` | `--html`, `--raw`, `--no-mark` | | `mail attachments` | `` | | | `mail download` | `` | `--attachment N\|all`, `--out ` | | `mail send` | | `--to`, `--cc`, `--bcc` (repeatable), `--subject`, `--body`, `--body-file`, `--attach`, `--mailbox`, `--from` | | `mail reply` | `` | `--all`, `--body`, `--body-file`, `--attach`, `--from` | | `mail draft` | | Same flags as `send`, plus `--message-id` to update an existing draft | | `mail draft send` | `` | | | `mail labels` | | Labels available to you | | `mail label add` | `