Skip to content

Repository files navigation

@kernhq/module-template

The starting point for a Kern module. Apache-2.0, so what you build from it is yours to license however you like — including not at all.

npx degit KernAIO/module-template my-module

A Kern module is one package. This one is a whole working module — a Note entity with list, create, delete and archive, its own Postgres schema, row-level security, permissions, capabilities, events, its own screens and its own strings — and a test that refuses to let the contract and the router drift apart.

The application holds no screens belonging to a module. Deleting your package removes your feature completely; that is the test of whether it is a module at all.

STRUCTURE.md is the map — what every directory is for, and the one part that is optional. In short: most modules are contract + server + client, and core hosts them. A module that needs its own process — an open socket, a queue it drains on its own clock — is hosted by a sibling service repository (see KernAIO/chat and KernAIO/mail), which declares it in featureModules and ships the Dockerfile. Nothing else changes.

What is in here

File What it is
src/contract.ts Zod models, the oRPC contract, events, permission keys, capabilities. Imported by both halves, so no Node code.
src/server/schema.ts Drizzle tables in mod_<id>.
src/server/_impl.ts The router. Separate from index.ts so the test can walk it without a kernel.
src/server/index.ts defineServerModule — schema, migrations, router, subscriptions.
src/client/index.ts The typed API client and module logic. Ships as source.
src/module.test.ts Contract-to-router parity and the authorisation guard. Keep it.
src/server/migrations.test.ts Replays the migration folder, then audits row-level security against the Postgres catalogue. Keep it.
migrations/0000_init.sql Generated by pnpm db:generate.
migrations/0001_rls.sql Hand-written. Never generated.

Copying it by hand

Each of these has been got wrong before.

  1. package.json — set name to @kernhq/module-<id>, and delete "private": true. A private package is skipped silently by changesets: the commit lands, CI is green, and nothing publishes.
  2. files must cover every directory ./client reaches — src/client and src/contract. The client ships as source, so a re-export the tarball omits breaks the consumer and nothing local notices, because the workspace resolves the file the package does not ship. pnpm check:pack catches it.
  3. The id agrees in four places: MODULE_ID, moduleSchema('<id>'), schemaFilter in drizzle.config.ts, and every permission and event prefix.
  4. Version comes from the package, never a literal: packageVersion(import.meta.url). A literal is not bumped by a release — chat once shipped as 0.2.0 while telling every admin it was 0.1.0, and that literal is what workspace_modules.installed_version recorded.
  5. Write the RLS migration. pnpm db:generate will not. Copy 0001_rls.sql and change the table names; rlsPolicySql from @kernhq/kernel emits the same four statements. See Your migrations must survive a replay below for why the drop policy if exists is one of them.
  6. Host it. A module nothing imports is invisible: its tests pass, it publishes, and every call 404s. Add it to featureModules in the core repo's src/service.ts, or to whichever service should hold it.
  7. Register the client. One line in the app's src/lib/modules/registry.ts: registerModule(crmClientModule), importing from @kernhq/module-crm/client. Together with step 6 that is the only wiring outside this package.

Your migrations must survive a replay

src/server/migrations.test.ts applies migrations/ to a database created from nothing, applies it a second time, and then asks the Postgres catalogue which of your tables are actually secured. Keep it, and keep it passing. It guards two failures that nothing else in a module's suite can see.

A migration that throws stops the whole host service. The kernel migrates every module at boot, in one process, before it binds a port. So a module whose SQL fails does not degrade its own feature — it takes the other modules in that service down with it, and core hosts five. A replay is ordinary rather than rare: drizzle keys applied migrations by content hash, so editing any file in migrations/ makes drizzle run every file in it again. create policy and add constraint have no if not exists, and create table and create index only get one if you write it.

A tenant table with no policy is simply readable. Row-level security is hand-written, so forgetting it on a new table leaves a schema that is valid, silent and unprotected. The test asks the catalogue rather than a list you maintain, so a table you added and did not secure fails by name.

Two lists keep it honest, and both live in your module:

  1. Add every new tenant table to TENANT_TABLES in src/server/schema.ts.
  2. If a table must stay outside a policy, name it in UNSECURED_BY_DESIGN in the test, with the reason and the code that isolates it instead.

A tenant table in neither list fails the test, so adding one is a decision you record. An entry in UNSECURED_BY_DESIGN that has since been given a policy also fails, so the list cannot quietly go stale.

What a module can contribute

The server half declares tables, migrations, a router, procedures other modules call through kernel.call(), jobs, subscriptions, search indexers and lifecycle hooks.

The client half — src/client/module.ts in this package — declares nav, routes, commands, settingsPages, widgets for the dashboard, sidebar for the column beside the rail, presenters for rendering this module's objects inside somebody else's screen, and messages for its own strings. The shell renders whatever it finds; there are no route files in the application to keep in step. Read the kern-widget skill before writing a widget, and kern-module for the whole sequence.

Internationalisation. The starter ships its strings in English only, by design; the platform's locales are en, de, fa, ar and tr, and a module author adds the bundles their own module needs in src/client/i18n.ts.

What a screen may reach for

A module cannot import the application, so everything it needs from the shell comes from @kernhq/ui: session (who is signed in, what they may do, which capabilities the workspace has), navigation (where we are, go, describe), getHost (the API origin, whether the mock is running), t (this module's strings and the shared common bundle), the formatters, realtime, uploadFile, WidgetState, the design-system components and the charts.

Three things will compile while you are editing inside the app and fail the moment this package is built on its own — so they are worth knowing before you write them:

  • $app/state and $app/navigation do not exist here. A route component is passed workspaceId, workspaceSlug and params; anything else asks navigation.
  • $lib/* and $msg are the application's aliases. Your strings live in src/client/i18n.ts.
  • Importing this package's own barrel (./index.js) from inside it is a cycle. Name the file. The barrel re-exports the manifest, which reaches Svelte — so a pure-function test that goes through it fails with $state is not defined.

Before you call it done

src/server/migrations.test.ts needs a real Postgres — it creates a scratch database, applies your migrations to it and drops it again. Nothing is mocked, because the failures it looks for live in the Postgres catalogue. Point DATABASE_URL at a database you can create databases from; without it the suite falls back to postgres://kern:kern@localhost:5432/kern. CI starts one as a service container, in .github/workflows/ci.yml.

export DATABASE_URL=postgres://kern:kern@localhost:5432/kern
pnpm typecheck && pnpm lint && pnpm test && pnpm build

All four report success, and pnpm test names your migration test among the files it ran.

Then check by hand the two things no script here checks: that the tarball npm pack produces contains every directory ./client imports from, and that the manifest reports packageVersion(import.meta.url) rather than a literal.

Then use it through the interface, signed in, with the module enabled for a workspace. A module that has never served a request is not finished, whatever the type-checker says.

About

Apache-2.0 starting point for a Kern module — a whole working module: contract, server, schema, RLS, screens and strings

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages