Developing a Kanban Board
This tutorial builds the content model for a Kanban board package. You define a workspace, a page node type, reusable element types and a board archetype, then load the data in your app with a route loader and a query-by-path function. The finished package is the launchpad example in the RaisinDB repository under examples/launchpad/package/; every file below is taken from it.
What you'll build
- A
launchpadworkspace (the content container) - A base page node type:
launchpad:Page - Reusable element types for composing pages:
launchpad:Herolaunchpad:FeatureGridlaunchpad:TextBlocklaunchpad:ListKanbanBoardslaunchpad:KanbanCard
- A
launchpad:KanbanBoardarchetype that arranges cards in columns - A TypeScript page loader and a parameterized SQL query
- A trigger and a function for server-side automation
Package structure
A package is a directory with a manifest.yaml. When you add content models, you mostly work in workspaces/, nodetypes/, elementtypes/ and archetypes/:
package
├── manifest.yaml
├── workspaces
│ └── launchpad.yaml
├── nodetypes
│ └── page.yaml
├── elementtypes
│ ├── feature-grid.yaml
│ ├── hero.yaml
│ ├── kanban-card.yaml
│ ├── list-kanban-boards.yaml
│ └── text-block.yaml
├── archetypes
│ ├── kanban-board.yaml
│ └── landing-page.yaml
├── content
│ ├── functions
│ │ ├── lib/launchpad/handle-friendship-request
│ │ │ ├── .node.yaml
│ │ │ └── index.js
│ │ └── triggers/on-friendship-request
│ │ └── .node.yaml
│ └── launchpad
│ └── launchpad
│ ├── home
│ ├── about
│ └── tasks
│ └── sprint-board
└── static
└── teaser_background.png
Folder guide
workspaces/: allowed content types and the root structurenodetypes/: the document schemas (pages, boards)elementtypes/: reusable blocks rendered by your frontendarchetypes/: templates that extend a node type with a specific layoutcontent/: starter content, plus triggers and functionsstatic/: package assets (images, icons)
The manifest lists what the package provides:
name: launchpad
version: 1.0.25
title: Launchpad
provides:
nodetypes:
- launchpad:Page
archetypes:
- launchpad:LandingPage
- launchpad:KanbanBoard
elementtypes:
- launchpad:Hero
- launchpad:TextBlock
- launchpad:FeatureGrid
- launchpad:ListKanbanBoards
- launchpad:KanbanCard
workspaces:
- launchpad
1) Define a workspace
The workspace defines what can exist, where it can exist, and the initial root skeleton.
Save as: workspaces/launchpad.yaml
name: launchpad
title: Launchpad
description: Content workspace for Launchpad portal
icon: rocket
color: "#6366f1"
allowed_node_types:
- launchpad:Page
- raisin:Folder
allowed_root_node_types:
- raisin:Folder
- launchpad:Page
root_structure:
- name: pages
node_type: raisin:Folder
title: Pages
description: Site pages
allowed_node_types rejects any node of another type at write time, and a predictable root keeps routing and querying straightforward.
2) Define a page node type
Pages are the routed documents your UI loads.
Save as: nodetypes/page.yaml
name: launchpad:Page
title: Page
description: Base page type for Launchpad content
icon: file-text
color: "#6366f1"
version: 1
properties:
- name: title
title: Title
type: String
required: true
index:
- Fulltext
- name: slug
title: Slug
type: String
required: true
- name: description
title: Description
type: String
required: false
versionable: true
publishable: true
auditable: true
indexable: true
- Treat
slugas the stable input for routing. index: [Fulltext]ontitlemakes the page findable withFULLTEXT_SEARCH.versionableandpublishablegive you revision history and a publish step;auditablewrites audit-log entries for changes.
3) Define element types (page blocks)
Elements are your reusable components. Your frontend renders each element type with a corresponding UI component. Fields are declared with a $type; the ones used here are TextField, RichTextField, MediaField and CompositeField.
Text block
Save as: elementtypes/text-block.yaml
name: launchpad:TextBlock
title: Text Block
description: Rich text content block
icon: align-left
color: "#10b981"
version: 1
fields:
- $type: TextField
name: heading
title: Heading
required: false
- $type: RichTextField
name: content
title: Content
required: true
Hero
Save as: elementtypes/hero.yaml
name: launchpad:Hero
title: Hero Section
description: Full-width hero section with headline, subheadline, and call-to-action
icon: image
color: "#8b5cf6"
version: 1
fields:
- $type: TextField
name: headline
title: Headline
required: true
- $type: TextField
name: subheadline
title: Subheadline
required: false
- $type: TextField
name: cta_text
title: CTA Button Text
required: false
- $type: TextField
name: cta_link
title: CTA Button Link
description: URL to navigate to when clicked
required: false
- $type: TextField
name: cta_action
title: CTA Action
description: Action to trigger (e.g., createBoard). Used instead of cta_link.
required: false
- $type: MediaField
name: background_image
title: Background Image
required: false
cta_action is a clean way to drive client-side behavior (open a modal, start a wizard) without encoding logic in URLs.
Feature grid
A CompositeField with repeatable: true holds a list of sub-records.
Save as: elementtypes/feature-grid.yaml
name: launchpad:FeatureGrid
title: Feature Grid
description: Grid of feature cards with icons and descriptions
icon: grid-3x3
color: "#f59e0b"
version: 1
fields:
- $type: TextField
name: heading
title: Section Heading
required: false
- $type: CompositeField
name: features
title: Features
repeatable: true
fields:
- $type: TextField
name: icon
title: Icon Name
required: false
- $type: TextField
name: title
title: Feature Title
required: true
- $type: TextField
name: description
title: Feature Description
required: true
List Kanban boards
Rendered as a grid of board links.
Save as: elementtypes/list-kanban-boards.yaml
name: launchpad:ListKanbanBoards
title: List Kanban Boards
description: Displays a grid of Kanban board links for navigation
icon: layout-grid
color: "#8b5cf6"
version: 1
fields:
- $type: TextField
name: heading
title: Section Heading
required: false
Kanban card
A single task card.
Save as: elementtypes/kanban-card.yaml
name: launchpad:KanbanCard
title: Kanban Card
description: A task card for Kanban boards
icon: square-check
color: "#8b5cf6"
version: 1
fields:
- $type: TextField
name: title
title: Card Title
required: true
- $type: TextField
name: description
title: Card Description
required: false
- $type: TextField
name: note
title: Note
required: false
4) Define the board archetype
An archetype extends a node type (base_node_type) with a specific layout. The Kanban board is a page whose columns field is a repeatable composite, and each column has a cards section that only accepts launchpad:KanbanCard elements.
Save as: archetypes/kanban-board.yaml
name: launchpad:KanbanBoard
title: Kanban Board
description: A Kanban board page with columns and draggable cards for task management
icon: kanban
color: "#8b5cf6"
base_node_type: launchpad:Page
version: 1
fields:
- $type: TextField
name: title
title: Board Title
required: true
- $type: TextField
name: slug
title: URL Slug
required: true
- $type: TextField
name: description
title: Board Description
required: false
- $type: CompositeField
name: columns
title: Columns
repeatable: true
fields:
- $type: TextField
name: id
title: Column ID
required: true
- $type: TextField
name: title
title: Column Title
required: true
- $type: SectionField
name: cards
title: Cards
allowed_element_types:
- launchpad:KanbanCard
publishable: true
A board instance is a launchpad:Page node with archetype: launchpad:KanbanBoard. Seed content ships in the package as YAML, content/launchpad/launchpad/tasks/sprint-board/.node.yaml:
node_type: launchpad:Page
archetype: launchpad:KanbanBoard
properties:
title: Sprint Board
slug: sprint-board
description: Current sprint tasks and progress tracking
columns:
- id: col-backlog
title: Backlog
cards:
- uuid: card-1
element_type: launchpad:KanbanCard
title: Setup project repository
description: Initialize Git repo and configure CI/CD pipeline
- id: col-in-progress
title: In Progress
cards:
- uuid: card-4
element_type: launchpad:KanbanCard
title: Implement user authentication
description: Add JWT-based auth with refresh tokens
Each element in a section carries a uuid and an element_type; the remaining keys are the element type's fields.
Deploy the package with the CLI:
raisindb deploy ./package --repo launchpad --install
5) Access the data from your app
Once the schemas exist and content is created, your app needs a consistent way to load a page:
- map route params to a canonical content
path - fetch the page by path
- return
{ page }or a controlled{ error }
Route loader (SvelteKit)
From examples/launchpad/frontend/src/routes/[...slug]/+page.ts:
import type { PageLoad } from './$types';
import { getPageByPath } from '$lib/raisin';
export const load: PageLoad = async ({ params }) => {
const slug = params.slug || 'home';
const path = `/${slug}`;
try {
const page = await getPageByPath(path);
return { page };
} catch (error) {
console.error(`Failed to load page: ${path}`, error);
return {
page: null,
error: error instanceof Error ? error.message : 'Page not found'
};
}
};
Query by path (SQL)
Inside getPageByPath run a parameterized SQL query. The workspace is the table, and archetype is a column, so the frontend can pick the component from the row. From examples/launchpad/frontend/src/lib/raisin.ts:
const sql = `
SELECT id, path, name, node_type, archetype, properties
FROM launchpad
WHERE path = $1
LIMIT 1
`;
const result = await db.executeSql(sql, [nodePath]);
const page = result.rows[0] ?? null;
db is client.database('launchpad') from @raisindb/client, and executeSql returns { columns, rows, row_count }. Use $1 parameters rather than string concatenation, keep the path format consistent (leading slash), and decide whether a missing page returns null or throws.
Functions (server-side automation)
Server-side automation in a package uses two node types:
- Triggers (
raisin:Trigger) declare when to run something. - Functions (
raisin:Function) hold the code that runs.
In the package layout they live under content/functions/triggers/ and content/functions/lib/.
The example below is a friendship-request workflow from the launchpad package rather than a Kanban automation, but the pattern is the same for Kanban (for example "when a card is created, assign a default status").
1) Trigger: when an outbox message is created
File: content/functions/triggers/on-friendship-request/.node.yaml
It listens for a Created node event, matches only raisin:Message nodes under **/users/**/outbox/* in the raisin:access_control workspace whose properties look like a pending friendship request, and dispatches to the function at function_path:
node_type: raisin:Trigger
properties:
title: Process Friendship Request
description: |
Handles friendship request messages by finding recipient by email
and creating a message in their inbox.
enabled: true
trigger_type: node_event
config:
event_kinds:
- Created
filters:
workspaces:
- raisin:access_control
paths:
- "**/users/**/outbox/*"
node_types:
- raisin:Message
property_filters:
message_type: friendship_request
status: pending
priority: 10
max_retries: 3
function_path: /lib/launchpad/handle-friendship-request
- Keep
filtersas specific as possible so the function only runs for the events you mean. max_retriesmeans the function can run more than once for the same event; make it idempotent.function_pathpoints intocontent/functions/lib/.
2) Function definition: metadata and entrypoint
File: content/functions/lib/launchpad/handle-friendship-request/.node.yaml
node_type: raisin:Function
properties:
name: handle-friendship-request
title: Handle Friendship Request
description: |
Processes friendship request messages from user outbox.
Finds the recipient by email and creates a message in their inbox.
execution_mode: async
enabled: true
language: javascript
entry_file: index.js:handleFriendshipRequest
version: 1
input_schema:
type: object
description: Trigger context with event and workspace
properties:
flow_input:
type: object
properties:
event:
type: object
properties:
type:
type: string
node_id:
type: string
node_type:
type: string
node_path:
type: string
workspace:
type: string
output_schema:
type: object
properties:
success:
type: boolean
error:
type: string
inbox_message_path:
type: string
entry_fileisfile.js:functionName. The function is looked up on the module exports or as a top-level function of that name.- Keep schemas small but accurate; they are the function's contract.
- Return
{ success: false, error: '...' }for expected failures so the trigger does not retry them.
3) Function implementation (JavaScript)
File: content/functions/lib/launchpad/handle-friendship-request/index.js
The steps:
- Load the message node that triggered the event
- Read and validate the recipient email
- Query the access-control workspace for the user
- Create a message in the recipient's inbox
- Update the original outbox message to
sent(orerror)
/**
* Triggered when a friendship_request message is created in a user's outbox.
* Finds the recipient by email and creates a message in their inbox.
*/
async function handleFriendshipRequest(context) {
const { event, workspace } = context.flow_input;
const ACCESS_CONTROL = 'raisin:access_control';
console.log('[friendship] Trigger fired for:', event.node_path);
// 1. Get the message node from the event
const message = await raisin.nodes.get(workspace, event.node_path);
if (!message) return { success: false, error: 'Message not found' };
// 2. Recipient email from the message body
const recipientEmail = message.properties.body?.recipient_email;
if (!recipientEmail) return { success: false, error: 'Missing recipient_email' };
// 3. Find the user. In the function runtime raisin.sql.query returns
// the row array directly.
const rows = await raisin.sql.query(
`SELECT id, path FROM '${ACCESS_CONTROL}'
WHERE node_type = 'raisin:User' AND properties->>'email'::String = $1`,
[recipientEmail],
);
if (!rows.length) {
await raisin.nodes.update(workspace, event.node_path, {
properties: { ...message.properties, status: 'error', error: `User not found: ${recipientEmail}` },
});
return { success: false, error: 'User not found' };
}
const recipient = rows[0];
// 4. Create the message in the recipient's inbox
try {
const inboxMessage = await raisin.nodes.create(ACCESS_CONTROL, `${recipient.path}/inbox`, {
name: `friend-req-${Date.now()}`,
node_type: 'raisin:Message',
properties: {
message_type: 'friendship_request',
subject: 'Friendship Request',
body: {
sender_email: message.properties.body?.sender_email,
sender_display_name: message.properties.body?.sender_display_name,
message: message.properties.body?.message,
},
sender_id: message.properties.sender_id,
recipient_id: recipient.id,
status: 'delivered',
created_at: new Date().toISOString(),
},
});
// 5. Mark the original as sent
await raisin.nodes.update(workspace, event.node_path, {
properties: { ...message.properties, status: 'sent', recipient_id: recipient.id },
});
return { success: true, inbox_message_path: inboxMessage.path };
} catch (err) {
await raisin.nodes.update(workspace, event.node_path, {
properties: { ...message.properties, status: 'error', error: err.message },
});
return { success: false, error: err.message };
}
}
The bindings used here are raisin.nodes.get(workspace, path), raisin.nodes.create(workspace, parentPath, { name, node_type, properties }), raisin.nodes.update(workspace, path, { properties }) and raisin.sql.query(sql, params). raisin.nodes.update replaces the properties object, which is why the code spreads the existing properties first.
The shipped version in the example goes one step further: it writes the inbox message and a notification in one transaction (raisin.nodes.beginTransaction(), tx.createDeep(...), tx.commit()) and then moves the outbox message to the sender's sent folder with raisin.nodes.move.
Practices
- Idempotency: triggers may retry. Store a correlation id on the original message and check for it before creating a second inbox message.
- Workspace boundaries: the function reads from the event's
workspacebut writes intoraisin:access_control. Make that boundary explicit in your design. - Exact matches:
properties->>'email'::String = $1compares the property as text; useLIKEonly when you want partial matching. - Schema drift: if the shape of
properties.body.recipient_emailchanges, update the function and its schema together.
Next steps
- Add card
statusandorderfields for real Kanban columns. - Add triggers in
content/functions/triggers/that react to card updates. - Read Data-Centric Application Design for the model behind archetypes.