Contributing & the polyrepo dev workflow
This is the workflow guide: how the repos fit together and how you build, test and ship a change. It assumes you've skimmed Architecture (why it's a polyrepo), Getting started (clone + link + run) and Conventions (the rules a package follows) — this page connects them into a day-to-day loop and doesn't repeat them.
The layout: siblings on disk
Everything is checked out side by side under one parent folder — the ~20
fusion-* package repos, the typescript-rules permission engine, and
fusion-meta (this docs site plus the example/ reference app):
fusion-config fusion-db fusion-env fusion-auth fusion-storage
fusion-ui fusion-ai fusion-cache fusion-mailer fusion-audit
fusion-settings fusion-search fusion-jobs fusion-metrics fusion-realtime
fusion-collab fusion-gis fusion-ldap fusion-edge-sso fusion-import-export
typescript-rules fusion-meta
That sibling arrangement isn't cosmetic — it's load-bearing. The example consumes
the packages as source, by relative path (../../fusion-*/src), so the repos
must sit next to each other. See Getting started for
how to clone them.
Source-linked in dev, not tarballs
In development the example does not install published package tarballs. It
points TypeScript at each package's source through tsconfig.json paths, so
editing ../../fusion-ui/src/... is picked up immediately — no build, no
bun link, no republish:
"paths": {
"#/*": ["./src/*"],
"#flatbox/*": ["./flatbox/*"],
"@tikab-interactive/fusion-db": ["../../fusion-db/src/index.ts"],
"@tikab-interactive/fusion-db/schema": ["../../fusion-db/src/schema/index.ts"],
"@tikab-interactive/fusion-db/foundation": ["../../fusion-db/src/schema/foundation.ts"],
"@tikab-interactive/fusion-env": ["../../fusion-env/src/index.ts"],
"@tikab-interactive/fusion-auth/server": ["../../fusion-auth/src/server.ts"],
"@tikab-interactive/fusion-auth/session": ["../../fusion-auth/src/session.ts"],
"@tikab-interactive/fusion-auth/edge-sso": ["../../fusion-auth/src/edge-sso-plugin.ts"],
"@tikab-interactive/fusion-auth/client": ["../../fusion-auth/src/client.ts"],
"@tikab-interactive/fusion-storage": ["../../fusion-storage/src/index.ts"],
"@tikab-interactive/fusion-ui": ["../../fusion-ui/src/index.ts"],
"@tikab-interactive/fusion-ui/testIds": ["../../fusion-ui/src/test-ids.ts"],
"@tikab-interactive/fusion-ui/flatbox": ["../../fusion-ui/src/flatbox/index.ts"],
"@tikab-interactive/fusion-ui/admin": ["../../fusion-ui/src/admin/index.ts"],
"@tikab-interactive/fusion-ui/mantine": ["../../fusion-ui/src/mantine.ts"],
"@tikab-interactive/fusion-ui/hooks": ["../../fusion-ui/src/hooks.ts"],
"@tikab-interactive/fusion-ui/icons": ["../../fusion-ui/src/icons.ts"],
"@tikab-interactive/fusion-ai": ["../../fusion-ai/src/index.ts"],
"@tikab-interactive/fusion-ai/agent": ["../../fusion-ai/src/agent/index.ts"],
"@tikab-interactive/fusion-ai/agent/pure": ["../../fusion-ai/src/agent/pure.ts"],
"@tikab-interactive/fusion-ai-host": ["../../fusion-ai-host/src/index.ts"],
"@tikab-interactive/fusion-ai-host/server": ["../../fusion-ai-host/src/server/index.ts"],
"@tikab-interactive/fusion-gis": ["../../fusion-gis/src/index.ts"],
"@tikab-interactive/fusion-gis/sweref99": ["../../fusion-gis/src/sweref99.ts"],
"@tikab-interactive/fusion-gis/MapView": ["../../fusion-gis/src/MapView.tsx"],
"@tikab-interactive/fusion-metrics": ["../../fusion-metrics/src/index.ts"],
"@tikab-interactive/fusion-cache": ["../../fusion-cache/src/index.ts"],
"@tikab-interactive/fusion-jobs": ["../../fusion-jobs/src/index.ts"],
"@tikab-interactive/fusion-jobs/hatchet": ["../../fusion-jobs/src/hatchet.ts"],
"@tikab-interactive/fusion-realtime": ["../../fusion-realtime/src/index.ts"],
"@tikab-interactive/fusion-realtime/client": ["../../fusion-realtime/src/client.ts"],
"@tikab-interactive/fusion-search": ["../../fusion-search/src/index.ts"],
"@tikab-interactive/fusion-edge-sso": ["../../fusion-edge-sso/src/index.ts"],
"@tikab-interactive/fusion-ldap": ["../../fusion-ldap/src/index.ts"],
"@tikab-interactive/fusion-ldap/ldapts": ["../../fusion-ldap/src/ldapts-adapter.ts"],
"@tikab-interactive/fusion-import-export": ["../../fusion-import-export/src/index.ts"],
"@tikab-interactive/fusion-import-export/csv": ["../../fusion-import-export/src/csv.ts"],
"@tikab-interactive/fusion-import-export/plan": ["../../fusion-import-export/src/plan.ts"],
"@tikab-interactive/fusion-import-export/download": [
"../../fusion-import-export/src/download.ts"
],
"@tikab-interactive/fusion-import-export/ImportPreview": [
"../../fusion-import-export/src/ImportPreview.tsx"
],
"@tikab-interactive/fusion-collab": ["../../fusion-collab/src/index.ts"],
"@tikab-interactive/fusion-settings": ["../../fusion-settings/src/index.ts"],
"@tikab-interactive/fusion-audit": ["../../fusion-audit/src/index.ts"],
"@tikab-interactive/fusion-mailer": ["../../fusion-mailer/src/index.ts"],
"@tikab-interactive/pulsn-apps/nyhetn": ["../../pulsn-apps/src/nyhetn/index.ts"],
"@tikab-interactive/pulsn-apps/nyhetn/schema": ["../../pulsn-apps/src/nyhetn/schema.ts"],
"@tikab-interactive/pulsn-apps/wikin": ["../../pulsn-apps/src/wikin/index.ts"],
"@tikab-interactive/pulsn-apps/wikin/schema": ["../../pulsn-apps/src/wikin/schema.ts"],
"@tikab-interactive/pulsn-apps/processn": ["../../pulsn-apps/src/processn/index.ts"],
"@tikab-interactive/pulsn-apps/processn/schema": ["../../pulsn-apps/src/processn/schema.ts"],
"@tikab-interactive/pulsn-apps/protokolln": ["../../pulsn-apps/src/protokolln/index.ts"],
"@tikab-interactive/pulsn-apps/protokolln/schema": [
"../../pulsn-apps/src/protokolln/schema.ts"
],
"@tikab-interactive/pulsn-apps/errors": ["../../pulsn-apps/src/errors.ts"],
"drizzle-orm": ["./node_modules/drizzle-orm"],
"drizzle-orm/*": ["./node_modules/drizzle-orm/*"],
"zod": ["./node_modules/zod"],
"zod/*": ["./node_modules/zod/*"],
"@tanstack/react-table": ["./node_modules/@tanstack/react-table"],
"@tanstack/ai": ["./node_modules/@tanstack/ai"],
"@tanstack/ai/*": ["./node_modules/@tanstack/ai/*"],
// The adapter + react packages must resolve to THIS app's copies too, so
// their transitive @tanstack/ai is the one mapped above — otherwise the
// linked fusion-ai/fusion-ui pull a second physical @tanstack/ai and its
// private types clash (nominal), even at the same version.
"@tanstack/ai-react": ["./node_modules/@tanstack/ai-react"],
"@tanstack/ai-react/*": ["./node_modules/@tanstack/ai-react/*"],
"@tanstack/ai-ollama": ["./node_modules/@tanstack/ai-ollama"],
"@tanstack/ai-openrouter": ["./node_modules/@tanstack/ai-openrouter"]
},Two things make those source paths actually resolve at runtime and in types:
-
Vite resolves the same
pathsviaresolve: { tsconfigPaths: true }, so the dev server and the build read straight from the sibling repos. Because the linked packages live outside the app root,server.fs.allowopens the parent folder, andresolve.dedupecollapses React / Mantine /drizzle-orm/@tanstack/aionto the app's single copy (a second React from a sibling'snode_modulesis the classic Invalid hook call; see Conventions):example/vite.config.tsresolve: { tsconfigPaths: true, // fusion-ui OWNS the UI stack (Mantine / tabler / tiptap) and declares it as peer // dependencies; this consuming app installs those peers (standard for a Mantine-based // component library). fusion-ui is source-linked from a sibling repo with its OWN // copies, so dedupe every shared package onto this app's single instance — otherwise // SSR loads two Reacts and throws "Invalid hook call". Same principle as drizzle-orm. dedupe: [ "react", "react-dom", "@mantine/core", "@mantine/hooks", "@tabler/icons-react", "@tanstack/react-table", // fusion-ai is linked as source and its adapters peer-depend on // @tanstack/ai; the app also calls chat() from it. Dedupe to one copy so // the adapter types line up with chat() (same reason as drizzle-orm). "@tanstack/ai", "@tanstack/ai-ollama", "@tanstack/ai-openrouter", // fusion-gis (source-linked) brings MapLibre + proj4; one copy each. "maplibre-gl", "proj4", // fusion-collab + fusion-realtime (source-linked) bring the Yjs CRDT stack, // the ProseKit editor and the Centrifuge client — dedupe so CRDT/editor // instance identity holds (a second Yjs copy breaks doc sync), same reason // as React/drizzle above. "yjs", "y-protocols", "y-prosemirror", "y-websocket", "prosekit", "centrifuge", ], }, -
tsgo (the typechecker) reads the same
paths, so types flow across the repo boundary with no published.d.ts.
The one package the app installs rather than links — typescript-rules — needs
the opposite treatment (ssr.noExternal, so Vite bundles its raw .ts instead of
handing it to Node). That contrast is spelled out in
Getting started and
Conventions; the takeaway for your
workflow is: linked = edit-and-go, installed-as-source = bundle for SSR.
fusion-config: the shared tooling repo
Every repo type-checks, lints and formats identically because they all extend
one package — fusion-config. It ships the
shared TypeScript / Oxlint / Oxfmt presets, a small scaffolding CLI, and the one
reusable CI workflow every repo calls. It's tooling, not a runtime dependency.
The shared TypeScript base — strict, bundler-mode, the defaults a package inherits:
{
// Shared TypeScript base for Fusion Stack projects.
// Consumers: { "extends": "@tikab-interactive/fusion-config/tsconfig.json" }
// Runtime-specific types (e.g. "types": ["bun"]) belong in the consuming project.
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}A consuming repo's own tsconfig.json is then almost nothing — it extends the
package by name (tsc resolves the npm package) and adds only what's
runtime-specific:
{ "extends": "@tikab-interactive/fusion-config/tsconfig.json" }Lint and format are configured the same way, by package import — and crucially
as TypeScript files, never a .oxlintrc.json (a stray JSON config silently
replaces the shared base instead of extending it). Conventions
has the full reasoning; the shape you'll copy is:
import config from "@tikab-interactive/fusion-config/oxlint";
import { defineConfig } from "oxlint";
export default defineConfig({
extends: [config],
rules: {}, // repo-specific overrides only
});The gates: four scripts, one CI workflow
Every package — and the example — defines the same four scripts. They're the contract the gate runs, and CI is the source of truth (green CI is the bar for merge, not "it works on my machine"):
| Script | Tool | What it checks |
|---|---|---|
lint | oxlint | lint rules (the shared Oxlint preset) |
fmt:check | oxfmt --check | formatting (fails if anything isn't formatted) |
typecheck | tsgo --noEmit | types across the source-linked boundary |
test | bun test | unit tests (an empty echo "no tests" is fine) |
Run them locally exactly as CI does:
bun run lint
bun run fmt:check # use `bun run fmt` to auto-fix, then re-check
bun run typecheck
bun run testOne workflow, called from a thin ci.yml
CI is defined once as a reusable workflow in fusion-config. It installs,
then runs those four scripts in order:
name: Package CI
# Shared CI gate for every Fusion package. Repos call it from a thin ci.yml:
#
# jobs:
# ci:
# permissions:
# contents: read
# packages: read
# uses: tikab-interactive/fusion-config/.github/workflows/package-ci.yml@main
# secrets: inherit
#
# Each package must define `lint`, `fmt:check`, `typecheck`, and `test` scripts
# (an empty `test` is fine — e.g. "echo \"no tests\"").
on:
workflow_call:
inputs:
bun-version:
description: Bun version to set up.
type: string
required: false
default: latest
secrets:
NPM_TOKEN:
# A read:packages PAT for installing @tikab-interactive/* from GitHub
# Packages. Optional — falls back to the job's GITHUB_TOKEN. Set it as an
# org/repo secret if GITHUB_TOKEN can't read the fusion-config package.
required: false
permissions:
contents: read
packages: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ inputs.bun-version }}
- name: Install dependencies
run: bun install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN || secrets.GITHUB_TOKEN }}
- name: Lint
run: bun run lint
- name: Format check
run: bun run fmt:check
- name: Typecheck
run: bun run typecheck
- name: Test
run: bun run testEach package repo just calls it — its entire ci.yml is a few lines:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
# Shared gate (install + lint + fmt:check + typecheck + test), defined once in
# fusion-config. See: tikab-interactive/fusion-config/.github/workflows/package-ci.yml
ci:
permissions:
contents: read
packages: read
uses: tikab-interactive/fusion-config/.github/workflows/package-ci.yml@main
secrets: inheritThe example gates differently (and that's the interesting part)
fusion-meta's CI has the same thin ci caller for the docs at the repo root —
but the example/ app can't use it, because it consumes the packages as source.
A plain bun install in CI would only fetch published tarballs; the source the
../../fusion-* paths point at wouldn't exist. So fusion-meta's workflow has a
second job that lays the sibling repos out next to the checkout first, then
runs the same four gates against the example:
example:
runs-on: ubuntu-latest
steps:
- name: Checkout fusion-meta
uses: actions/checkout@v6
with:
path: fusion-meta
# Lay out the fusion-* source repos the example links to (tsconfig `paths`).
# tsgo (typecheck) and bun (test resolution) read those paths, so the source
# must be present — published tarballs are not enough for source linking.
- name: Checkout sibling package repos
run: |
repos=(fusion-env fusion-db fusion-auth fusion-storage fusion-ui …)
for repo in "${repos[@]}"; do
git clone --depth 1 \
"https://x-access-token:${GH_TOKEN}@github.com/tikab-interactive/${repo}.git" \
"$repo"
done
- name: Install / Lint / Format / Typecheck / Test (example)
working-directory: fusion-meta/example
run: bun install --frozen-lockfile && bun run lint && bun run fmt:check && bun run typecheck && bun run test(The real job also attaches a Postgres service and runs a best-effort Playwright
auth smoke — kept continue-on-error so a flaky full-stack boot can't red the
gate. The hard gates are the four scripts. See Testing for the e2e
story.)
The lesson for contributors: if you change a package's public surface, the
example's gate is what catches the break — because CI rebuilds the example
against your edited source. Run the example's typecheck locally before you push a
package change.
Building a change, end to end
A typical change touches a package and the example. The loop:
- Branch in each repo you'll touch (package +
fusion-meta). An external watcher may auto-commit and push your edits — so work on a branch, never commit straight tomain. - Edit the package under
fusion-*/src. The example picks it up live (source link) —bun run devinexample/reflects it on save. - Wire it into the example if it's a new capability — a sandbox page for a leaf package, or real usage in the product app. (How a feature is assembled: Building a feature.)
- Run the gates in both repos:
lint,fmt:check,typecheck,test. The example'stypecheckis the cross-repo integration check. - Open a PR per repo. Each repo's thin
ci.ymlruns the shared gate;fusion-meta's also rebuilds the example against the siblings. - Don't publish or deploy unless asked. Releases are a deliberate, bottom-up step (next section); deploys are out of band (Deploy).
Adding a new package
To extract or create fusion-<name>, match the canonical file set so it behaves
like every other repo. Getting started
lists the steps; here's the file set and the few easy-to-miss bits.
package.json — name under the @tikab-interactive scope, version 0.1.0 for a
fresh package, the four scripts, publishConfig pointing at GitHub Packages, and
fusion-config as a dev dependency. The scaffold template is:
{
"name": "@tikab-interactive/fusion-<name>",
"version": "0.1.0",
"type": "module",
"module": "src/index.ts",
"exports": { ".": "./src/index.ts" },
"files": ["src"],
"repository": { "type": "git", "url": "https://github.com/tikab-interactive/fusion-<name>.git" },
"publishConfig": { "registry": "https://npm.pkg.github.com/" },
"scripts": {
"typecheck": "tsgo --noEmit",
"lint": "oxlint",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"test": "echo \"no tests\"",
},
"devDependencies": {
"@tikab-interactive/fusion-config": "^1.3.0",
"oxfmt": "^0.54.0",
"oxlint": "^1.69.0",
"typescript": "^5",
},
}The tooling configs, each a one-liner that imports the shared preset:
tsconfig.json→{ "extends": "@tikab-interactive/fusion-config/tsconfig.json" }oxlint.config.ts→ imports…/fusion-config/oxlint(see above); never.oxlintrc.jsonoxfmt.config.ts→ imports…/fusion-config/oxfmt
The two GitHub workflows. CI is the thin caller shown above.
Release runs the same gate, verifies the git tag matches package.json, then
publishes to GitHub Packages on a *.*.* tag:
name: Publish to GitHub Packages
on:
push:
tags:
- "*.*.*"
jobs:
# Run the same shared gate as every PR before publishing.
checks:
permissions:
contents: read
packages: read
uses: tikab-interactive/fusion-config/.github/workflows/package-ci.yml@main
secrets: inherit
publish:
needs: checks
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN || secrets.GITHUB_TOKEN }}
- name: Verify the tag matches package.json version
run: |
PKG_VERSION="$(bun -e 'console.log(require("./package.json").version)')"
if [ "$PKG_VERSION" != "$GITHUB_REF_NAME" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $PKG_VERSION"
exit 1
fi
- name: Publish
run: bun publish --no-git-checks
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN || secrets.GITHUB_TOKEN }}A few gotchas worth internalising (all from Conventions):
- Server-only packages expose a
browserexport condition that resolves to a throwing stub, so the Postgres driver / crypto can't reach the client bundle. - fusion-ui owns all UI and its UI dependencies (Mantine, Tabler, Tiptap) — a new package that needs UI doesn't add Mantine; it goes through fusion-ui.
- Peer deps stay peer. A package declares shared libs (React, Mantine) as
peerDependencies; the consumer installs and dedupes them. bun testwith no tests fails — use"test": "echo \"no tests\""until you have a suite, so the gate stays green.
Then bun install, get the gate green, and tag 0.1.0 to publish. Publishing is
bottom-up: you can only extract a package once everything it depends on is
already published (a dependent can't bun install a dep that isn't on the registry
yet). During that window, the app keeps linking the unpublished package by
source path — exactly the mechanism this whole repo runs on.
The docs (this site)
The docs are a vocs MDX site in fusion-meta/docs. Run and
build them like any Vite project:
cd docs
bun install
bun run dev # local preview with hot reload
bun run build # production build into dist/
bun run fmt:check # docs follow the same fmt gateA page is an .mdx file under docs/src/pages/; the URL is its path
(contributing.mdx → /contributing). To add a page, create the file and add it
to the sidebar in vocs.config.ts.
Code in docs is included from real source, never pasted
This is the one convention that makes the docs trustworthy: almost every code
block on this site is a physical include of a real file, so it can't drift out of
sync with the code. You write a fenced block whose only body is a single include
directive — a // [!include ~/snippets/<path>] comment — and vocs splices in
the live file at build time. (Most code blocks on this very page are exactly that;
the table below shows a few.) The fence's title is the file path, e.g.
[example/src/lib/db.ts].
How the path resolves:
~/snippets/isdocs/src/snippets/, a directory of symlinks. Eachfusion-*sibling repo is symlinked in by name, and so is theexampleapp. So~/snippets/fusion-ui/src/index.tsreaches the realfusion-uisource, and~/snippets/example/src/lib/db.tsreaches the example.- The include path mirrors the title path — the
[file/path]you put in the fence is the same path you include from~/snippets/. Keeping them identical is the convention; it's why a reader can copy the title and find the file. - Whole file: point at the file (good for files under ~120 lines).
- A slice: mark a region in the source with a
// [!region name]…// [!endregion name]pair of comments and suffix the include path with:name(e.g.…/example/vite.config.ts:resolveTsconfigPaths). The two source-linked config blocks near the top of this page are sliced exactly that way. Region markers live only in the example's files (whichfusion-metaowns) — never edit a sibling package's source just to add a docs marker. For a sibling, include the whole file (or a small file from it).
When inline is fine
Not everything is a physical include. Use a normal fenced block for:
- Shell commands (```sh) and terminal recipes.
package.jsonexcerpts / templates as ```jsonc — small, low-drift, and often illustrative rather than a verbatim file.- mermaid diagrams.
- Repo-root files of this repo that aren't reachable via
~/snippets/. There is nofusion-metaself-symlink, so a file likefusion-meta/.github/workflows/ci.ymlhas no include path — paste it inline (it's config, low drift). Files underexample/are reachable (via theexamplesymlink), so include those.
Verify before you push
Unresolved includes render literally (the raw directive shows up in the page), so always do a clean build and check no literal includes leaked:
cd docs
rm -rf node_modules/.cache dist
bun run build
grep -o '!include' dist/public/<page>/index.html # must print NOTHINGIf you added any [!region] markers to the example's files, make sure the
example still passes its own format/lint gate:
cd example
bun run fmt:check
bun run lint