* fix: mixed up migrations * feat: semantic similarity first take * feat: semantic similarity first round of fixes * feat: more work on making semantic similarity work properly * feat: getPostBySlug for the AI * feat: show similarity in post-link-insert-modal * chore: remove done doc --------- Co-authored-by: hugo <hugoms@me.com>
333 lines
17 KiB
TypeScript
333 lines
17 KiB
TypeScript
import { sqliteTable, text, integer, real, blob, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core';
|
||
|
||
// Projects table - stores blog projects/websites
|
||
export const projects = sqliteTable('projects', {
|
||
id: text('id').primaryKey(),
|
||
name: text('name').notNull(),
|
||
slug: text('slug').notNull().unique(),
|
||
description: text('description'),
|
||
dataPath: text('data_path'), // Custom path for project data (null = default userData/projects/{id})
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false),
|
||
});
|
||
|
||
// Posts table - stores metadata for blog posts
|
||
// Draft content is stored in the `content` column.
|
||
// Published content lives on the filesystem; `filePath` points to the .md file.
|
||
// When a post is published, `content` is cleared (moved to file).
|
||
// When a published post is edited, `content` holds draft changes and status becomes 'draft'.
|
||
export const posts = sqliteTable('posts', {
|
||
projectId: text('project_id').notNull(),
|
||
id: text('id').primaryKey(),
|
||
title: text('title').notNull(),
|
||
slug: text('slug').notNull(),
|
||
excerpt: text('excerpt'),
|
||
content: text('content'), // Draft body text (null/empty when published — content is in the file)
|
||
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
|
||
author: text('author'),
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
publishedAt: integer('published_at', { mode: 'timestamp' }),
|
||
filePath: text('file_path').notNull().default(''), // Empty for never-published drafts
|
||
checksum: text('checksum'),
|
||
tags: text('tags'), // JSON array stored as text
|
||
categories: text('categories'), // JSON array stored as text
|
||
templateSlug: text('template_slug'), // Optional user template override for this post
|
||
language: text('language'), // Optional per-post language override (ISO code, e.g. 'en', 'de')
|
||
// Legacy columns (kept for migration compatibility, no longer written)
|
||
publishedTitle: text('published_title'),
|
||
publishedContent: text('published_content'),
|
||
publishedTags: text('published_tags'),
|
||
publishedCategories: text('published_categories'),
|
||
publishedExcerpt: text('published_excerpt'),
|
||
}, (table) => ({
|
||
// Composite unique index: slug must be unique within each project
|
||
projectSlugIdx: uniqueIndex('posts_project_slug_idx').on(table.projectId, table.slug),
|
||
}));
|
||
|
||
// Media table - stores metadata for images and other media
|
||
export const media = sqliteTable('media', {
|
||
projectId: text('project_id').notNull(),
|
||
id: text('id').primaryKey(),
|
||
filename: text('filename').notNull(),
|
||
originalName: text('original_name').notNull(),
|
||
mimeType: text('mime_type').notNull(),
|
||
size: integer('size').notNull(),
|
||
width: integer('width'),
|
||
height: integer('height'),
|
||
title: text('title'),
|
||
alt: text('alt'),
|
||
caption: text('caption'),
|
||
author: text('author'),
|
||
filePath: text('file_path').notNull(),
|
||
sidecarPath: text('sidecar_path').notNull(),
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
checksum: text('checksum'),
|
||
tags: text('tags'), // JSON array stored as text
|
||
});
|
||
|
||
// App settings - stores application configuration
|
||
export const settings = sqliteTable('settings', {
|
||
key: text('key').primaryKey(),
|
||
value: text('value').notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
});
|
||
|
||
// Generated file hashes - tracks html/xml output content hashes to skip unchanged writes
|
||
export const generatedFileHashes = sqliteTable('generated_file_hashes', {
|
||
projectId: text('project_id').notNull(),
|
||
relativePath: text('relative_path').notNull(),
|
||
contentHash: text('content_hash').notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
projectPathIdx: uniqueIndex('generated_file_hashes_project_path_idx').on(table.projectId, table.relativePath),
|
||
}));
|
||
|
||
// Post links - tracks internal links between posts
|
||
export const postLinks = sqliteTable('post_links', {
|
||
id: text('id').primaryKey(),
|
||
sourcePostId: text('source_post_id').notNull(), // Post containing the link
|
||
targetPostId: text('target_post_id').notNull(), // Post being linked to
|
||
linkText: text('link_text'), // The text of the link
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
});
|
||
|
||
// Post-Media links - tracks which media files are linked to which posts
|
||
export const postMedia = sqliteTable('post_media', {
|
||
id: text('id').primaryKey(),
|
||
projectId: text('project_id').notNull(),
|
||
postId: text('post_id').notNull(),
|
||
mediaId: text('media_id').notNull(),
|
||
sortOrder: integer('sort_order').notNull().default(0), // For ordering media within a post
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
// Composite unique index: a media can only be linked once to a post
|
||
postMediaIdx: uniqueIndex('post_media_post_media_idx').on(table.postId, table.mediaId),
|
||
}));
|
||
|
||
// Tags table - stores tag metadata with optional colors
|
||
export const tags = sqliteTable('tags', {
|
||
id: text('id').primaryKey(),
|
||
projectId: text('project_id').notNull(),
|
||
name: text('name').notNull(),
|
||
color: text('color'), // Optional hex color like #ff0000
|
||
postTemplateSlug: text('post_template_slug'), // Optional user template override for posts with this tag
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
// Composite unique index: tag name must be unique within each project
|
||
projectNameIdx: uniqueIndex('tags_project_name_idx').on(table.projectId, table.name),
|
||
}));
|
||
|
||
// Chat conversations table - stores AI chat sessions
|
||
export const chatConversations = sqliteTable('chat_conversations', {
|
||
id: text('id').primaryKey(),
|
||
title: text('title').notNull(),
|
||
model: text('model'), // Model used for this conversation
|
||
copilotSessionId: text('copilot_session_id'), // Legacy, no longer used
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
});
|
||
|
||
// Chat messages table - stores messages within conversations
|
||
export const chatMessages = sqliteTable('chat_messages', {
|
||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||
conversationId: text('conversation_id').notNull(),
|
||
role: text('role', { enum: ['system', 'user', 'assistant', 'tool'] }).notNull(),
|
||
content: text('content'),
|
||
toolCallId: text('tool_call_id'), // For tool responses
|
||
toolCalls: text('tool_calls'), // JSON array of tool calls
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
});
|
||
|
||
// Import definitions table - stores WXR import configurations
|
||
export const importDefinitions = sqliteTable('import_definitions', {
|
||
id: text('id').primaryKey(),
|
||
projectId: text('project_id').notNull(),
|
||
name: text('name').notNull(),
|
||
wxrFilePath: text('wxr_file_path'),
|
||
uploadsFolderPath: text('uploads_folder_path'),
|
||
lastAnalysisResult: text('last_analysis_result'), // JSON text of ImportAnalysisReport
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
});
|
||
|
||
// Scripts table - stores metadata for Python scripts persisted in scripts/*.py
|
||
export const scripts = sqliteTable('scripts', {
|
||
id: text('id').primaryKey(),
|
||
projectId: text('project_id').notNull(),
|
||
slug: text('slug').notNull(),
|
||
title: text('title').notNull(),
|
||
kind: text('kind', { enum: ['macro', 'utility', 'transform'] }).notNull().default('utility'),
|
||
entrypoint: text('entrypoint').notNull().default('render'),
|
||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||
version: integer('version').notNull().default(1),
|
||
filePath: text('file_path').notNull(),
|
||
// Draft lifecycle columns (added in 0007)
|
||
status: text('status', { enum: ['draft', 'published'] }).notNull().default('published'),
|
||
content: text('content'), // draft body; NULL when on-disk (published)
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
// Composite unique index: slug must be unique within each project
|
||
projectSlugIdx: uniqueIndex('scripts_project_slug_idx').on(table.projectId, table.slug),
|
||
}));
|
||
|
||
// Templates table - stores metadata for Liquid templates persisted in templates/*.liquid
|
||
export const templates = sqliteTable('templates', {
|
||
id: text('id').primaryKey(),
|
||
projectId: text('project_id').notNull(),
|
||
slug: text('slug').notNull(),
|
||
title: text('title').notNull(),
|
||
kind: text('kind', { enum: ['post', 'list', 'not-found', 'partial'] }).notNull().default('post'),
|
||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||
version: integer('version').notNull().default(1),
|
||
filePath: text('file_path').notNull(),
|
||
// Draft lifecycle columns (added in 0007)
|
||
status: text('status', { enum: ['draft', 'published'] }).notNull().default('published'),
|
||
content: text('content'), // draft body; NULL when on-disk (published)
|
||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
// Composite unique index: slug must be unique within each project
|
||
projectSlugIdx: uniqueIndex('templates_project_slug_idx').on(table.projectId, table.slug),
|
||
}));
|
||
|
||
// DB notifications table - CLI writes a row after every mutation; app's NotificationWatcher
|
||
// queries for seenAt IS NULL AND fromCli = 1, invalidates engine caches, emits IPC events.
|
||
export const dbNotifications = sqliteTable('db_notifications', {
|
||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||
entity: text('entity').notNull(), // 'post' | 'media' | 'script' | 'template'
|
||
entityId: text('entity_id').notNull(),
|
||
action: text('action').notNull(), // 'created' | 'updated' | 'deleted'
|
||
fromCli: integer('from_cli').notNull().default(1), // 1 = written by CLI; reserved for future app→CLI
|
||
seenAt: integer('seen_at'), // NULL = unprocessed by app
|
||
createdAt: integer('created_at').notNull(),
|
||
});
|
||
|
||
// ── Model Catalog ──
|
||
// Normalised tables from models.dev API.
|
||
// Refreshed on user action via conditional GET (ETag). Survives offline use.
|
||
|
||
// Provider table — one row per models.dev top-level provider
|
||
export const modelCatalogProviders = sqliteTable('ai_providers', {
|
||
id: text('id').primaryKey(), // provider key (e.g. 'opencode', 'mistral')
|
||
name: text('name').notNull(), // display name (e.g. 'OpenCode Zen')
|
||
env: text('env'), // JSON array of env var names
|
||
npm: text('npm'), // primary npm package
|
||
api: text('api'), // API base URL
|
||
doc: text('doc'), // documentation URL
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
});
|
||
|
||
// Model table — one row per (provider, modelId) pair
|
||
export const modelCatalog = sqliteTable('ai_models', {
|
||
provider: text('provider').notNull(), // FK → ai_providers.id
|
||
modelId: text('model_id').notNull(),
|
||
name: text('name').notNull(), // display name (e.g. 'Claude Sonnet 4.5')
|
||
family: text('family'), // model family (e.g. 'claude-sonnet')
|
||
attachment: integer('attachment', { mode: 'boolean' }).default(false),
|
||
reasoning: integer('reasoning', { mode: 'boolean' }).default(false),
|
||
toolCall: integer('tool_call', { mode: 'boolean' }).default(false),
|
||
structuredOutput: integer('structured_output', { mode: 'boolean' }).default(false),
|
||
temperature: integer('temperature', { mode: 'boolean' }).default(false),
|
||
knowledge: text('knowledge'), // knowledge cutoff (e.g. '2025-03-31')
|
||
releaseDate: text('release_date'),
|
||
lastUpdatedDate: text('last_updated_date'),
|
||
openWeights: integer('open_weights', { mode: 'boolean' }).default(false),
|
||
inputPrice: real('input_price'), // USD per 1M input tokens
|
||
outputPrice: real('output_price'), // USD per 1M output tokens
|
||
cacheReadPrice: real('cache_read_price'),
|
||
cacheWritePrice: real('cache_write_price'),
|
||
contextWindow: integer('context_window'), // max context tokens
|
||
maxInputTokens: integer('max_input_tokens'),
|
||
maxOutputTokens: integer('max_output_tokens'),
|
||
interleaved: text('interleaved'), // JSON object (e.g. '{"field":"reasoning_content"}')
|
||
status: text('status'), // e.g. 'deprecated'
|
||
providerNpm: text('provider_npm'), // per-model npm override
|
||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
pk: primaryKey({ columns: [table.provider, table.modelId] }),
|
||
}));
|
||
|
||
// Modality junction table — each row is one (direction, modality) tag for a model
|
||
// e.g. ('opencode', 'claude-sonnet-4', 'input', 'image')
|
||
export const modelCatalogModalities = sqliteTable('ai_model_modalities', {
|
||
provider: text('provider').notNull(),
|
||
modelId: text('model_id').notNull(),
|
||
direction: text('direction').notNull(), // 'input' | 'output'
|
||
modality: text('modality').notNull(), // 'text' | 'image' | 'pdf' | 'audio' | 'video'
|
||
}, (table) => ({
|
||
pk: primaryKey({ columns: [table.provider, table.modelId, table.direction, table.modality] }),
|
||
}));
|
||
|
||
// HTTP cache metadata (ETag for conditional GET)
|
||
export const modelCatalogMeta = sqliteTable('ai_catalog_meta', {
|
||
key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt'
|
||
value: text('value').notNull(),
|
||
});
|
||
|
||
// Embedding keys table - maps USearch bigint labels to post IDs for semantic similarity
|
||
export const embeddingKeys = sqliteTable('embedding_keys', {
|
||
label: integer('label').primaryKey(), // USearch bigint key (stored as number, cast to bigint at runtime)
|
||
postId: text('post_id').notNull(),
|
||
projectId: text('project_id').notNull(),
|
||
contentHash: text('content_hash').notNull(), // SHA-256 of title+content, for change detection
|
||
vector: blob('vector', { mode: 'buffer' }), // Raw Float32Array bytes (384 × 4 = 1536 bytes)
|
||
});
|
||
|
||
// Dismissed duplicate pairs - user has reviewed and dismissed these near-duplicates
|
||
export const dismissedDuplicatePairs = sqliteTable('dismissed_duplicate_pairs', {
|
||
id: text('id').primaryKey(),
|
||
projectId: text('project_id').notNull(),
|
||
postIdA: text('post_id_a').notNull(),
|
||
postIdB: text('post_id_b').notNull(),
|
||
dismissedAt: integer('dismissed_at', { mode: 'timestamp' }).notNull(),
|
||
}, (table) => ({
|
||
pairIdx: uniqueIndex('dismissed_pairs_idx').on(table.projectId, table.postIdA, table.postIdB),
|
||
}));
|
||
|
||
// Types for TypeScript
|
||
export type Project = typeof projects.$inferSelect;
|
||
export type NewProject = typeof projects.$inferInsert;
|
||
export type Post = typeof posts.$inferSelect;
|
||
export type NewPost = typeof posts.$inferInsert;
|
||
export type Media = typeof media.$inferSelect;
|
||
export type NewMedia = typeof media.$inferInsert;
|
||
export type Setting = typeof settings.$inferSelect;
|
||
export type NewSetting = typeof settings.$inferInsert;
|
||
export type GeneratedFileHash = typeof generatedFileHashes.$inferSelect;
|
||
export type NewGeneratedFileHash = typeof generatedFileHashes.$inferInsert;
|
||
export type PostLink = typeof postLinks.$inferSelect;
|
||
export type NewPostLink = typeof postLinks.$inferInsert;
|
||
export type PostMediaLink = typeof postMedia.$inferSelect;
|
||
export type NewPostMediaLink = typeof postMedia.$inferInsert;
|
||
export type Tag = typeof tags.$inferSelect;
|
||
export type NewTag = typeof tags.$inferInsert;
|
||
export type ChatConversation = typeof chatConversations.$inferSelect;
|
||
export type NewChatConversation = typeof chatConversations.$inferInsert;
|
||
export type ChatMessage = typeof chatMessages.$inferSelect;
|
||
export type NewChatMessage = typeof chatMessages.$inferInsert;
|
||
export type ImportDefinition = typeof importDefinitions.$inferSelect;
|
||
export type NewImportDefinition = typeof importDefinitions.$inferInsert;
|
||
export type Script = typeof scripts.$inferSelect;
|
||
export type NewScript = typeof scripts.$inferInsert;
|
||
export type Template = typeof templates.$inferSelect;
|
||
export type NewTemplate = typeof templates.$inferInsert;
|
||
export type DbNotification = typeof dbNotifications.$inferSelect;
|
||
export type NewDbNotification = typeof dbNotifications.$inferInsert;
|
||
export type ModelCatalogProviderEntry = typeof modelCatalogProviders.$inferSelect;
|
||
export type NewModelCatalogProviderEntry = typeof modelCatalogProviders.$inferInsert;
|
||
export type ModelCatalogEntry = typeof modelCatalog.$inferSelect;
|
||
export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert;
|
||
export type ModelCatalogModalityEntry = typeof modelCatalogModalities.$inferSelect;
|
||
export type NewModelCatalogModalityEntry = typeof modelCatalogModalities.$inferInsert;
|
||
export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
|
||
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;
|
||
export type EmbeddingKey = typeof embeddingKeys.$inferSelect;
|
||
export type NewEmbeddingKey = typeof embeddingKeys.$inferInsert;
|
||
export type DismissedDuplicatePair = typeof dismissedDuplicatePairs.$inferSelect;
|
||
export type NewDismissedDuplicatePair = typeof dismissedDuplicatePairs.$inferInsert;
|