Kern's wiki: spaces, a nested page tree, collaborative documents, versions and comments — and databases, which are pages with typed columns.
This is a first-party Kern module. It ships its own contract, server, screens, strings and manifest
in one package; a host service imports quireModule and the app shell registers quireClientModule.
Deleting the package removes the feature completely.
pnpm install
pnpm typecheck && pnpm lint && pnpm test && pnpm buildThe integration tests need a Postgres to talk to:
DATABASE_URL=postgres://kern:kern@localhost:5432/kern pnpm test. They create a scratch database per
run and drop it afterwards, so they never touch development data.
| File | What it is |
|---|---|
src/contract/ |
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_quire. |
src/server/services/ |
Spaces, pages, versions, comments, databases, and the SQL that filters and sorts a view. |
src/server/_impl.ts |
The router. Separate from index.ts so module.test.ts can walk it without a kernel. |
src/client/ |
The typed API client, the Svelte screens and the message bundles. Ships as source. |
src/client/database/ |
The database interface: the views, the cells, the property and view editors. |
migrations/ |
Generated by pnpm db:generate, except the row-level-security one, which is written by hand. |
The two realtime paths stay separate, and that is the decision the rest of the module hangs off.
A page's prose lives in a Y.Doc synchronised through the collab service; its title, position,
properties and comments are ordinary rows. page_versions is the backbone of both halves of the
draft model: a page serves published_version_id to a reader while a live doc serves the
document — one mechanism, two behaviours.
Restoring a version uses collab.document.replace, never .apply. applyUpdate merges, so
applying an older state produces the union of old and new rather than the old one.
See docs/adr/0006-collaborative-documents.md in the umbrella repository.
A database is not a second kind of object beside a page: it is a page whose body draws a view instead of prose, and each of its rows is a page too. That is what makes a row openable, commentable, versioned and searchable without any of it being built twice.
- A row's cells live in
pages.props, keyed by the property's key rather than its id, so renaming a column keeps its data. - What the server works out from those cells — formulas and rollups — lives in
pages.computed, so a view can sort by a formula in SQL like any other column. - Formulas are evaluated by walking a typed AST. Never
eval, nevernew Function: an expression is text a workspace member types and the server runs. - Filtering and sorting happen in SQL. A page of fifty rows filtered down to three is not a page of three, and the caller has no way to ask for the rest.
A page whose kind is database renders view tabs, a toolbar and the chosen view.
| View | What it does |
|---|---|
| Table | Columns in position order, inline editing per type, resize and reorder, row hover actions, paged 50 at a time |
| Board | Lanes from a select, status or tick column; drag between lanes, and a Move to menu on every card |
| Gallery | Cards of the title and the columns the view shows |
| List | One row each, with the first three columns as trailing values |
| Calendar | A month plotted on a date column; drag between days, and a Move to a date menu on every card |
Every property type has an editor, and the six the server writes — formula, rollup,
created_time, created_by, edited_time, edited_by — render read-only and say why on hover.
Three things are deliberately not built, and the interface says so rather than pretending:
- Attachments. Quire has no file handling at all, so an
attachmentcolumn renders read-only, is not offered when adding a column, and a gallery's cover picker is disabled with that reason. - Timeline.
ViewKinddeclares it; it needs a start and an end date per row, a scale and a horizontal virtualiser, none of which exists. It is left out of the kinds the view editor offers, and a view already saved astimelinefalls back to the table. - A manual row order. Rows are ordered by the view's sorts and then by id; there is no per-row rank. A board drop inside its own lane therefore snaps back, because there is nothing to persist.
updateView replaces config wholesale. It is one jsonb column and the service writes what it
is given, so { config: { sorts: [...] } } destroys the filters, the grouping, the column widths and
the visible columns in the same request. Every write goes through mergeConfig in
src/client/database/view-config.ts.
A property's value is not always in props. Formulas and rollups are in computed, and the four
audit stamps are real columns on the page. valueExpr in src/server/services/query.ts is the one
place that knows which; reading props for those six matches nothing and sorts nothing, silently,
because an absent key is a valid absent value.
pnpm typecheck && pnpm lint && pnpm test && pnpm buildlint runs three checks beyond Biome, and each of them exists because the mistake it catches is
invisible otherwise: an icon name that is not in @kernhq/ui's registry renders a blank square, a
--kern-* token that does not exist is a declaration the browser drops, and a message key missing
from one of the five locale bundles type-checks perfectly and falls back to English at runtime.
Then use it through the interface, signed in, with the module enabled for a workspace — in both themes, and in Persian. A module that has never served a request is not finished, whatever the type-checker says.