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 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 Maps | 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.
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.
| 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.
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
- Keep JSX minimal. No complex ternaries,
.map(), or calculations in the return. State, event handling, and data processing move into auseFeatureNamehook or a helper above the JSX. - Conditional visibility takes a prop. Instead of
{condition && <BigComponent />}, give the component anisVisibleprop and returnnullwhen 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") oruseThemeColor('foreground')where aclassNamedoesn’t reach, such as Lucide icons and RNPressablestyle props. - Never
console.*in runtime code — biome enforces this as an error. Uselogfrom@tinycld/core/lib/loggeron the client andlogging.ForPackage("<slug>")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.
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.