-- allium: 1 -- bDS Persistence Data Contract -- Scope: core (Wave 1 — exact compatibility contract) -- Distilled from: ../bDS/src/main/database/schema.ts -- -- This document specifies the persisted data model the rewrite must be able -- to read and write. It is the ground truth for storage compatibility. enum PostStatus { draft published archived } enum PostTranslationStatus { draft published } enum TemplateStatus { draft published } enum ScriptStatus { draft published } -- ============================================================================ -- CORE ENTITIES -- ============================================================================ entity Project { id: String -- UUID v4 name: String -- Display name slug: String -- URL-safe identifier description: String? -- Optional description data_path: String? -- Custom data directory (null = default) created_at: Timestamp -- Unix timestamp updated_at: Timestamp -- Unix timestamp is_active: Boolean -- Exactly one project is active at a time } entity Post { id: String -- UUID v4 project_id: String title: String slug: String -- URL-friendly identifier excerpt: String? -- Optional summary content: String? -- Draft body (null when published) status: PostStatus author: String? -- Author name created_at: Timestamp updated_at: Timestamp published_at: Timestamp? file_path: String -- Empty for never-published drafts checksum: String? -- SHA-256 of content tags: Set -- JSON array stored as text categories: Set -- JSON array stored as text template_slug: String? -- User template override language: String? -- ISO 639-1 code do_not_translate: Boolean -- Published snapshot columns (written on publish for diff detection) published_title: String? published_content: String? published_tags: String? published_categories: String? published_excerpt: String? } entity PostTranslation { id: String -- UUID v4 project_id: String translation_for: String -- Canonical post ID language: String -- ISO 639-1 code title: String excerpt: String? content: String? -- Draft body (null when published) status: PostTranslationStatus created_at: Timestamp updated_at: Timestamp published_at: Timestamp? file_path: String checksum: String? } entity Media { id: String -- UUID v4 project_id: String filename: String -- Generated filename original_name: String -- Original uploaded filename mime_type: String -- e.g. "image/jpeg" size: Integer -- Bytes width: Integer? -- Image dimensions height: Integer? title: String? alt: String? caption: String? author: String? file_path: String -- Absolute path to binary sidecar_path: String -- Path to .meta sidecar file created_at: Timestamp updated_at: Timestamp checksum: String? tags: Set -- JSON array stored as text language: String? -- ISO 639-1 code } entity MediaTranslation { id: String -- UUID v4 project_id: String translation_for: String -- Canonical media ID language: String -- ISO 639-1 code title: String? alt: String? caption: String? created_at: Timestamp updated_at: Timestamp } entity Tag { id: String -- UUID v4 project_id: String name: String -- Case-insensitive unique per project color: String? -- Hex color like #ff0000 post_template_slug: String? -- Template override for this tag created_at: Timestamp updated_at: Timestamp } entity Template { id: String -- UUID v4 project_id: String slug: String -- URL-safe identifier title: String kind: post | list | not_found | partial enabled: Boolean version: Integer -- Incremented on each update file_path: String -- templates/{slug}.liquid status: TemplateStatus content: String? -- Draft body (null when published) created_at: Timestamp updated_at: Timestamp } entity Script { id: String -- UUID v4 project_id: String slug: String -- URL-safe identifier title: String kind: macro | utility | transform entrypoint: String -- Default: "render" for macros enabled: Boolean version: Integer -- Incremented on each update file_path: String -- scripts/{slug}.{extension} status: ScriptStatus content: String? -- Draft body (null when published) created_at: Timestamp updated_at: Timestamp } -- ============================================================================ -- RELATIONSHIP TABLES -- ============================================================================ entity PostLink { id: String -- UUID v4 source_post_id: String -- Post containing the link target_post_id: String -- Post being linked to link_text: String? -- Anchor text created_at: Timestamp } entity PostMediaLink { id: String -- UUID v4 project_id: String post_id: String media_id: String sort_order: Integer -- For ordering media within a post created_at: Timestamp } -- ============================================================================ -- METADATA TABLES -- ============================================================================ entity Setting { key: String -- Primary key value: String -- Serialized value updated_at: Timestamp } entity GeneratedFileHash { project_id: String relative_path: String content_hash: String -- SHA-256 of file content updated_at: Timestamp } -- ============================================================================ -- SEARCH INDEX (FTS5 Virtual Tables) -- ============================================================================ entity PostSearchIndex { -- Full-text search index projection, not a user-authored entity -- Indexed fields: title, excerpt, content, tags, categories -- Plus all translation titles, excerpts, and content post: Post stemmed_content: String -- Processed via Snowball stemmer } entity MediaSearchIndex { -- Full-text search index projection -- Indexed fields: title, alt, caption, original_name, tags -- Plus all translation titles, alts, and captions media: Media stemmed_content: String -- Processed via Snowball stemmer } -- ============================================================================ -- AI / CHAT TABLES -- ============================================================================ entity ChatConversation { id: String -- UUID v4 title: String model: String? -- Model used for conversation copilot_session_id: String? -- Legacy, no longer used created_at: Timestamp updated_at: Timestamp } entity ChatMessage { id: Integer -- Auto-increment conversation_id: String role: system | user | assistant | tool content: String? tool_call_id: String? -- For tool responses tool_calls: String? -- JSON array of tool calls created_at: Timestamp } entity AiProvider { -- Provider catalog, populated from upstream model registry. -- Managed by the application and treated as read-only during normal use. id: String -- PRIMARY KEY name: String env: String? -- Environment variable for API key package_ref: String? -- Legacy package reference api: String? -- Base API URL doc: String? -- Documentation URL updated_at: Timestamp } entity AiModel { -- Full model catalog with capability metadata. -- Composite primary key: (provider, model_id). provider: AiProvider model_id: String name: String family: String? attachment: Boolean -- supports file attachments reasoning: Boolean -- supports chain-of-thought tool_call: Boolean -- supports tool/function calling structured_output: Boolean temperature: Boolean -- supports temperature parameter knowledge: String? -- training data cutoff release_date: String? last_updated_date: String? open_weights: Boolean input_price: Integer? -- price per million input tokens output_price: Integer? -- price per million output tokens cache_read_price: Integer? cache_write_price: Integer? context_window: Integer max_input_tokens: Integer max_output_tokens: Integer interleaved: String? -- interleaved capability descriptor status: String? -- active | deprecated | preview provider_package_ref: String? -- provider-specific legacy package reference updated_at: Timestamp } entity AiModelModality { -- Input/output modality declarations per model. provider: AiProvider model_id: String direction: String -- "input" | "output" modality: String -- "text" | "image" | "audio" | "video" } entity AiCatalogMeta { key: String -- "{endpoint_kind}_etag" | "{endpoint_kind}_lastFetchedAt" value: String } -- ============================================================================ -- EMBEDDINGS TABLES -- ============================================================================ entity EmbeddingKey { label: Integer -- USearch bigint key post_id: String project_id: String content_hash: String -- SHA-256 of title+content vector: String -- Encoded vector payload (1536 bytes for 384-dim) } entity DismissedDuplicatePair { id: String -- UUID v4 project_id: String post_id_a: String post_id_b: String dismissed_at: Timestamp } -- ============================================================================ -- IMPORT TABLES -- ============================================================================ entity ImportDefinition { id: String -- UUID v4 project_id: String name: String wxr_file_path: String? -- WordPress XML export file uploads_folder_path: String? -- WordPress uploads directory last_analysis_result: String? -- JSON text of ImportAnalysisReport created_at: Timestamp updated_at: Timestamp } -- ============================================================================ -- NOTIFICATION TABLES -- ============================================================================ entity DbNotification { id: Integer -- Auto-increment entity_type: String -- 'post' | 'media' | 'script' | 'template' entity_id: String action: created | updated | deleted from_cli: Boolean -- 1 = written by CLI seen_at: Timestamp? -- NULL = unprocessed created_at: Timestamp } surface ProjectRecordSurface { context project: Project exposes: project.id project.name project.slug project.description when project.description != null project.data_path when project.data_path != null project.created_at project.updated_at project.is_active } surface PostTranslationRecordSurface { context translation: PostTranslation exposes: translation.id translation.project_id translation.translation_for translation.language translation.title translation.excerpt when translation.excerpt != null translation.content when translation.content != null translation.status translation.created_at translation.updated_at translation.published_at when translation.published_at != null translation.file_path translation.checksum when translation.checksum != null } surface MediaTranslationRecordSurface { context translation: MediaTranslation exposes: translation.id translation.project_id translation.translation_for translation.language translation.title when translation.title != null translation.alt when translation.alt != null translation.caption when translation.caption != null translation.created_at translation.updated_at } surface TagRecordSurface { context tag: Tag exposes: tag.id tag.project_id tag.name tag.color when tag.color != null tag.post_template_slug when tag.post_template_slug != null tag.created_at tag.updated_at } surface TemplateRecordSurface { context template: Template exposes: template.id template.project_id template.slug template.title template.kind template.enabled template.version template.file_path template.status template.content when template.content != null template.created_at template.updated_at } surface ScriptRecordSurface { context script: Script exposes: script.id script.project_id script.slug script.title script.kind script.entrypoint script.enabled script.version script.file_path script.status script.content when script.content != null script.created_at script.updated_at } surface PostLinkRecordSurface { context link: PostLink exposes: link.id link.source_post_id link.target_post_id link.link_text when link.link_text != null link.created_at } surface PostMediaLinkRecordSurface { context link: PostMediaLink exposes: link.id link.project_id link.post_id link.media_id link.sort_order link.created_at } surface SettingRecordSurface { context setting: Setting exposes: setting.key setting.value setting.updated_at } surface GeneratedFileHashRecordSurface { context record: GeneratedFileHash exposes: record.project_id record.relative_path record.content_hash record.updated_at } surface PostSearchIndexRecordSurface { context record: PostSearchIndex exposes: record.post record.stemmed_content } surface MediaSearchIndexRecordSurface { context record: MediaSearchIndex exposes: record.media record.stemmed_content } surface ChatConversationRecordSurface { context conversation: ChatConversation exposes: conversation.id conversation.title conversation.model when conversation.model != null conversation.copilot_session_id when conversation.copilot_session_id != null conversation.created_at conversation.updated_at } surface ChatMessageRecordSurface { context message: ChatMessage exposes: message.id message.conversation_id message.role message.content when message.content != null message.tool_call_id when message.tool_call_id != null message.tool_calls when message.tool_calls != null message.created_at } surface AiModelRecordSurface { context model: AiModel exposes: model.provider model.model_id model.name model.family when model.family != null model.attachment model.reasoning model.tool_call model.structured_output model.temperature model.knowledge when model.knowledge != null model.release_date when model.release_date != null model.last_updated_date when model.last_updated_date != null model.open_weights model.input_price when model.input_price != null model.output_price when model.output_price != null model.cache_read_price when model.cache_read_price != null model.cache_write_price when model.cache_write_price != null model.context_window model.max_input_tokens model.max_output_tokens model.interleaved when model.interleaved != null model.status when model.status != null model.provider_package_ref when model.provider_package_ref != null model.updated_at } surface AiModelModalityRecordSurface { context modality: AiModelModality exposes: modality.provider modality.model_id modality.direction modality.modality } surface AiCatalogMetaRecordSurface { context meta: AiCatalogMeta exposes: meta.key meta.value } surface EmbeddingKeyRecordSurface { context key: EmbeddingKey exposes: key.label key.post_id key.project_id key.content_hash key.vector } surface DismissedDuplicatePairRecordSurface { context pair: DismissedDuplicatePair exposes: pair.id pair.project_id pair.post_id_a pair.post_id_b pair.dismissed_at } surface ImportDefinitionRecordSurface { context definition: ImportDefinition exposes: definition.id definition.project_id definition.name definition.wxr_file_path when definition.wxr_file_path != null definition.uploads_folder_path when definition.uploads_folder_path != null definition.last_analysis_result when definition.last_analysis_result != null definition.created_at definition.updated_at } surface DbNotificationRecordSurface { context notification: DbNotification exposes: notification.id notification.entity_type notification.entity_id notification.action notification.from_cli notification.seen_at when notification.seen_at != null notification.created_at } surface Fts5PostSchemaSurface { context schema: Fts5PostSchema exposes: schema.fields schema.stemmer_languages } surface Fts5MediaSchemaSurface { context schema: Fts5MediaSchema exposes: schema.fields schema.stemmer_languages } surface MigrationVersionSurface { context _: MigrationVersion } -- ============================================================================ -- SCHEMA CONSTRAINTS AND INDEXES -- ============================================================================ invariant UniqueProjectSlug { -- projects.slug must be unique across all projects } invariant UniquePostSlugPerProject { -- posts.slug must be unique within each project.project_id -- Enforced by: posts_project_slug_idx unique index } invariant UniqueTranslationPerPostLanguage { -- post_translations must have unique (translation_for, language) -- Enforced by: post_translations_translation_language_idx } invariant UniqueMediaTranslationPerMediaLanguage { -- media_translations must have unique (translation_for, language) -- Enforced by: media_translations_translation_language_idx } invariant UniqueTagNamePerProject { -- tags.name must be unique within each project.project_id -- Enforced by: tags_project_name_idx unique index } invariant UniqueScriptSlugPerProject { -- scripts.slug must be unique within each project.project_id -- Enforced by: scripts_project_slug_idx unique index } invariant UniqueTemplateSlugPerProject { -- templates.slug must be unique within each project.project_id -- Enforced by: templates_project_slug_idx unique index } invariant UniquePostMediaLink { -- post_media must have unique (post_id, media_id) pair -- Enforced by: post_media_post_media_idx unique index } invariant UniqueGeneratedFileHash { -- generated_file_hashes must have unique (project_id, relative_path) -- Enforced by: generated_file_hashes_project_path_idx unique index } invariant UniqueDismissedDuplicatePair { -- dismissed_duplicate_pairs must have unique (project_id, post_id_a, post_id_b) -- Enforced by: dismissed_pairs_idx unique index } -- ============================================================================ -- FTS5 VIRTUAL TABLE SCHEMAS (Snowball Stemmer Integration) -- ============================================================================ value Fts5PostSchema { -- CREATE VIRTUAL TABLE posts_fts USING fts5( -- post_id UNINDEXED, -- title, excerpt, content, tags, categories -- ); -- Standalone table (no content-sync) because text is pre-stemmed -- via Snowball before insertion; content-sync would read un-stemmed -- base-table text at query time instead. fields: Set -- {post_id UNINDEXED, title, excerpt, content, tags, categories} stemmer_languages: Integer = 24 } value Fts5MediaSchema { -- CREATE VIRTUAL TABLE media_fts USING fts5( -- media_id UNINDEXED, -- title, alt, caption, original_name, tags -- ); -- Standalone table (no content-sync) — same rationale as posts_fts. fields: Set -- {media_id UNINDEXED, title, alt, caption, original_name, tags} stemmer_languages: Integer = 24 } -- ============================================================================ -- MIGRATION HISTORY -- ============================================================================ value MigrationVersion { -- Schema version tracking via refinery migrations -- Current version: 0007 (scripts and templates draft lifecycle) -- Migration files located in: migrations/ -- Note: Migration list documented in comments, not as Allium value }