Skip to content
Fusion

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:

example/tsconfig.json
		"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 paths via resolve: { 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.allow opens the parent folder, and resolve.dedupe collapses React / Mantine / drizzle-orm / @tanstack/ai onto the app's single copy (a second React from a sibling's node_modules is the classic Invalid hook call; see Conventions):

    example/vite.config.ts
    	resolve: {
    		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.

Loading diagram...

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:

fusion-config/configs/tsconfig.base.json
{
	// 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:

tsconfig.json
{ "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:

oxlint.config.ts
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"):

ScriptToolWhat it checks
lintoxlintlint rules (the shared Oxlint preset)
fmt:checkoxfmt --checkformatting (fails if anything isn't formatted)
typechecktsgo --noEmittypes across the source-linked boundary
testbun testunit 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 test

One 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:

fusion-config/.github/workflows/package-ci.yml
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 test

Each package repo just calls it — its entire ci.yml is a few lines:

.github/workflows/ci.yml
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: inherit

The 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:

.github/workflows/ci.yml — example job (excerpt)
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:

  1. 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 to main.
  2. Edit the package under fusion-*/src. The example picks it up live (source link) — bun run dev in example/ reflects it on save.
  3. 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.)
  4. Run the gates in both repos: lint, fmt:check, typecheck, test. The example's typecheck is the cross-repo integration check.
  5. Open a PR per repo. Each repo's thin ci.yml runs the shared gate; fusion-meta's also rebuilds the example against the siblings.
  6. 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:

package.json (template)
{
	"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.json
  • oxfmt.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:

.github/workflows/release.yml
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 browser export 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 test with 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 gate

A 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 // [&#33;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/ is docs/src/snippets/, a directory of symlinks. Each fusion-* sibling repo is symlinked in by name, and so is the example app. So ~/snippets/fusion-ui/src/index.ts reaches the real fusion-ui source, and ~/snippets/example/src/lib/db.ts reaches 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 // [&#33;region name]// [&#33;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 (which fusion-meta owns) — 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.json excerpts / 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 no fusion-meta self-symlink, so a file like fusion-meta/.github/workflows/ci.yml has no include path — paste it inline (it's config, low drift). Files under example/ are reachable (via the example symlink), 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 NOTHING

If you added any [&#33;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