Conventions

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.

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.

See 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 hookuseMutation 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 rowsuseOrgLiveQuery to read, useMutation to write
N separate queries stitched together with JS Mapsone query with .join() + .select()

Read with useOrgLiveQuery. It waits until the user id is known and adds it to the dependency list for you.

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 and 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.

NeedUse
Form fieldsuseForm + zod
Server / async datauseOrgLiveQuery
MutationsuseMutation
Derived values.select() on the query
Responding to a prop changecompute during render
DOM refsuseRef
Shared UI statea 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 and Forms.

Imports and coupling

Use ~/* and @tinycld/core/*. Never a relative climb like ../../tinycld/....

Inside a feature package, ~/tinycld/<slug>/* 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:

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 <member>/node_modules <member>/*-lock.yaml.

Routing

App routes live under a fixed /a prefix/a/contacts, /a/mail, /a/settings/profile. Nothing is interpolated into that segment: a deployment is one workspace, so there is no slug in the URL. Public share pages live under /p/<slug>/… and protocol mounts (/dav, /caldav, /carddav) sit outside the prefix.

Navigate with useOrgHref() from @tinycld/core/lib/org-routes (or the plain appHref() outside a component), never with a literal path and never with an as OneRouter.Href cast. Where the slug is a runtime value, pass it to the helper: appHref(pkgSlug).

See 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, '<slug>'), 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.

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 covers the ecosystem-wide variants.

Style

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 and 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.

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/<id>.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.