backend / inkboard

Inkboard

A personal kanban whose Next.js server actions sit on a typed domain layer over SQLite (libSQL + Drizzle), with fractional ranking for drag-and-drop order and deadline buckets computed on the server.

2026 · paused · local repository

ui → actions: server action RPCui → page: RSC render ?board&viewpage → services: getBoardSnapshotactions → services: create/move/updateservices → domain: rankBetween, dueBucketservices → db: Drizzle SQLmigrator → db: schema on first connectKanban UI (client)Kanban UI (client) (client) dnd-kit, useOptimistic, cmdkBoot migratorBoot migrator (worker) idempotent CREATE IF NOT EXISTSpage.tsx (RSC)page.tsx (RSC) (service) force-dynamic board snapshotServer actionsServer actions (service) 11 actions + revalidatePathBoard/task servicesBoard/task services (service) Drizzle queries, DomainErrorDomain layerDomain layer (service) rank, deadline buckets, statusSQLite via libSQLSQLite via libSQL (store) 5 tables, data/inkboard.dbserver action RPCRSC render ?board&viewgetBoardSnapshotcreate/move/updaterankBetween, dueBucketDrizzle SQLschema on first conne… ui → actions: server action RPCui → page: RSC render ?board&viewpage → services: getBoardSnapshotactions → services: create/move/updateservices → domain: rankBetween, dueBucketservices → db: Drizzle SQLmigrator → db: schema on first connectKanban UI (client)Kanban UI (client) (client) dnd-kit, useOptimistic, cmdkBoot migratorBoot migrator (worker) idempotent CREATE IF NOT EXISTSpage.tsx (RSC)page.tsx (RSC) (service) force-dynamic board snapshotServer actionsServer actions (service) 11 actions + revalidatePathBoard/task servicesBoard/task services (service) Drizzle queries, DomainErrorDomain layerDomain layer (service) rank, deadline buckets, statusSQLite via libSQLSQLite via libSQL (store) 5 tables, data/inkboard.dbserver action RPCRSC render ?board&viewgetBoardSnapshotcreate/move/updaterankBetween, dueBucketDrizzle SQLschema on first conne…
clientserviceworkerstoreasync
  • [*]LanguagesTypeScript
  • [*]FrameworksNext.js App Router server actions · Drizzle ORM · React 19 useOptimistic
  • [*]StoresSQLite (libSQL)
  • [*]InfraBun · Next.js (local)

Inkboard is a single-user kanban: each card has a deadline and moves through five stages (Backlog, Ready, Processing, Review, Done). The backend is 11 Next.js server actions in src/module/board/actions.ts. Each one calls a service in src/server/services and then revalidates the page. The page itself is a force-dynamic server component that loads a whole-board snapshot.

Data lives in five SQLite tables (boards, tasks, labels, task_labels, checklist_items), accessed through Drizzle on libSQL. A composite index on (board_id, status, rank) matches the column query, and foreign keys cascade on delete. The schema is applied on first connection by an idempotent CREATE ... IF NOT EXISTS migrator, and the client is cached on globalThis per DATABASE_URL, so dev hot-reloads do not open a new handle each time and Playwright can point at a throwaway file.

Card order uses fractional ranks. A move stores the midpoint between its neighbours, so a drag writes one row. When two neighbours get closer than 1e-6, the column is re-spaced to multiples of 1,000. Deadlines are stored as date-only strings and turned into overdue, today, week or later buckets on the server at read time. Done cards are excluded from the urgent views, and the sidebar counts come from the same buckets.

Domain rules (non-blank titles, known status and priority, YYYY-MM-DD deadlines, the last board can't be archived) throw a typed DomainError from the service layer. The client uses React useOptimistic for moves and shows a toast if the server rejects one. The repo also has its own ESLint plugin with three rules, one of which stops a 'use client' file from importing @/server or @/db.

How it holds up

Auth
None. It is a local single-user app with no sessions or accounts; the only boundary is architectural: a custom ESLint rule stops client components from importing the server or DB modules.
Data
5 tables (boards, tasks, labels, task_labels, checklist_items), 1 composite index tasks(board_id, status, rank), ON DELETE CASCADE foreign keys, and soft-archive via archived_at on boards and tasks. One idempotent migrator with 5 CREATE TABLE statements; there are no versioned migration files.
Consistency
Ordering uses fractional ranks (midpoint insert, rebalance to 1000-spaced ranks when the gap drops below 1e-6), so a move normally writes one row. No writes are wrapped in transactions: label replacement (delete then insert) and column rebalancing (N updates) can be left half-applied if one fails. ensurePersonalBoard is idempotent on first boot, which a test confirms.
Failure
Service functions throw a typed DomainError for invariant violations and missing rows. Server actions re-throw it, and the client shows the message in a toast while useOptimistic drops the optimistic move when the transition ends. There are no retries; it is a local single process.
Testing
3 bun test files with 26 cases (tasks/boards service 11 against an in-memory libSQL DB, deadline 9, rank 6). 1 Playwright spec with 4 cases that runs the dev server on its own throwaway SQLite file.

Techniques

  1. Fractional ranking with rebalanceA drag-and-drop move stores the midpoint of its neighbours' ranks, so reordering writes one row. When the gap falls below 1e-6, the whole column is re-spaced to multiples of 1,000.src/server/domain/rank.ts:4 src/server/domain/rank.ts:16 src/server/services/tasks.ts:293 src/server/services/tasks.ts:311
  2. Batched relation hydrationLabels and checklist items for a whole column are loaded with two IN queries and grouped in memory, not fetched per card, so a board render takes three queries however many cards there are.src/server/services/tasks.ts:65 src/server/services/tasks.ts:105
  3. Server-computed deadline bucketsDate-only deadlines are parsed strictly (calendar-validated) and bucketed against the local day into overdue, today, week or later on the server. Done cards are excluded from urgent views, and the sidebar counts come from the same buckets.src/server/domain/deadline.ts:15 src/server/domain/deadline.ts:49 src/server/services/tasks.ts:421
  4. Hot-reload-safe DB singleton plus in-memory test DBThe libSQL client is cached on globalThis, keyed by DATABASE_URL, and migrated on first connect. A createMemoryDb twin gives each test a fresh in-memory schema, and Playwright gets its own SQLite file.src/db/index.ts:10 src/db/index.ts:23 src/db/index.ts:38 playwright.config.ts:23
  5. Server/client boundary lint ruleA repo-local ESLint plugin fails the build if a 'use client' module imports @/server or @/db. This catches the bug where a database client or secret-bearing module ends up in the browser bundle.tooling/eslint-plugin-standards/rules/no-server-import-in-client.js:10 eslint.config.mjs:24

Counted

  • 0 (untracked working tree on a feature branch)commits
    git status in ~/dev/inkboard
  • 11server actions
    grep -c 'export async function' src/module/board/actions.ts
  • 5tables
    grep -c 'CREATE TABLE' src/db/migrate.ts
  • 3unit test files
    ls src/server/**/*.test.ts | wc -l
  • 26unit test cases
    grep -cE '^\s*(test|it)\(' per test file (11 + 9 + 6)
  • 4e2e cases
    grep -c test( e2e/board.spec.ts
  • 3custom ESLint rules
    ls tooling/eslint-plugin-standards/rules | wc -l
  • 1391server + data-layer LOC
    find src/server src/db src/module/board/actions.ts -name '*.ts' | xargs wc -l | tail -1
  • 4760total source LOC (src, tooling, e2e)
    find src tooling e2e -name '*.ts' -o -name '*.tsx' -o -name '*.js' | xargs wc -l | tail -1