Skip to content
Fusion

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:

ScriptTool
lintoxlint
fmt:checkoxfmt --check
typechecktsgo --noEmit
testbun 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, …/iconsnever @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/react via Mantine, use-sync-external-store via Tiptap) resolves a second React from fusion-ui/node_modulesInvalid hook call / Cannot read properties of null (reading 'useId') the instant a Popover or editor renders.
  • Forcing that whole closure to bundle with ssr.noExternal instead 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:

example/src/db/schema/index.ts
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).