Page System
Status: Shipped Crates: crates/domain/src/page.rs, crates/domain/src/block.rs,
crates/application/src/page/
Overview
Section titled “Overview”The Page System is the primary content management subsystem. Pages are the fundamental content unit in a workspace —
each page has a title, a URL-safe slug, and one or more blocks of content. Pages form a parent-child hierarchy where
folders are themselves pages (“folders ARE pages” model), and connect to each other through wiki-style links
([[Display|slug]]) with automatic backlink tracking.
All pages are stored as rows in a per-workspace SQLite database (inklings.db). Block content is stored as Loro CRDT
BLOBs (content_loro column) which serve as the source of truth for editor state and operation history. Materialized
text is kept alongside for FTS5 full-text search indexing.
Architecture
Section titled “Architecture”File Structure
Section titled “File Structure”crates/domain/src/ page.rs # Page entity, PageParts, slugify(), validate_slug() page_type.rs # PageType enum (Page, Folder) page_tree.rs # PageTreeNode (sidebar tree representation) page_detail.rs # PageDetail (context panel composite) block.rs # Block entity, validate_area_name() block_content_type.rs # BlockContentType enum (Markdown, Image) reference.rs # Reference entity (wiki-link tracking) search.rs # SearchResult entity
crates/application/src/page/ mod.rs # Re-exports all use cases services.rs # PageRepository + ReferenceRepository traits create.rs # CreatePageUseCase get.rs # GetPageUseCase get_detail.rs # GetPageDetailUseCase update.rs # UpdatePageUseCase delete.rs # DeletePageUseCase (soft delete) permanent_delete.rs # PermanentDeletePageUseCase empty_trash.rs # EmptyTrashUseCase restore.rs # RestoreDeletedPageUseCase list.rs # ListPagesUseCase list_tree.rs # ListPageTreeUseCase list_filtered_tree.rs # ListFilteredTreeUseCase list_deleted.rs # ListDeletedPagesUseCase move_page.rs # MovePageUseCase rename.rs # RenamePageUseCase (with link propagation) search.rs # SearchPagesUseCase export.rs # ExportPagesUseCase set_icon.rs # SetPageIconUseCase set_type.rs # SetPageTypeUseCase create_block.rs # CreateBlockUseCase create_block_in_area.rs # CreateBlockInAreaUseCase save_block_content.rs # SaveBlockContentUseCase (CRDT BLOB + text) save_block_content_by_id.rs # SaveBlockContentByIdUseCase (per-cell grid) get_block_snapshot.rs # GetBlockSnapshotUseCase (CRDT BLOB retrieval) update_block_metadata.rs # UpdateBlockMetadataUseCase get_backlinks.rs # GetBacklinksUseCase get_outgoing_links.rs # GetOutgoingLinksUseCase get_descendants.rs # GetPageDescendantsUseCase apply_layout.rs # ApplyLayoutUseCase remove_layout.rs # RemoveLayoutUseCase assign_block_area.rs # AssignBlockToAreaUseCase check_layout_compatibility.rs # CheckLayoutCompatibilityUseCase update_references.rs # UpdateReferencesUseCase wiki_link_parser.rs # Wiki-link parsing and replacementKey Design Decisions
Section titled “Key Design Decisions”- Folders ARE pages. A folder is a page with
page_type = Folderand child pages. Every folder has its own content — there is no separate folder concept. - SQLite storage, not files. Pages are rows in
inklings.db, not markdown files on disk. One database per workspace. - Loro CRDT source of truth. Block content is stored as LoroDoc binary snapshots in
content_loroBLOB columns. ThecontentTEXT column holds materialized text derived from the CRDT, used only for FTS5 indexing and debug inspection. The CRDT BLOB is passed through untouched — never re-serialized from materialized text. - FTS5 3-column index. Full-text search uses a 3-column FTS5 index over
title,content, andtagswith weighted BM25 scoring (bm25(10.0, 1.0, 5.0)). - Specta type generation. All domain structs derive
specta::Typefor automatic Rust-to-TypeScript type generation intopackages/contracts/generated/. No hand-written TypeScript interfaces. - Slug-based addressing. Pages are identified by a URL-safe slug derived from the title (e.g.,
"The Hero's Journey"becomes"the-hero-s-journey"). Hierarchical pages use path slugs (e.g.,"characters/hero"). - Capability-gated operations. Every use case requires a
PermissionGuardand checks the appropriate capability (PagesWrite,PagesRead,PagesDelete,PagesOrganize). - Two-phase deletion. Delete moves pages to trash (soft delete). Users can restore or permanently delete from trash.
EmptyTrashUseCasepurges all trashed pages.
Domain Rules
Section titled “Domain Rules”| Rule | Enforcement | Location |
|---|---|---|
| Min 1 block per page | Page::new() creates an initial empty block; Page::from_parts() rejects empty blocks unless a layout is defined | domain/src/page.rs |
| No cycles in hierarchy | PageTreeNode::would_create_cycle() validates before move; repository move_page() enforces | domain/src/page_tree.rs, repository |
| Cascade deletion | soft_delete(slug, cascade=true) marks all descendants as deleted | PageRepository::soft_delete() |
| Workspace scoping | All pages belong to exactly one workspace database | Infrastructure (one inklings.db per workspace) |
| Title validation | Non-empty, max 200 characters, must produce a non-empty slug | CreatePageUseCase, UpdatePageUseCase |
| Unique slugs | Repository rejects duplicate slugs within a workspace | PageRepository::create(), exists_by_slug() |
| Block content type is immutable | A Markdown block cannot become an Image block after creation | BlockContentType design — no mutation method |
| Slug safety | validate_slug() rejects path traversal, absolute paths, null bytes, backslashes | domain/src/page.rs |
Key Types
Section titled “Key Types”Page Entity
Section titled “Page Entity”Page { id: Uuid, title: String, page_type: PageType, // Page | Folder template: Option<String>, icon: Option<String>, icon_color: Option<String>, blocks: Vec<Block>, // Invariant: non-empty (unless layout is Some) frontmatter: HashMap<String, Value>, type_assignments: Vec<TypeAssignment>, layout: Option<Layout>, // CSS Grid template areas created_at: DateTime<Utc>, updated_at: DateTime<Utc>,}Block Entity
Section titled “Block Entity”Block { id: Uuid, slot_id: u32, // Position index within the page content: String, // Materialized text (markdown or alt text) content_type: BlockContentType, // Markdown | Image(ImageBlockMetadata) area: Option<String>, // Named CSS Grid area assignment}BlockContentType
Section titled “BlockContentType”- Markdown (default): Rich text edited through TipTap/Loro. Content stored as LoroDoc BLOB.
- Image(ImageBlockMetadata): References an attachment UUID with display settings (scale, alignment, caption, link
URL). The
contentfield holds alt text. Metadata stored as JSON incontent_type_metadatacolumn.
PageTreeNode
Section titled “PageTreeNode”Lightweight representation for sidebar navigation. Contains slug, title, id, page type, icon, type slugs, and recursive
children. Supports cycle detection via would_create_cycle().
PageDetail
Section titled “PageDetail”Composite type for the context panel combining page metadata with computed statistics (word count, backlink count, outgoing link count) and full link details.
Key Code Paths
Section titled “Key Code Paths”Create Page
Section titled “Create Page”CreatePageUseCase.execute()validates title (non-empty, max 200 chars, produces non-empty slug)Page::new(title)creates the page with one initial empty block- If
parent_slugis provided, validates parent exists, callsrepository.create_child() - Otherwise checks slug uniqueness via
exists_by_slug(), callsrepository.create() - Returns
CreatePageResponse { page, slug }
Save Block Content (CRDT)
Section titled “Save Block Content (CRDT)”SaveBlockContentUseCase.execute()receivesslug, raw CRDTcontent_blob, andcontent_text- Validates BLOB is non-empty
- Calls
repository.save_block_content_blob(slug, content_blob, content_text) - Infrastructure writes BLOB to
content_lorocolumn and text tocontentcolumn atomically - For grid layouts,
SaveBlockContentByIdUseCasetargets a specific block by UUID
Rename Page (with Link Propagation)
Section titled “Rename Page (with Link Propagation)”RenamePageUseCase.execute()loads the page, validates the new title- Calls
repository.rename(slug, new_title)to update title and recalculate slug - Queries
reference_repository.get_backlinks()to find all pages linking to the old slug - For each linking page, rewrites
[[Display|old-slug]]to[[Display|new-slug]]in block content - Updates the reference index via
reference_repository.update_target_slugs() - A separate
preview()method shows what would change without committing
Delete Page (Soft Delete)
Section titled “Delete Page (Soft Delete)”DeletePageUseCase.execute()checksPagesDeletecapability- Calls
repository.soft_delete(slug, cascade)which marks page(s) as deleted - Pages remain in database but are excluded from normal queries
RestoreDeletedPageUseCasecan undo;PermanentDeletePageUseCaseremoves permanently
Move Page
Section titled “Move Page”MovePageUseCase.execute()checksPagesOrganizecapability- Delegates to
repository.move_page(slug, new_parent_slug) - Repository validates no cycle via tree traversal
- Converts target parent to folder if needed
- Moves page and all descendants, returns new slug
Error Handling
Section titled “Error Handling”| Error Type | Variants | When Raised |
|---|---|---|
PageRepositoryError::NotFound | Page slug does not exist | get_by_slug, rename, move_page |
PageRepositoryError::AlreadyExists | Duplicate slug in workspace | create |
PageRepositoryError::InvalidData | Empty title, title too long, empty slug | create, update, rename |
PageRepositoryError::InvalidOperation | Permission denied, unsupported operation | Capability checks, unimplemented defaults |
PageRepositoryError::Validation | Domain validation failure | Empty BLOB, invalid area name |
PageRepositoryError::CrdtDecodeError | LoroDoc BLOB cannot be decoded | get_block_content_blob |
DomainError::InvalidState | Empty blocks without layout | Page::from_parts() |
DomainError::Validation | Invalid area name, layout area mismatch | Block::set_area(), Page::apply_layout() |
Related
Section titled “Related”- Layout System — CSS Grid layout support is embedded in the Page entity
- Wiki-Link System —
wiki_link_parser.rsandupdate_references.rsin the page module - Tag System — tags are associated via
page_tagsjunction table, FTS5 3-column index includes tags - Attachment System — Image blocks reference attachments by UUID
Was this page helpful?
Thanks for your feedback!