FAQ

The “why is it built this way?” questions, answered. These are design-rationale answers, not how-tos — for the mechanics see Getting started and Adding a package; for failure modes see 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 — 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? for the cost we accept in exchange, and 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:

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:

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:

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.

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:

"./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/[orgSlug]/<slug>/, 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.

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