Conventions
The packages stay interchangeable because they all follow the same rules. If you're adding or changing a package, these are the load-bearing ones.
Shared config
Every package extends @tikab-interactive/fusion-config for TypeScript, Oxlint and
Oxfmt — so they type-check, lint and format identically, with no copied settings.
Scaffold a new package with fusion-config init (or pull individual configs with
fusion-config add) — don't hand-roll these files. The canonical versions are templates
in fusion-config (templates/package/); copying or scaffolding keeps every repo in sync.
tsconfig.json extends the package by name (tsc resolves npm packages):
// tsconfig.json
{ "extends": "@tikab-interactive/fusion-config/tsconfig.json" }Lint and format are TypeScript config files, not JSON:
// oxlint.config.ts ← NOT .oxlintrc.json
import config from "@tikab-interactive/fusion-config/oxlint";
import { defineConfig } from "oxlint";
export default defineConfig({
extends: [config],
// Repo-specific overrides go here. fusion-config stays strict; a consumer only loosens
// what doesn't fit it — e.g. an app relaxing library-oriented rules. Put relaxations in
// an `overrides` block when they must beat fusion-config's file-specific (*.tsx) tuning.
rules: {},
});Use oxlint.config.ts (and oxfmt.config.ts), never a .oxlintrc.json. The reason is
load-bearing: oxlint's JSON extends is a file-path mechanism — it does not resolve
npm packages — whereas the .ts config above is a real import, so the bare
@tikab-interactive/fusion-config/oxlint specifier resolves. A stray .oxlintrc.json in a
package silently replaces the shared base (and an auto-discovered one merges with
oxlint's built-in defaults), so the repo quietly stops sharing fusion-config.
A sub-package inside a repo that already has an oxlint.config.ts (e.g. fusion-meta/example
under fusion-meta/) inherits it through oxlint's directory walk-up — it needs no config
of its own, and app-level relaxations for it live in the parent repo's oxlint.config.ts.
Shared CI
CI is defined once — a reusable GitHub Actions workflow in fusion-config —
and each repo calls it from a thin ci.yml. Every package must define four
scripts; the gate runs them on each PR:
| Script | Tool |
|---|---|
lint | oxlint |
fmt:check | oxfmt --check |
typecheck | tsgo --noEmit |
test | bun test (an empty no-op is fine) |
Source-published, bundler-consumed
Packages ship raw TypeScript with no build step; the consumer's bundler compiles
them. Server-only packages expose a browser export condition that resolves to a
stub which throws — so Node-only code (the Postgres driver, crypto, the Better
Auth server runtime) can never reach the client bundle.
When such a source package is installed from the registry rather than linked
(e.g. @tikab-interactive/typescript-rules), the consumer must keep it out of SSR
externalization (ssr.noExternal) — otherwise Vite hands raw .ts to Node, which
can't run it. Linked packages don't need this; tsconfigPaths already routes them
through the bundler.
Keep db-using code behind a server boundary
The same rule applies to the app's own server code, with one sharp edge worth
calling out. A createServerFn(...).handler(fn) is safe to import from a route
component: the compiler extracts the handler into a server-only chunk and leaves the
client an RPC stub, so the db/Postgres import the handler used is tree-shaken out
of the browser bundle. A plain exported function that touches db is not —
nothing strips it, so a route that imports it (even just for a sibling type) drags
the Postgres driver into the client bundle. The failure mode is nasty: the page
still server-renders, but hydration silently dies — no console error, just dead
clicks and inert forms.
The fix is a boundary: keep plain db-using helpers in a module the route
components never import. In the example, the route-facing *-server.ts files expose
only createServerFns, while the plain full-text search helpers the agent calls
live in src/lib/knowledge-search.ts (server-only). Symptom to recognise: a
controlled input whose onChange does nothing means the page never hydrated —
look for a server-only import that leaked.
fusion-ui owns the UI and its dependencies
fusion-ui is the only package that renders UI.
It owns every presentational component, declares the UI stack — Mantine, Tabler icons,
Tiptap — as its own dependencies + peerDependencies, and owns their stylesheets:
FusionProvider does import "@mantine/core/styles.css" and WikiArticleView does
import "@mantine/tiptap/styles.css", so a consumer never imports Mantine CSS itself.
The app consumes UI; it authors none. It imports composed components from the
barrel (@tikab-interactive/fusion-ui), and even raw Mantine primitives, hooks and
icons through fusion-ui's re-export subpaths — …/mantine, …/hooks, …/icons —
never @mantine/core directly. An app "component" is a thin wrapper that wires
data and i18n strings into fusion-ui elements.
Keep the UI packages installed in the app — don't "move every dep to fusion-ui"
fusion-ui declares Mantine et al. as peer dependencies, and it's linked from a
sibling repo with its own node_modules. So the consuming app must keep those UI
packages installed and listed in resolve.dedupe — that's the peer contract, the same
as for any Mantine-based component library. Deleting them from the app's package.json
to "move all dependencies into fusion-ui" breaks dev SSR, and the failure mode is
subtle:
- An externalised transitive (
@floating-ui/reactvia Mantine,use-sync-external-storevia Tiptap) resolves a second React fromfusion-ui/node_modules→Invalid hook call/Cannot read properties of null (reading 'useId')the instant a Popover or editor renders. - Forcing that whole closure to bundle with
ssr.noExternalinstead only trades the crash for a CJS-interop one (module is not defined).
The robust state is one node_modules tree: the app installs the peers, and
resolve.dedupe (react, react-dom, @mantine/core, @mantine/hooks,
@tabler/icons-react, @tanstack/react-table) collapses the source-linked fusion-ui
onto the app's single copy. Once fusion-ui is published, the app gets these peers
transitively and they can leave its direct dependencies — until then, they stay.
Core packages are decoupled
A core package depends only on what it strictly needs; optional features and shared
resources are injected by the consumer, never reached for as singletons.
fusion-db 2.0 owns no client — the app calls createDb(schema, url) once and
passes the result in. fusion-auth takes that client (createAuth({ db })) and
wires caching, password-reset email and edge-SSO sign-in the same
way, via createAuth({ secondaryStorage, sendResetPassword, plugins }) (the edge-SSO
plugin is just a plugins entry). This is why the auth slice is just db + auth.
The app composes the schema and owns its migrations
fusion-db ships the reusable foundation schema (at its /foundation export);
the app composes it with its own domains into one module:
export * from "@tikab-interactive/fusion-db/foundation";
export * from "./project";
export * from "./agent";
export * from "./carola";
export * from "./chat-document";
export * from "./host";
export * from "./nyhetn";
export * from "./processn";
export * from "./protokolln";
export * from "./search-index";
export * from "./wikin";That module re-exports the foundation plus every domain (ProcessN, ProtokollN, NyhetN,
WikiN, plus the agent / Carola / chat-document / search-index tables), and is the single
source for both the runtime client (createDb(schema, …)) and drizzle-kit. The
migrations live in the app: it owns its drizzle/ directory and runs
generate/migrate itself.
Publishing
Packages publish to GitHub Packages (the @tikab-interactive scope) — even
installing needs an .npmrc and a read:packages token. For the air-gapped
network the package tarballs are vendored into the delivery .tar and installed
offline (see Deploy).