chore: tended the spec against the bDS app to fix contradictions

This commit is contained in:
2026-04-05 08:06:04 +02:00
parent ee61ad56ea
commit f72d87eafe
9 changed files with 213 additions and 124 deletions

View File

@@ -66,10 +66,11 @@ rule WritePostFile {
-- ============================================================================ -- ============================================================================
value MediaSidecar { value MediaSidecar {
-- File path: media/{id}.md -- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
-- Binary file at: media/{filename} -- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter)
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
id: String -- UUID v4 id: String -- UUID v4
filename: String -- Generated filename (e.g., photo_1234567890.jpg)
original_name: String -- Original uploaded filename original_name: String -- Original uploaded filename
mime_type: String mime_type: String
size: Integer -- Bytes size: Integer -- Bytes
@@ -87,7 +88,7 @@ value MediaSidecar {
invariant MediaSidecarLayout { invariant MediaSidecarLayout {
for m in Media: for m in Media:
m.sidecar_path = format("media/{id}.md", id: m.id) m.sidecar_path = format("{binary_path}.meta", binary_path: m.file_path)
} }
-- ============================================================================ -- ============================================================================
@@ -301,10 +302,11 @@ invariant ConditionalPostFields {
} }
invariant RequiredMediaFields { invariant RequiredMediaFields {
-- These fields are ALWAYS written for media -- These fields are ALWAYS written for media sidecars
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself
for m in Media: for m in Media:
required_fields(m) = { required_fields(m) = {
id, filename, original_name, mime_type, size, id, original_name, mime_type, size,
created_at, updated_at, tags created_at, updated_at, tags
} }
} }

View File

@@ -36,7 +36,8 @@ invariant LanguageNormalization {
invariant MenuTranslations { invariant MenuTranslations {
-- Menu item labels are separately translatable -- Menu item labels are separately translatable
-- translateMenu() uses render locale, not UI locale -- translateMenu() uses UI locale (system/OS locale), NOT render locale
-- This follows the OS convention: menus match the system language
} }
invariant RenderTranslations { invariant RenderTranslations {

View File

@@ -35,7 +35,7 @@ entity Media {
file_path: String file_path: String
sidecar_path: String sidecar_path: String
checksum: String? checksum: String?
tags: Set<String> tags: List<String>
created_at: Timestamp created_at: Timestamp
updated_at: Timestamp updated_at: Timestamp
@@ -85,7 +85,7 @@ rule ImportMedia {
width: detect_width(source_file), width: detect_width(source_file),
height: detect_height(source_file), height: detect_height(source_file),
file_path: dest, file_path: dest,
tags: {} tags: []
) )
ensures: FileCopied(source_file, dest) ensures: FileCopied(source_file, dest)
ensures: SidecarWritten(media) ensures: SidecarWritten(media)

View File

@@ -15,75 +15,111 @@ use "./search.allium" as search
-- ============================================================================ -- ============================================================================
value MediaFileLayout { value MediaFileLayout {
-- Binary assets stored in: media/{filename} -- Binary assets stored in: media/{YYYY}/{MM}/{uuid}.{ext}
-- Sidecar metadata in: media/{id}.md -- Sidecar metadata in: {binary_path}.meta
-- Thumbnails in: thumbnails/{id}.webp -- Thumbnails in: thumbnails/{id[0:2]}/{id}-{size}.webp
-- Thumbnail source filename: thumbnails/{id}_source.{ext} -- (ai thumbnail is JPEG: thumbnails/{id[0:2]}/{id}-ai.jpg)
binary_path: String -- media/{filename} binary_path: String -- media/{YYYY}/{MM}/{uuid}.{ext}
sidecar_path: String -- media/{id}.md sidecar_path: String -- {binary_path}.meta
thumbnail_path: String -- thumbnails/{id}.webp thumbnail_small: String -- thumbnails/{prefix}/{id}-small.webp
thumbnail_source_path: String -- thumbnails/{id}_source.{ext} thumbnail_medium: String -- thumbnails/{prefix}/{id}-medium.webp
thumbnail_large: String -- thumbnails/{prefix}/{id}-large.webp
thumbnail_ai: String -- thumbnails/{prefix}/{id}-ai.jpg
} }
invariant MediaFileNaming { invariant MediaFileNaming {
-- Original filename is preserved in original_name field -- Original filename is preserved in original_name field
-- Stored filename is generated: {timestamp}_{random}.{ext} -- Stored filename uses UUID v4: {uuid}.{ext}
-- Example: 1710512345678_abc123.jpg -- Example: a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg
for m in Media: for m in Media:
m.filename = format("{timestamp}_{random}.{ext}", m.filename = format("{uuid}.{ext}",
timestamp: m.created_at.milliseconds, uuid: generate_uuid_v4(),
random: generate_random_string(6),
ext: file_extension(m.original_name)) ext: file_extension(m.original_name))
} }
invariant ThumbnailPathBucketing {
-- Thumbnails are bucketed by first 2 chars of media ID
-- This avoids filesystem slowdowns from too many files in one directory
for m in Media:
let prefix = m.id[0:2]
m.thumbnails.small = format("thumbnails/{prefix}/{id}-small.webp",
prefix: prefix, id: m.id)
m.thumbnails.medium = format("thumbnails/{prefix}/{id}-medium.webp",
prefix: prefix, id: m.id)
m.thumbnails.large = format("thumbnails/{prefix}/{id}-large.webp",
prefix: prefix, id: m.id)
m.thumbnails.ai = format("thumbnails/{prefix}/{id}-ai.jpg",
prefix: prefix, id: m.id)
}
-- ============================================================================ -- ============================================================================
-- THUMBNAIL GENERATION -- THUMBNAIL GENERATION
-- ============================================================================ -- ============================================================================
config { config {
-- Thumbnail configuration -- Four thumbnail sizes generated per image
thumbnail_width: Integer = 400 thumbnail_small_width: Integer = 150
thumbnail_height: Integer = 300 thumbnail_medium_width: Integer = 400
thumbnail_fit: String = "cover" -- "cover", "contain", "fill" thumbnail_large_width: Integer = 800
thumbnail_quality: Integer = 80 -- WEBP quality 1-100 thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG
thumbnail_format: String = "webp" -- Output format thumbnail_quality: Integer = 80 -- WebP quality, hardcoded
thumbnail_format: String = "webp" -- All sizes except AI
thumbnail_ai_format: String = "jpeg" -- AI thumbnail only
} }
rule GenerateThumbnail { rule GenerateThumbnails {
when: MediaImported(media) when: MediaImported(media)
requires: is_image(media.mime_type) requires: is_image(media.mime_type)
-- Generate thumbnail from source image -- Generate all four thumbnail sizes
ensures: ThumbnailGenerated( ensures: ThumbnailGenerated(
source: media.file_path, source: media.file_path,
destination: media.thumbnail_path, destination: media.thumbnails.small,
width: config.thumbnail_width, width: config.thumbnail_small_width,
height: config.thumbnail_height,
fit: config.thumbnail_fit,
quality: config.thumbnail_quality, quality: config.thumbnail_quality,
format: config.thumbnail_format format: config.thumbnail_format
) )
ensures: ThumbnailSourceSaved( ensures: ThumbnailGenerated(
source: media.file_path, source: media.file_path,
destination: media.thumbnail_source_path destination: media.thumbnails.medium,
width: config.thumbnail_medium_width,
quality: config.thumbnail_quality,
format: config.thumbnail_format
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.large,
width: config.thumbnail_large_width,
quality: config.thumbnail_quality,
format: config.thumbnail_format
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.ai,
size: config.thumbnail_ai_size,
format: config.thumbnail_ai_format
) )
} }
-- Thumbnail generation algorithm -- Thumbnail generation algorithm
value ThumbnailGeneration { value ThumbnailGeneration {
-- 1. Load source image with EXIF orientation -- 1. Load source image
-- 2. Auto-rotate based on EXIF orientation tag -- 2. Read raw image dimensions from header (not EXIF-orientation-aware)
-- 3. Resize using "cover" fit (crop to fill target dimensions) -- 3. Resize: small/medium/large preserve aspect ratio (width-constrained)
-- 4. Encode as WEBP with specified quality -- AI thumbnail is a 448x448 center crop
-- 5. Write to thumbnails/{id}.webp -- 4. Encode as WebP (quality 80) for small/medium/large
-- Encode as JPEG for AI thumbnail
-- Preserve original for source thumbnail -- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext}
-- This allows regenerating thumbnails at different sizes later --
-- No _source copy is made. Thumbnails regenerated from the original binary.
} }
invariant ThumbnailExifHandling { invariant ThumbnailExifHandling {
-- EXIF orientation must be respected during thumbnail generation -- TypeScript app reads raw image header dimensions
-- Photos taken with phone cameras often have incorrect orientation -- It does NOT apply EXIF orientation correction during thumbnail generation
-- without proper EXIF handling -- Width/height stored in DB are the raw header values
@guidance
-- The Rust implementation should match this: raw header dimensions
-- EXIF auto-rotation can be added later as an enhancement
} }
-- ============================================================================ -- ============================================================================
@@ -97,51 +133,53 @@ value ImageProcessing {
"image/webp", "image/tiff", "image/bmp", "image/webp", "image/tiff", "image/bmp",
"image/heic", "image/heif" "image/heic", "image/heif"
} }
-- Output formats: -- Output formats:
output_formats: Set<String> = { output_formats: Set<String> = {
"image/webp", -- Primary output "image/webp", -- Primary output for thumbnails
"image/jpeg", -- Fallback for non-WEBP browsers (not used in Rust version) "image/jpeg" -- AI thumbnail only
"image/png" -- For images with transparency
} }
-- Processing rules: -- Processing rules:
-- 1. All images are converted to WEBP for thumbnails -- 1. All thumbnails (except AI) are encoded as WebP quality 80
-- 2. Original format is preserved for full-size assets -- 2. AI thumbnail is encoded as JPEG (for vision model compatibility)
-- 3. EXIF data is stripped from thumbnails (privacy) -- 3. Original format is preserved for full-size assets (no conversion)
-- 4. EXIF orientation is applied during processing -- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent)
} }
rule ProcessImageMetadata { rule ProcessImageMetadata {
when: MediaImported(media) when: MediaImported(media)
-- Extract image metadata -- Extract image metadata from raw file header
ensures: media.width = extract_width(source_file) ensures: media.width = extract_width_from_header(source_file)
ensures: media.height = extract_height(source_file) ensures: media.height = extract_height_from_header(source_file)
ensures: media.mime_type = detect_mime_type(source_file) ensures: media.mime_type = detect_mime_from_extension(source_file)
ensures: media.size = file_size(source_file) ensures: media.size = file_size(source_file)
} }
invariant MimeDetection {
-- MIME type is detected from file extension, not from file content/magic bytes
-- Extension mapping: .jpg/.jpeg -> image/jpeg, .png -> image/png, etc.
}
-- ============================================================================ -- ============================================================================
-- MEDIA TRANSLATION FILES -- MEDIA TRANSLATION FILES
-- ============================================================================ -- ============================================================================
value MediaTranslationFile { value MediaTranslationFile {
-- File path: media/{id}/{language}.md -- File path: {binary_path}.{language}.meta
-- Format: YAML frontmatter with localized metadata -- Format: YAML-like key-value sidecar (same as canonical sidecar)
translation_for: String -- Canonical media ID translation_for: String -- Canonical media ID
language: String -- ISO 639-1 code language: String -- ISO 639-1 code
title: String? title: String?
alt: String? alt: String?
caption: String? caption: String?
created_at: Timestamp
updated_at: Timestamp
} }
invariant MediaTranslationFileLayout { invariant MediaTranslationFileLayout {
for t in MediaTranslations: for t in MediaTranslations:
-- Stored in subdirectory per media item -- Translation sidecars sit next to the binary, with language suffix
t.file_path = format("media/{id}/{lang}.md", t.file_path = format("{binary_path}.{lang}.meta",
id: t.translation_for, binary_path: t.media.file_path,
lang: t.language) lang: t.language)
} }
@@ -151,25 +189,24 @@ invariant MediaTranslationFileLayout {
rule ImportMedia { rule ImportMedia {
when: ImportMediaRequested(source_path, project) when: ImportMediaRequested(source_path, project)
-- 1. Validate file type (must be image) -- 1. Validate file type (must be supported image)
-- 2. Generate unique filename -- 2. Generate UUID v4 filename
-- 3. Copy to media/{filename} -- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext}
-- 4. Generate thumbnail -- 4. Write sidecar {binary_path}.meta
-- 5. Create sidecar file with metadata -- 5. Generate four thumbnail sizes
-- 6. Index for search (FTS5) -- 6. Index for search (FTS5)
-- 7. Generate embedding (if enabled)
ensures: media/Media.created( ensures: media/Media.created(
filename: generate_filename(), filename: generate_uuid_v4_filename(source_path),
original_name: basename(source_path), original_name: basename(source_path),
mime_type: detect_mime_type(source_path), mime_type: detect_mime_from_extension(source_path),
size: file_size(source_path), size: file_size(source_path),
width: extract_width(source_path), width: extract_width_from_header(source_path),
height: extract_height(source_path), height: extract_height_from_header(source_path),
file_path: format("media/{filename}", filename: generated_filename), file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"),
sidecar_path: format("media/{id}.md", id: media_id), sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"),
checksum: sha256(source_path) checksum: sha256(source_path)
) )
ensures: ThumbnailGenerated(media_id) ensures: ThumbnailsGenerated(media_id)
ensures: SearchIndexUpdated(media_id) ensures: SearchIndexUpdated(media_id)
} }
@@ -198,14 +235,17 @@ rule DeleteMedia {
when: DeleteMediaRequested(media) when: DeleteMediaRequested(media)
-- 1. Remove from database -- 1. Remove from database
-- 2. Delete binary file -- 2. Delete binary file
-- 3. Delete sidecar file -- 3. Delete sidecar file ({binary_path}.meta)
-- 4. Delete thumbnail files -- 4. Delete all four thumbnail files
-- 5. Remove from search index -- 5. Delete translation sidecars ({binary_path}.{lang}.meta)
-- 6. Remove from all post links -- 6. Remove from search index
-- 7. Remove from all post links
ensures: FileDeleted(media.file_path) ensures: FileDeleted(media.file_path)
ensures: FileDeleted(media.sidecar_path) ensures: FileDeleted(media.sidecar_path)
ensures: FileDeleted(media.thumbnail_path) ensures: FileDeleted(media.thumbnails.small)
ensures: FileDeleted(media.thumbnail_source_path) ensures: FileDeleted(media.thumbnails.medium)
ensures: FileDeleted(media.thumbnails.large)
ensures: FileDeleted(media.thumbnails.ai)
ensures: SearchIndexRemoved(media) ensures: SearchIndexRemoved(media)
ensures: ensures:
for p in media.linked_posts: for p in media.linked_posts:
@@ -221,7 +261,7 @@ rule ValidateMedia {
-- Check for: -- Check for:
-- 1. Missing binary files -- 1. Missing binary files
-- 2. Missing sidecar files -- 2. Missing sidecar files
-- 3. Missing thumbnails -- 3. Missing thumbnails (all 4 sizes)
-- 4. Corrupted image files -- 4. Corrupted image files
-- 5. Orphan media (not linked to any post) -- 5. Orphan media (not linked to any post)
for m in project.media: for m in project.media:
@@ -229,8 +269,14 @@ rule ValidateMedia {
ensures: ValidationIssueReported(m, "missing_binary") ensures: ValidationIssueReported(m, "missing_binary")
if not file_exists(m.sidecar_path): if not file_exists(m.sidecar_path):
ensures: ValidationIssueReported(m, "missing_sidecar") ensures: ValidationIssueReported(m, "missing_sidecar")
if not file_exists(m.thumbnail_path): if not file_exists(m.thumbnails.small):
ensures: ValidationIssueReported(m, "missing_thumbnail") ensures: ValidationIssueReported(m, "missing_thumbnail_small")
if not file_exists(m.thumbnails.medium):
ensures: ValidationIssueReported(m, "missing_thumbnail_medium")
if not file_exists(m.thumbnails.large):
ensures: ValidationIssueReported(m, "missing_thumbnail_large")
if not file_exists(m.thumbnails.ai):
ensures: ValidationIssueReported(m, "missing_thumbnail_ai")
if not is_valid_image(m.file_path): if not is_valid_image(m.file_path):
ensures: ValidationIssueReported(m, "corrupted") ensures: ValidationIssueReported(m, "corrupted")
if not exists (p in Posts where m in p.linked_media): if not exists (p in Posts where m in p.linked_media):
@@ -238,13 +284,15 @@ rule ValidateMedia {
} }
-- ============================================================================ -- ============================================================================
-- MEDIA EXPORT/EXPORT RULES -- MEDIA SIDECAR FORMAT
-- ============================================================================ -- ============================================================================
invariant MediaSidecarFormat { invariant MediaSidecarFormat {
-- Sidecar files use YAML frontmatter -- Sidecar files use YAML-like key-value format (hand-built, not gray-matter)
-- Same structure as Media entity fields -- Path: {binary_path}.meta
-- Only truthy fields are written (except required fields) -- Only truthy fields are written (except required fields)
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
-- Note: 'filename' is NOT written to sidecar (it is the binary filename itself)
} }
-- ============================================================================ -- ============================================================================
@@ -252,22 +300,10 @@ invariant MediaSidecarFormat {
-- ============================================================================ -- ============================================================================
config { config {
-- Image optimization settings -- No file size limit on import (TypeScript app has no max_original_size check)
max_original_size: Integer = 20971520 -- 20 MB in bytes -- Original files are stored as-is (no compression, no resize)
max_width: Integer = 4096 -- Only thumbnails are generated from the original
max_height: Integer = 4096 strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it
strip_exif: Boolean = true -- Strip EXIF from thumbnails
preserve_color_profile: Boolean = false -- Don't preserve ICC profiles
}
rule OptimizeMedia {
when: ImportMediaRequested(source_path, project)
requires: file_size(source_path) <= config.max_original_size
-- Original files are stored as-is (no compression)
-- Only thumbnails are optimized
@guidance
-- Media entity is created by ImportMedia rule
-- This rule documents the size constraint
} }
-- ============================================================================ -- ============================================================================
@@ -278,7 +314,6 @@ rule IndexMediaForSearch {
when: SearchIndexUpdated(media: media/Media) when: SearchIndexUpdated(media: media/Media)
-- Index fields: title, alt, caption, original_name, tags -- Index fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions -- Plus all translation titles, alts, and captions
-- Note: media.translations not available in this context
let all_text = concat( let all_text = concat(
media.title, media.title,
media.alt, media.alt,

View File

@@ -53,8 +53,8 @@ entity Post {
template_slug: String? template_slug: String?
file_path: String file_path: String
checksum: String? checksum: String?
tags: Set<String> tags: List<String>
categories: Set<String> categories: List<String>
created_at: Timestamp created_at: Timestamp
updated_at: Timestamp updated_at: Timestamp
published_at: Timestamp? published_at: Timestamp?
@@ -112,8 +112,8 @@ rule CreatePost {
status: draft, status: draft,
author: author, author: author,
language: language, language: language,
tags: tags ?? {}, tags: tags ?? [],
categories: categories ?? {}, categories: categories ?? [],
template_slug: template_slug, template_slug: template_slug,
do_not_translate: false, do_not_translate: false,
file_path: "" file_path: ""
@@ -184,7 +184,6 @@ invariant DateBasedFileLayout {
slug: post.slug) slug: post.slug)
} }
-- Slug freezing behaviour matches TypeScript app: -- Slug freeze: once published_at is set, the slug is permanently frozen.
-- slug changes allowed on draft posts even if previously published, -- This matches TypeScript behaviour: is_slug_frozen = published_at != null
-- frozen only while status = published (is_slug_frozen = published_at != null -- Even if the post reverts to draft, the slug cannot be changed.
-- is the guard, but status must also be published for the file to exist)

View File

@@ -61,8 +61,10 @@ rule SetActiveProject {
rule DeleteProject { rule DeleteProject {
when: DeleteProjectRequested(project) when: DeleteProjectRequested(project)
requires: project != default_project requires: project.id != "default"
-- The default project cannot be deleted -- The default project (id='default') cannot be deleted
requires: project.is_active = false
-- The currently active project cannot be deleted
ensures: not exists project ensures: not exists project
@guidance @guidance
-- deleteProjectWithData removes DB rows + internal directory -- deleteProjectWithData removes DB rows + internal directory
@@ -70,5 +72,12 @@ rule DeleteProject {
} }
config { config {
default_project_id: String = "default"
default_project_name: String = "My Blog" default_project_name: String = "My Blog"
} }
invariant DefaultProjectExists {
-- A project with id='default' always exists
-- It is created on first launch if missing
exists p in Projects where p.id = "default"
}

View File

@@ -43,6 +43,7 @@ invariant ScriptFileLayout {
rule CreateScript { rule CreateScript {
when: CreateScriptRequested(title, kind, content, entrypoint) when: CreateScriptRequested(title, kind, content, entrypoint)
let slug = slugify(title) let slug = slugify(title)
-- Creates a draft script: content stored in DB, no file written yet
ensures: Script.created( ensures: Script.created(
slug: slug, slug: slug,
title: title, title: title,
@@ -56,6 +57,26 @@ rule CreateScript {
) )
} }
rule CreateAndPublishScript {
-- Alternative creation path: create + immediately publish (file written)
-- TypeScript: createScript() does this in one step
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint)
let slug = slugify(title)
requires: ValidateScript(content) = valid
ensures: Script.created(
slug: slug,
title: title,
kind: kind,
content: null,
entrypoint: entrypoint ?? "render",
status: published,
enabled: true,
version: 1,
file_path: format("scripts/{slug}.lua", slug: slug)
)
ensures: ScriptFileWritten(script)
}
rule PublishScript { rule PublishScript {
when: PublishScriptRequested(script) when: PublishScriptRequested(script)
requires: ValidateScript(script.content) = valid requires: ValidateScript(script.content) = valid

View File

@@ -4,10 +4,12 @@
-- Distilled from: src/main/engine/TaskManager.ts -- Distilled from: src/main/engine/TaskManager.ts
entity Task { entity Task {
title: String name: String
status: pending | running | completed | failed | cancelled status: pending | running | completed | failed | cancelled
progress: Decimal? -- 0.0..1.0 progress: Decimal? -- 0.0..1.0
message: String? message: String?
group_id: String? -- Optional task grouping
group_name: String?
created_at: Timestamp created_at: Timestamp
transitions status { transitions status {
@@ -35,9 +37,9 @@ invariant FifoQueue {
} }
rule SubmitTask { rule SubmitTask {
when: SubmitTaskRequested(title, work) when: SubmitTaskRequested(name, work)
let running_tasks = Tasks where status = running let running_tasks = Tasks where status = running
ensures: Task.created(title: title, status: pending) ensures: Task.created(name: name, status: pending)
ensures: ensures:
if running_tasks.count < config.max_concurrent: if running_tasks.count < config.max_concurrent:
TaskStarted(task, work) TaskStarted(task, work)
@@ -79,8 +81,8 @@ invariant ProgressThrottled {
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts) -- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask { rule RegisterExternalTask {
when: RegisterExternalTaskRequested(title) when: RegisterExternalTaskRequested(name)
ensures: Task.created(title: title, status: running) ensures: Task.created(name: name, status: running)
@guidance @guidance
-- External tasks are not managed by the queue -- External tasks are not managed by the queue
-- The caller is responsible for updating status -- The caller is responsible for updating status

View File

@@ -48,6 +48,7 @@ invariant TemplateFileLayout {
rule CreateTemplate { rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content) when: CreateTemplateRequested(title, kind, content)
let slug = slugify(title) let slug = slugify(title)
-- Creates a draft template: content stored in DB, no file written yet
ensures: Template.created( ensures: Template.created(
slug: slug, slug: slug,
title: title, title: title,
@@ -60,6 +61,25 @@ rule CreateTemplate {
) )
} }
rule CreateAndPublishTemplate {
-- Alternative creation path: create + immediately publish (file written)
-- TypeScript: createTemplate() does this in one step
when: CreateAndPublishTemplateRequested(title, kind, content)
let slug = slugify(title)
requires: ValidateLiquid(content) = valid
ensures: Template.created(
slug: slug,
title: title,
kind: kind,
content: null,
status: published,
enabled: true,
version: 1,
file_path: format("templates/{slug}.liquid", slug: slug)
)
ensures: TemplateFileWritten(template)
}
rule UpdateTemplate { rule UpdateTemplate {
when: UpdateTemplateRequested(template, changes) when: UpdateTemplateRequested(template, changes)
ensures: TemplateFieldsUpdated(template, changes) ensures: TemplateFieldsUpdated(template, changes)