返回 oh-my-ppt
index.ts
根目录 / src / main / db / patch / index.ts
1 import type { createClient } from '@libsql/client'
2 import type { drizzle } from 'drizzle-orm/libsql'
3 import path from 'path'
4 import fs from 'fs'
5 import { nanoid } from 'nanoid'
6 import * as schema from '../schema'
7 import type { GenerationPageStatus, GenerationRunStatus } from '../schema'
8 import { defaultModelTimeoutMs } from '@shared/model-timeout'
9 import { patchModelConfigMaxTokens } from './add-model-max-tokens'
10 import { patchModelConfigDisableTemperature } from './add-model-disable-temperature'
11 import { patchModelConfigThinkingParameterMode } from './add-model-thinking-parameter-mode'
12 import { patchStylesColumns } from './add-styles-columns'
13 import { patchDesignContractFonts } from './backfill-design-contract-fonts'
14 import { patchSourcePageSkeletonAgendaItems } from './add-source-page-skeleton-agenda-items'
15
16 type LibSqlClient = ReturnType<typeof createClient>
17 type DrizzleDb = ReturnType<typeof drizzle>
18
19 const SETTINGS_TABLE_SQL = `
20 CREATE TABLE IF NOT EXISTS settings (
21 key TEXT PRIMARY KEY,
22 value TEXT NOT NULL,
23 updated_at INTEGER NOT NULL
24 );
25 `
26
27 const MESSAGES_TABLE_SQL = `
28 CREATE TABLE IF NOT EXISTS messages (
29 id TEXT PRIMARY KEY,
30 session_id TEXT NOT NULL,
31 chat_scope TEXT NOT NULL DEFAULT 'main',
32 page_id TEXT,
33 selector TEXT,
34 image_paths TEXT,
35 video_paths TEXT,
36 role TEXT NOT NULL,
37 content TEXT NOT NULL,
38 type TEXT,
39 tool_name TEXT,
40 tool_call_id TEXT,
41 token_count INTEGER,
42 run_model TEXT,
43 created_at INTEGER NOT NULL
44 );
45
46 CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
47 CREATE INDEX IF NOT EXISTS idx_messages_session_scope ON messages(session_id, chat_scope, page_id, created_at);
48 CREATE INDEX IF NOT EXISTS idx_messages_session_only ON messages(session_id);
49 `
50
51 const MODEL_USAGE_TABLE_SQL = `
52 CREATE TABLE IF NOT EXISTS model_usage_events (
53 id TEXT PRIMARY KEY,
54 provider TEXT NOT NULL,
55 model TEXT NOT NULL,
56 model_config_id TEXT,
57 input_tokens INTEGER NOT NULL DEFAULT 0,
58 output_tokens INTEGER NOT NULL DEFAULT 0,
59 total_tokens INTEGER NOT NULL DEFAULT 0,
60 usage_source TEXT NOT NULL DEFAULT 'provider',
61 created_at INTEGER NOT NULL
62 );
63
64 CREATE INDEX IF NOT EXISTS idx_model_usage_events_created ON model_usage_events(created_at);
65 CREATE INDEX IF NOT EXISTS idx_model_usage_events_model ON model_usage_events(provider, model, created_at);
66 `
67
68 const INIT_SQL = `
69 CREATE TABLE IF NOT EXISTS sessions (
70 id TEXT PRIMARY KEY,
71 title TEXT NOT NULL,
72 topic TEXT,
73 style_id TEXT,
74 page_count INTEGER,
75 slide_size_id TEXT NOT NULL DEFAULT 'wide-16-9',
76 slide_width INTEGER NOT NULL DEFAULT 1600,
77 slide_height INTEGER NOT NULL DEFAULT 900,
78 reference_document_path TEXT,
79 status TEXT NOT NULL DEFAULT 'active',
80 provider TEXT NOT NULL,
81 model TEXT NOT NULL,
82 created_at INTEGER NOT NULL,
83 updated_at INTEGER NOT NULL,
84 metadata TEXT,
85 design_contract TEXT,
86 current_operation_id TEXT,
87 current_commit TEXT,
88 visual_enabled INTEGER NOT NULL DEFAULT 0,
89 image_model_config_id TEXT REFERENCES image_model_configs(id) ON DELETE RESTRICT
90 );
91
92 ${MESSAGES_TABLE_SQL}
93
94 ${MODEL_USAGE_TABLE_SQL}
95
96 CREATE TABLE IF NOT EXISTS projects (
97 id TEXT PRIMARY KEY,
98 session_id TEXT NOT NULL,
99 title TEXT NOT NULL,
100 output_path TEXT NOT NULL,
101 root_path TEXT,
102 file_count INTEGER DEFAULT 0,
103 total_size INTEGER DEFAULT 0,
104 status TEXT NOT NULL DEFAULT 'draft',
105 created_at INTEGER NOT NULL,
106 updated_at INTEGER NOT NULL
107 );
108
109 ${SETTINGS_TABLE_SQL}
110
111 CREATE TABLE IF NOT EXISTS model_configs (
112 id TEXT PRIMARY KEY,
113 name TEXT NOT NULL,
114 provider TEXT NOT NULL,
115 model TEXT NOT NULL,
116 api_key TEXT NOT NULL DEFAULT '',
117 base_url TEXT NOT NULL DEFAULT '',
118 max_tokens INTEGER NOT NULL DEFAULT 4096,
119 disable_temperature INTEGER NOT NULL DEFAULT 0,
120 thinking_parameter_mode TEXT NOT NULL DEFAULT 'auto',
121 active INTEGER NOT NULL DEFAULT 0,
122 created_at INTEGER NOT NULL,
123 updated_at INTEGER NOT NULL
124 );
125 CREATE UNIQUE INDEX IF NOT EXISTS idx_model_configs_single_active ON model_configs(active) WHERE active = 1;
126 CREATE INDEX IF NOT EXISTS idx_model_configs_updated ON model_configs(updated_at);
127
128 CREATE TABLE IF NOT EXISTS image_model_configs (
129 id TEXT PRIMARY KEY,
130 name TEXT NOT NULL,
131 provider TEXT NOT NULL,
132 model_config TEXT NOT NULL DEFAULT '{}',
133 active INTEGER NOT NULL DEFAULT 0,
134 created_at INTEGER NOT NULL,
135 updated_at INTEGER NOT NULL
136 );
137 CREATE UNIQUE INDEX IF NOT EXISTS idx_image_model_configs_single_active ON image_model_configs(active) WHERE active = 1;
138 CREATE INDEX IF NOT EXISTS idx_image_model_configs_updated ON image_model_configs(updated_at);
139
140 CREATE TABLE IF NOT EXISTS image_generation_histories (
141 id TEXT PRIMARY KEY,
142 session_id TEXT NOT NULL,
143 page_id TEXT NOT NULL,
144 prompt TEXT NOT NULL,
145 image_paths TEXT NOT NULL DEFAULT '[]',
146 model_config_id TEXT NOT NULL,
147 provider TEXT NOT NULL,
148 model TEXT NOT NULL,
149 created_at INTEGER NOT NULL
150 );
151 CREATE INDEX IF NOT EXISTS idx_image_generation_histories_session ON image_generation_histories(session_id, created_at);
152 CREATE INDEX IF NOT EXISTS idx_image_generation_histories_page ON image_generation_histories(session_id, page_id, created_at);
153
154 CREATE TABLE IF NOT EXISTS memory_summaries (
155 id TEXT PRIMARY KEY,
156 session_id TEXT NOT NULL,
157 message_range_start INTEGER NOT NULL,
158 message_range_end INTEGER NOT NULL,
159 summary TEXT NOT NULL,
160 token_count INTEGER,
161 created_at INTEGER NOT NULL
162 );
163
164 CREATE INDEX IF NOT EXISTS idx_memory_summaries_session ON memory_summaries(session_id, message_range_end);
165
166 CREATE INDEX IF NOT EXISTS idx_projects_session ON projects(session_id);
167 CREATE INDEX IF NOT EXISTS idx_memory_summaries_session_id ON memory_summaries(session_id);
168
169 CREATE TABLE IF NOT EXISTS generation_runs (
170 id TEXT PRIMARY KEY,
171 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
172 mode TEXT NOT NULL DEFAULT 'generate',
173 status TEXT NOT NULL DEFAULT 'running',
174 total_pages INTEGER NOT NULL DEFAULT 0,
175 error TEXT,
176 metadata TEXT,
177 animation_preferences TEXT,
178 model_config_id TEXT,
179 created_at INTEGER NOT NULL,
180 updated_at INTEGER NOT NULL
181 );
182 CREATE INDEX IF NOT EXISTS idx_generation_runs_session ON generation_runs(session_id, created_at);
183
184 CREATE TABLE IF NOT EXISTS session_jobs (
185 id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE,
186 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
187 kind TEXT NOT NULL,
188 previous_session_status TEXT NOT NULL,
189 target_page_id TEXT,
190 target_page_number INTEGER,
191 selector TEXT,
192 total_pages INTEGER,
193 status TEXT NOT NULL,
194 abort_reason TEXT,
195 created_at INTEGER NOT NULL,
196 activated_at INTEGER,
197 updated_at INTEGER NOT NULL,
198 finished_at INTEGER
199 );
200 CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status ON session_jobs(session_id, status, updated_at);
201 CREATE INDEX IF NOT EXISTS idx_session_jobs_status ON session_jobs(status, updated_at);
202
203 CREATE TABLE IF NOT EXISTS generation_pages (
204 id TEXT PRIMARY KEY,
205 run_id TEXT NOT NULL REFERENCES generation_runs(id) ON DELETE CASCADE,
206 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
207 page_id TEXT NOT NULL,
208 page_number INTEGER NOT NULL,
209 title TEXT NOT NULL,
210 content_outline TEXT,
211 layout_intent TEXT,
212 layout_id TEXT,
213 layout_contract_version INTEGER,
214 html_path TEXT,
215 status TEXT NOT NULL DEFAULT 'pending',
216 error TEXT,
217 retry_count INTEGER NOT NULL DEFAULT 0,
218 created_at INTEGER NOT NULL,
219 updated_at INTEGER NOT NULL
220 );
221 CREATE INDEX IF NOT EXISTS idx_generation_pages_run ON generation_pages(run_id, page_number);
222 CREATE INDEX IF NOT EXISTS idx_generation_pages_session_status ON generation_pages(session_id, status, page_number);
223
224 CREATE TABLE IF NOT EXISTS session_pages (
225 id TEXT PRIMARY KEY,
226 session_id TEXT NOT NULL,
227 legacy_page_id TEXT,
228 file_slug TEXT NOT NULL,
229 page_number INTEGER NOT NULL,
230 title TEXT NOT NULL,
231 html_path TEXT NOT NULL,
232 layout_intent TEXT,
233 layout_id TEXT,
234 layout_contract_version INTEGER,
235 status TEXT NOT NULL DEFAULT 'pending',
236 error TEXT,
237 created_at INTEGER NOT NULL,
238 updated_at INTEGER NOT NULL,
239 deleted_at INTEGER
240 );
241 CREATE INDEX IF NOT EXISTS idx_session_pages_session_number ON session_pages(session_id, page_number);
242
243 CREATE TABLE IF NOT EXISTS image_fulfillment_jobs (
244 id TEXT PRIMARY KEY,
245 run_id TEXT NOT NULL REFERENCES generation_runs(id) ON DELETE CASCADE,
246 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
247 session_page_id TEXT NOT NULL REFERENCES session_pages(id) ON DELETE CASCADE,
248 page_id TEXT NOT NULL,
249 layout_id TEXT,
250 layout_contract_version INTEGER,
251 image_model_config_id TEXT REFERENCES image_model_configs(id) ON DELETE SET NULL,
252 image_provider TEXT,
253 image_model TEXT,
254 attempt INTEGER NOT NULL,
255 retry_of_job_id TEXT REFERENCES image_fulfillment_jobs(id) ON DELETE SET NULL,
256 idempotency_key TEXT,
257 status TEXT NOT NULL DEFAULT 'pending',
258 error TEXT,
259 cancel_requested_at INTEGER,
260 lease_owner TEXT,
261 lease_expires_at INTEGER,
262 finalization_manifest_path TEXT,
263 created_at INTEGER NOT NULL,
264 started_at INTEGER,
265 updated_at INTEGER NOT NULL,
266 finished_at INTEGER
267 );
268 CREATE UNIQUE INDEX IF NOT EXISTS image_fulfillment_run_page_attempt_unique
269 ON image_fulfillment_jobs(run_id, session_page_id, attempt);
270 CREATE UNIQUE INDEX IF NOT EXISTS image_fulfillment_idempotency_unique
271 ON image_fulfillment_jobs(session_id, idempotency_key);
272 CREATE UNIQUE INDEX IF NOT EXISTS idx_image_fulfillment_one_active_page
273 ON image_fulfillment_jobs(session_id, session_page_id)
274 WHERE status IN ('pending', 'running', 'finalizing');
275 CREATE INDEX IF NOT EXISTS idx_image_fulfillment_jobs_session_page_status
276 ON image_fulfillment_jobs(session_id, session_page_id, status, updated_at);
277
278 CREATE TABLE IF NOT EXISTS image_fulfillment_intents (
279 id TEXT PRIMARY KEY,
280 job_id TEXT NOT NULL REFERENCES image_fulfillment_jobs(id) ON DELETE CASCADE,
281 slot_id TEXT NOT NULL,
282 layout_slot_id TEXT NOT NULL,
283 role TEXT NOT NULL,
284 layer TEXT NOT NULL,
285 request_version INTEGER NOT NULL DEFAULT 1,
286 size_hint TEXT,
287 subject TEXT NOT NULL,
288 text_zone TEXT,
289 subject_zone TEXT,
290 negative_space TEXT,
291 avoid_json TEXT,
292 request_json TEXT NOT NULL,
293 image_history_id TEXT,
294 asset_path TEXT,
295 width INTEGER,
296 height INTEGER,
297 mime_type TEXT,
298 attempt INTEGER NOT NULL DEFAULT 1,
299 retry_of_intent_id TEXT REFERENCES image_fulfillment_intents(id) ON DELETE SET NULL,
300 status TEXT NOT NULL DEFAULT 'pending',
301 error TEXT,
302 created_at INTEGER NOT NULL,
303 updated_at INTEGER NOT NULL
304 );
305 CREATE UNIQUE INDEX IF NOT EXISTS image_fulfillment_intent_slot_unique
306 ON image_fulfillment_intents(job_id, slot_id);
307 CREATE INDEX IF NOT EXISTS idx_image_fulfillment_intents_job_status
308 ON image_fulfillment_intents(job_id, status, updated_at);
309
310 CREATE TABLE IF NOT EXISTS source_page_skeletons (
311 id TEXT PRIMARY KEY,
312 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
313 page_number INTEGER NOT NULL,
314 title TEXT NOT NULL,
315 role TEXT NOT NULL DEFAULT 'content',
316 source_document_path TEXT NOT NULL,
317 source_document_name TEXT,
318 source_heading TEXT NOT NULL,
319 heading_level INTEGER NOT NULL,
320 line_start INTEGER NOT NULL,
321 line_end INTEGER NOT NULL,
322 agenda_items_json TEXT,
323 reason TEXT,
324 confidence TEXT NOT NULL DEFAULT 'high',
325 created_at INTEGER NOT NULL,
326 updated_at INTEGER NOT NULL
327 );
328 CREATE INDEX IF NOT EXISTS idx_source_page_skeletons_session ON source_page_skeletons(session_id, page_number);
329
330 CREATE TABLE IF NOT EXISTS user_preferences (
331 key TEXT PRIMARY KEY,
332 value TEXT NOT NULL,
333 confidence REAL DEFAULT 1.0,
334 source_sessions TEXT,
335 created_at INTEGER NOT NULL,
336 updated_at INTEGER NOT NULL,
337 last_used_at INTEGER
338 );
339
340 CREATE TABLE IF NOT EXISTS styles (
341 id TEXT PRIMARY KEY,
342 style TEXT UNIQUE NOT NULL,
343 style_name TEXT NOT NULL,
344 style_name_zh TEXT NOT NULL DEFAULT '',
345 style_name_en TEXT NOT NULL DEFAULT '',
346 description TEXT NOT NULL DEFAULT '',
347 category TEXT NOT NULL DEFAULT '',
348 aliases TEXT NOT NULL DEFAULT '[]',
349 source TEXT NOT NULL DEFAULT 'custom',
350 style_skill TEXT NOT NULL DEFAULT '',
351 version TEXT NOT NULL DEFAULT '1.0.0',
352 style_case TEXT NOT NULL DEFAULT '',
353 image_generation_prompt TEXT NOT NULL DEFAULT '',
354 package_dir TEXT NOT NULL DEFAULT '',
355 active INTEGER NOT NULL DEFAULT 1,
356 favorite_at INTEGER,
357 created_at INTEGER NOT NULL,
358 updated_at INTEGER NOT NULL
359 );
360 CREATE UNIQUE INDEX IF NOT EXISTS idx_styles_style ON styles(style);
361
362 CREATE TABLE IF NOT EXISTS thumbnails (
363 key TEXT PRIMARY KEY,
364 resource_type TEXT NOT NULL,
365 resource_id TEXT NOT NULL,
366 variant TEXT NOT NULL DEFAULT 'default',
367 source_path TEXT NOT NULL,
368 source_mtime_ms INTEGER NOT NULL DEFAULT 0,
369 signature TEXT NOT NULL DEFAULT '',
370 thumbnail_path TEXT NOT NULL DEFAULT '',
371 status TEXT NOT NULL DEFAULT 'queued',
372 error TEXT,
373 created_at INTEGER NOT NULL,
374 updated_at INTEGER NOT NULL
375 );
376 CREATE UNIQUE INDEX IF NOT EXISTS thumbnails_resource_variant_unique
377 ON thumbnails(resource_type, resource_id, variant);
378 CREATE INDEX IF NOT EXISTS thumbnails_status_idx ON thumbnails(status, updated_at);
379
380 CREATE TABLE IF NOT EXISTS session_style_snapshots (
381 id TEXT PRIMARY KEY,
382 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
383 style_id TEXT NOT NULL,
384 style_key TEXT NOT NULL,
385 style_name TEXT NOT NULL,
386 style_name_zh TEXT NOT NULL DEFAULT '',
387 style_name_en TEXT NOT NULL DEFAULT '',
388 description TEXT NOT NULL DEFAULT '',
389 category TEXT NOT NULL DEFAULT '',
390 aliases TEXT NOT NULL DEFAULT '[]',
391 source TEXT NOT NULL,
392 version TEXT NOT NULL DEFAULT '1.0.0',
393 style_case TEXT NOT NULL DEFAULT '',
394 image_generation_prompt TEXT NOT NULL DEFAULT '',
395 package_dir TEXT NOT NULL DEFAULT '',
396 style_skill TEXT NOT NULL DEFAULT '',
397 created_at INTEGER NOT NULL
398 );
399 CREATE UNIQUE INDEX IF NOT EXISTS session_style_snapshots_session_id_unique
400 ON session_style_snapshots(session_id);
401
402 CREATE TABLE IF NOT EXISTS session_operations (
403 id TEXT PRIMARY KEY,
404 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
405 type TEXT NOT NULL,
406 status TEXT NOT NULL DEFAULT 'completed',
407 scope TEXT,
408 prompt TEXT,
409 parent_operation_id TEXT,
410 before_commit TEXT,
411 after_commit TEXT,
412 target_operation_id TEXT,
413 target_commit TEXT,
414 changed_files_json TEXT NOT NULL DEFAULT '[]',
415 changed_pages_json TEXT NOT NULL DEFAULT '[]',
416 tracked_files_json TEXT NOT NULL DEFAULT '[]',
417 metadata_json TEXT NOT NULL DEFAULT '{}',
418 created_at INTEGER NOT NULL,
419 completed_at INTEGER
420 );
421 CREATE INDEX IF NOT EXISTS idx_session_operations_session_created ON session_operations(session_id, created_at);
422 CREATE INDEX IF NOT EXISTS idx_session_operations_session_status ON session_operations(session_id, status, created_at);
423
424 CREATE TABLE IF NOT EXISTS session_operation_pages (
425 id TEXT PRIMARY KEY,
426 operation_id TEXT NOT NULL REFERENCES session_operations(id) ON DELETE CASCADE,
427 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
428 page_id TEXT NOT NULL,
429 legacy_page_id TEXT,
430 file_slug TEXT NOT NULL,
431 page_number INTEGER NOT NULL,
432 title TEXT NOT NULL,
433 html_path TEXT NOT NULL,
434 status TEXT NOT NULL DEFAULT 'pending',
435 error TEXT,
436 created_at INTEGER NOT NULL,
437 updated_at INTEGER NOT NULL
438 );
439 CREATE INDEX IF NOT EXISTS idx_session_operation_pages_order ON session_operation_pages(operation_id, page_number);
440 CREATE INDEX IF NOT EXISTS idx_session_operation_pages_session ON session_operation_pages(session_id, operation_id);
441 `
442
443 const getRowValue = (row: unknown, key: string): unknown => {
444 if (row && typeof row === 'object' && !Array.isArray(row) && key in row) {
445 return (row as Record<string, unknown>)[key]
446 }
447 return undefined
448 }
449
450 const parseJsonObject = (value: unknown): Record<string, unknown> => {
451 if (typeof value !== 'string' || value.trim().length === 0) return {}
452 try {
453 const parsed = JSON.parse(value) as unknown
454 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
455 ? (parsed as Record<string, unknown>)
456 : {}
457 } catch {
458 return {}
459 }
460 }
461
462 const toPositiveInt = (value: unknown, fallback: number): number => {
463 const number = Number(value)
464 return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback
465 }
466
467 const inferPageNumber = (page: Record<string, unknown>, fallback: number): number => {
468 const explicit = toPositiveInt(page.pageNumber ?? page.page_number, 0)
469 if (explicit > 0) return explicit
470 const rawPageId =
471 typeof (page.pageId ?? page.page_id) === 'string'
472 ? String(page.pageId ?? page.page_id).trim()
473 : ''
474 const fromPageId = toPositiveInt(rawPageId.match(/^page-(\d+)$/i)?.[1], 0)
475 return fromPageId > 0 ? fromPageId : fallback
476 }
477
478 const resolveLegacyPagePath = (
479 page: Record<string, unknown>,
480 projectDir: string,
481 pageId: string
482 ): string => {
483 const rawPath =
484 typeof (page.htmlPath ?? page.html_path) === 'string'
485 ? String(page.htmlPath ?? page.html_path).trim()
486 : ''
487 if (!rawPath) return path.join(projectDir, `${pageId}.html`)
488 return path.isAbsolute(rawPath) ? rawPath : path.join(projectDir, rawPath)
489 }
490
491 const getTableColumns = async (
492 client: LibSqlClient,
493 tableName:
494 | 'settings'
495 | 'messages'
496 | 'sessions'
497 | 'projects'
498 | 'generation_runs'
499 | 'generation_jobs'
500 | 'session_jobs'
501 | 'page_edit_jobs'
502 | 'deck_edit_jobs'
503 | 'generation_pages'
504 | 'session_pages'
505 | 'model_configs'
506 | 'image_model_configs'
507 | 'html_edit_messages'
508 ): Promise<Set<string>> => {
509 const result = await client.execute(`PRAGMA table_info(${tableName})`)
510 const rows = Array.isArray((result as { rows?: unknown[] }).rows)
511 ? ((result as { rows?: unknown[] }).rows as unknown[])
512 : []
513 const columns = new Set<string>()
514 for (const row of rows) {
515 if (row && typeof row === 'object' && 'name' in row) {
516 const name = (row as { name?: unknown }).name
517 if (typeof name === 'string' && name.trim().length > 0) {
518 columns.add(name.trim())
519 }
520 continue
521 }
522 if (Array.isArray(row) && typeof row[1] === 'string' && row[1].trim().length > 0) {
523 columns.add(row[1].trim())
524 }
525 }
526 return columns
527 }
528
529 const enforceSettingsSchema = async (client: LibSqlClient): Promise<void> => {
530 await client.execute(SETTINGS_TABLE_SQL)
531 const columns = await getTableColumns(client, 'settings')
532 if (!columns.has('value')) {
533 await client.execute(`ALTER TABLE settings ADD COLUMN value TEXT NOT NULL DEFAULT '""'`)
534 }
535 if (!columns.has('updated_at')) {
536 await client.execute('ALTER TABLE settings ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0')
537 }
538 await client.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_key ON settings(key)')
539 }
540
541 const enforceModelConfigsSchema = async (client: LibSqlClient): Promise<void> => {
542 await client.execute(`
543 CREATE TABLE IF NOT EXISTS model_configs (
544 id TEXT PRIMARY KEY,
545 name TEXT NOT NULL,
546 provider TEXT NOT NULL,
547 model TEXT NOT NULL,
548 api_key TEXT NOT NULL DEFAULT '',
549 base_url TEXT NOT NULL DEFAULT '',
550 max_tokens INTEGER NOT NULL DEFAULT 4096,
551 active INTEGER NOT NULL DEFAULT 0,
552 created_at INTEGER NOT NULL,
553 updated_at INTEGER NOT NULL
554 )
555 `)
556 await client.execute(
557 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_configs_single_active ON model_configs(active) WHERE active = 1'
558 )
559 await client.execute(
560 'CREATE INDEX IF NOT EXISTS idx_model_configs_updated ON model_configs(updated_at)'
561 )
562 }
563
564 const enforceImageModelConfigsSchema = async (client: LibSqlClient): Promise<void> => {
565 const columns = await getTableColumns(client, 'image_model_configs')
566 if (columns.size > 0 && !columns.has('model_config')) {
567 await client.execute('DROP INDEX IF EXISTS idx_image_model_configs_single_active')
568 await client.execute('DROP INDEX IF EXISTS idx_image_model_configs_updated')
569 await client.execute('DROP TABLE IF EXISTS image_model_configs')
570 }
571 await client.execute(`
572 CREATE TABLE IF NOT EXISTS image_model_configs (
573 id TEXT PRIMARY KEY,
574 name TEXT NOT NULL,
575 provider TEXT NOT NULL,
576 model_config TEXT NOT NULL DEFAULT '{}',
577 active INTEGER NOT NULL DEFAULT 0,
578 created_at INTEGER NOT NULL,
579 updated_at INTEGER NOT NULL
580 )
581 `)
582 await client.execute(
583 'CREATE UNIQUE INDEX IF NOT EXISTS idx_image_model_configs_single_active ON image_model_configs(active) WHERE active = 1'
584 )
585 await client.execute(
586 'CREATE INDEX IF NOT EXISTS idx_image_model_configs_updated ON image_model_configs(updated_at)'
587 )
588 }
589
590 const enforceSessionsSchema = async (client: LibSqlClient): Promise<void> => {
591 const columns = await getTableColumns(client, 'sessions')
592 if (!columns.has('style_id')) {
593 await client.execute('ALTER TABLE sessions ADD COLUMN style_id TEXT')
594 }
595 if (!columns.has('reference_document_path')) {
596 await client.execute('ALTER TABLE sessions ADD COLUMN reference_document_path TEXT')
597 }
598 if (!columns.has('slide_size_id')) {
599 await client.execute(
600 "ALTER TABLE sessions ADD COLUMN slide_size_id TEXT NOT NULL DEFAULT 'wide-16-9'"
601 )
602 }
603 if (!columns.has('slide_width')) {
604 await client.execute(
605 'ALTER TABLE sessions ADD COLUMN slide_width INTEGER NOT NULL DEFAULT 1600'
606 )
607 }
608 if (!columns.has('slide_height')) {
609 await client.execute(
610 'ALTER TABLE sessions ADD COLUMN slide_height INTEGER NOT NULL DEFAULT 900'
611 )
612 }
613 await client.execute({
614 sql: `
615 UPDATE sessions
616 SET
617 slide_size_id = 'wide-16-9',
618 slide_width = 1600,
619 slide_height = 900
620 WHERE
621 slide_size_id IS NULL
622 OR TRIM(slide_size_id) = ''
623 OR slide_width IS NULL
624 OR slide_width <= 0
625 OR slide_height IS NULL
626 OR slide_height <= 0
627 `,
628 args: []
629 })
630 if (!columns.has('current_operation_id')) {
631 await client.execute('ALTER TABLE sessions ADD COLUMN current_operation_id TEXT')
632 }
633 if (!columns.has('current_commit')) {
634 await client.execute('ALTER TABLE sessions ADD COLUMN current_commit TEXT')
635 }
636 if (!columns.has('visual_enabled')) {
637 await client.execute("ALTER TABLE sessions ADD COLUMN visual_enabled INTEGER NOT NULL DEFAULT 0")
638 }
639 if (!columns.has('image_model_config_id')) {
640 await client.execute('ALTER TABLE sessions ADD COLUMN image_model_config_id TEXT')
641 }
642 await client.execute(
643 'UPDATE sessions SET visual_enabled = 0 WHERE visual_enabled IS NULL OR visual_enabled NOT IN (0, 1)'
644 )
645 await client.execute(
646 'UPDATE sessions SET image_model_config_id = NULL WHERE visual_enabled = 0'
647 )
648 await client.execute(
649 'CREATE INDEX IF NOT EXISTS idx_sessions_image_model_config ON sessions(image_model_config_id)'
650 )
651 }
652
653 const enforceProjectsSchema = async (client: LibSqlClient): Promise<void> => {
654 const columns = await getTableColumns(client, 'projects')
655 if (!columns.has('root_path')) {
656 await client.execute('ALTER TABLE projects ADD COLUMN root_path TEXT')
657 }
658 }
659
660 const hasSessionHtmlFiles = (dir: string): boolean => {
661 try {
662 if (!fsExistsSafe(path.join(dir, 'index.html'))) return false
663 return fs
664 .readdirSync(dir, { withFileTypes: true })
665 .some((entry) => entry.isFile() && /^page-\d+\.html?$/i.test(entry.name))
666 } catch {
667 return false
668 }
669 }
670
671 const inferProjectRootPath = async (
672 client: LibSqlClient,
673 sessionId: string,
674 outputPath: string,
675 metadata: Record<string, unknown>,
676 resolveStoragePath: () => Promise<string>
677 ): Promise<string> => {
678 const candidateDirs: string[] = []
679 const addCandidate = (value: unknown): void => {
680 if (typeof value !== 'string' || value.trim().length === 0) return
681 const resolved = path.resolve(value.trim())
682 if (!candidateDirs.includes(resolved)) candidateDirs.push(resolved)
683 }
684
685 if (typeof metadata.indexPath === 'string' && metadata.indexPath.trim().length > 0) {
686 addCandidate(path.dirname(metadata.indexPath.trim()))
687 }
688 addCandidate(outputPath)
689
690 const generatedPages = Array.isArray(metadata.generatedPages) ? metadata.generatedPages : []
691 for (const page of generatedPages) {
692 if (!page || typeof page !== 'object' || Array.isArray(page)) continue
693 const htmlPath = (page as Record<string, unknown>).htmlPath
694 if (typeof htmlPath === 'string' && htmlPath.trim().length > 0) {
695 addCandidate(path.dirname(htmlPath.trim()))
696 }
697 }
698
699 const pageRows = await client
700 .execute({
701 sql: 'SELECT html_path FROM session_pages WHERE session_id = ?',
702 args: [sessionId]
703 })
704 .catch(() => ({ rows: [] as unknown[] }))
705 for (const row of pageRows.rows || []) {
706 const htmlPath = getRowValue(row, 'html_path')
707 if (typeof htmlPath === 'string' && fsExistsSafe(htmlPath)) {
708 addCandidate(path.dirname(htmlPath))
709 }
710 }
711
712 const storagePath = await resolveStoragePath().catch(() => '')
713 addCandidate(path.join(storagePath || process.cwd(), sessionId))
714
715 for (const dir of candidateDirs) {
716 if (hasSessionHtmlFiles(dir)) return dir
717 }
718 for (const dir of candidateDirs) {
719 if (fsExistsSafe(path.join(dir, '.git'))) return dir
720 }
721 return ''
722 }
723
724 const patchProjectRootPaths = async (args: {
725 client: LibSqlClient
726 resolveStoragePath: () => Promise<string>
727 }): Promise<void> => {
728 const { client, resolveStoragePath } = args
729 const result = await client.execute(`
730 SELECT projects.id AS project_id,
731 projects.session_id AS session_id,
732 projects.output_path AS output_path,
733 sessions.metadata AS metadata
734 FROM projects
735 LEFT JOIN sessions ON sessions.id = projects.session_id
736 WHERE projects.root_path IS NULL OR TRIM(projects.root_path) = ''
737 `)
738
739 for (const row of result.rows || []) {
740 const projectId = String(getRowValue(row, 'project_id') || '')
741 const sessionId = String(getRowValue(row, 'session_id') || '')
742 const outputPath = String(getRowValue(row, 'output_path') || '')
743 if (!projectId || !sessionId) continue
744 const metadata = parseJsonObject(getRowValue(row, 'metadata'))
745 const rootPath = await inferProjectRootPath(
746 client,
747 sessionId,
748 outputPath,
749 metadata,
750 resolveStoragePath
751 )
752 if (!rootPath) continue
753 await client.execute({
754 sql: 'UPDATE projects SET root_path = ? WHERE id = ? AND (root_path IS NULL OR TRIM(root_path) = ?)',
755 args: [rootPath, projectId, '']
756 })
757 }
758 }
759
760 const enforceSessionOperationsSchema = async (client: LibSqlClient): Promise<void> => {
761 await client.execute(`
762 CREATE TABLE IF NOT EXISTS session_operations (
763 id TEXT PRIMARY KEY,
764 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
765 type TEXT NOT NULL,
766 status TEXT NOT NULL DEFAULT 'completed',
767 scope TEXT,
768 prompt TEXT,
769 parent_operation_id TEXT,
770 before_commit TEXT,
771 after_commit TEXT,
772 target_operation_id TEXT,
773 target_commit TEXT,
774 changed_files_json TEXT NOT NULL DEFAULT '[]',
775 changed_pages_json TEXT NOT NULL DEFAULT '[]',
776 tracked_files_json TEXT NOT NULL DEFAULT '[]',
777 metadata_json TEXT NOT NULL DEFAULT '{}',
778 created_at INTEGER NOT NULL,
779 completed_at INTEGER
780 )
781 `)
782 await client.execute(
783 'CREATE INDEX IF NOT EXISTS idx_session_operations_session_created ON session_operations(session_id, created_at)'
784 )
785 await client.execute(
786 'CREATE INDEX IF NOT EXISTS idx_session_operations_session_status ON session_operations(session_id, status, created_at)'
787 )
788 }
789
790 const enforceSessionOperationPagesSchema = async (client: LibSqlClient): Promise<void> => {
791 await client.execute(`
792 CREATE TABLE IF NOT EXISTS session_operation_pages (
793 id TEXT PRIMARY KEY,
794 operation_id TEXT NOT NULL REFERENCES session_operations(id) ON DELETE CASCADE,
795 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
796 page_id TEXT NOT NULL,
797 legacy_page_id TEXT,
798 file_slug TEXT NOT NULL,
799 page_number INTEGER NOT NULL,
800 title TEXT NOT NULL,
801 html_path TEXT NOT NULL,
802 status TEXT NOT NULL DEFAULT 'pending',
803 error TEXT,
804 created_at INTEGER NOT NULL,
805 updated_at INTEGER NOT NULL
806 )
807 `)
808 await client.execute(
809 'CREATE INDEX IF NOT EXISTS idx_session_operation_pages_order ON session_operation_pages(operation_id, page_number)'
810 )
811 await client.execute(
812 'CREATE INDEX IF NOT EXISTS idx_session_operation_pages_session ON session_operation_pages(session_id, operation_id)'
813 )
814 }
815
816 const enforceMessagesSchema = async (client: LibSqlClient): Promise<void> => {
817 await client.executeMultiple(MESSAGES_TABLE_SQL)
818 const columns = await getTableColumns(client, 'messages')
819 if (!columns.has('chat_scope')) {
820 await client.execute(`ALTER TABLE messages ADD COLUMN chat_scope TEXT NOT NULL DEFAULT 'main'`)
821 }
822 if (!columns.has('page_id')) {
823 await client.execute('ALTER TABLE messages ADD COLUMN page_id TEXT')
824 }
825 if (!columns.has('selector')) {
826 await client.execute('ALTER TABLE messages ADD COLUMN selector TEXT')
827 }
828 if (!columns.has('image_paths')) {
829 await client.execute('ALTER TABLE messages ADD COLUMN image_paths TEXT')
830 }
831 if (!columns.has('video_paths')) {
832 await client.execute('ALTER TABLE messages ADD COLUMN video_paths TEXT')
833 }
834 if (!columns.has('type')) {
835 await client.execute('ALTER TABLE messages ADD COLUMN type TEXT')
836 }
837 if (!columns.has('tool_name')) {
838 await client.execute('ALTER TABLE messages ADD COLUMN tool_name TEXT')
839 }
840 if (!columns.has('tool_call_id')) {
841 await client.execute('ALTER TABLE messages ADD COLUMN tool_call_id TEXT')
842 }
843 if (!columns.has('token_count')) {
844 await client.execute('ALTER TABLE messages ADD COLUMN token_count INTEGER')
845 }
846 if (!columns.has('run_model')) {
847 await client.execute('ALTER TABLE messages ADD COLUMN run_model TEXT')
848 }
849 await client.execute(
850 'CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at)'
851 )
852 await client.execute(
853 'CREATE INDEX IF NOT EXISTS idx_messages_session_scope ON messages(session_id, chat_scope, page_id, created_at)'
854 )
855 await client.execute(
856 'CREATE INDEX IF NOT EXISTS idx_messages_session_only ON messages(session_id)'
857 )
858 }
859
860 const enforceGenerationSchema = async (client: LibSqlClient): Promise<void> => {
861 await client.execute(`
862 CREATE TABLE IF NOT EXISTS session_jobs (
863 id TEXT PRIMARY KEY REFERENCES generation_runs(id) ON DELETE CASCADE,
864 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
865 kind TEXT NOT NULL,
866 previous_session_status TEXT NOT NULL DEFAULT 'active',
867 target_page_id TEXT,
868 target_page_number INTEGER,
869 selector TEXT,
870 total_pages INTEGER,
871 status TEXT NOT NULL,
872 abort_reason TEXT,
873 created_at INTEGER NOT NULL,
874 activated_at INTEGER,
875 updated_at INTEGER NOT NULL,
876 finished_at INTEGER
877 )
878 `)
879 const runColumns = await getTableColumns(client, 'generation_runs')
880 if (!runColumns.has('model_config_id')) {
881 await client.execute('ALTER TABLE generation_runs ADD COLUMN model_config_id TEXT')
882 }
883 if (!runColumns.has('animation_preferences')) {
884 await client.execute('ALTER TABLE generation_runs ADD COLUMN animation_preferences TEXT')
885 }
886 let legacyGenerationJobColumns = await getTableColumns(client, 'generation_jobs')
887 if (legacyGenerationJobColumns.size > 0 && !legacyGenerationJobColumns.has('abort_reason')) {
888 await client.execute('ALTER TABLE generation_jobs ADD COLUMN abort_reason TEXT')
889 }
890 if (legacyGenerationJobColumns.size > 0 && !legacyGenerationJobColumns.has('activated_at')) {
891 await client.execute('ALTER TABLE generation_jobs ADD COLUMN activated_at INTEGER')
892 }
893 if (legacyGenerationJobColumns.size > 0 && !legacyGenerationJobColumns.has('finished_at')) {
894 await client.execute('ALTER TABLE generation_jobs ADD COLUMN finished_at INTEGER')
895 }
896 if (legacyGenerationJobColumns.size > 0) {
897 legacyGenerationJobColumns = await getTableColumns(client, 'generation_jobs')
898 }
899 const legacyPageEditJobColumns = await getTableColumns(client, 'page_edit_jobs')
900 const legacyDeckEditJobColumns = await getTableColumns(client, 'deck_edit_jobs')
901 // Keep every legacy table until every source has been copied. A failed migration can then
902 // be retried on the next startup without losing the original job records.
903 const migration = await client.transaction('write')
904 const upsertSessionJob = `
905 ON CONFLICT(id) DO UPDATE SET
906 session_id = excluded.session_id,
907 kind = excluded.kind,
908 previous_session_status = excluded.previous_session_status,
909 target_page_id = excluded.target_page_id,
910 target_page_number = excluded.target_page_number,
911 selector = excluded.selector,
912 total_pages = excluded.total_pages,
913 status = excluded.status,
914 abort_reason = excluded.abort_reason,
915 created_at = excluded.created_at,
916 activated_at = excluded.activated_at,
917 updated_at = excluded.updated_at,
918 finished_at = excluded.finished_at
919 `
920 try {
921 if (legacyGenerationJobColumns.size > 0) {
922 const specializedJobGuards = [
923 legacyPageEditJobColumns.size > 0
924 ? 'NOT EXISTS (SELECT 1 FROM page_edit_jobs AS page_jobs WHERE page_jobs.id = jobs.id)'
925 : '',
926 legacyDeckEditJobColumns.size > 0
927 ? 'NOT EXISTS (SELECT 1 FROM deck_edit_jobs AS deck_jobs WHERE deck_jobs.id = jobs.id)'
928 : ''
929 ].filter(Boolean)
930 await migration.execute(`
931 INSERT INTO session_jobs (
932 id, session_id, kind, previous_session_status, target_page_id, target_page_number,
933 selector, total_pages, status, abort_reason, created_at, activated_at, updated_at, finished_at
934 )
935 SELECT
936 jobs.id,
937 jobs.session_id,
938 CASE jobs.kind
939 WHEN 'template' THEN 'template'
940 WHEN 'retry' THEN 'retry'
941 WHEN 'edit' THEN 'deck-edit'
942 ELSE 'standard'
943 END,
944 COALESCE(
945 CASE
946 WHEN runs.metadata IS NOT NULL AND json_valid(runs.metadata)
947 THEN json_extract(runs.metadata, '$.previousSessionStatus')
948 END,
949 'active'
950 ),
951 NULL,
952 NULL,
953 NULL,
954 runs.total_pages,
955 jobs.status,
956 jobs.abort_reason,
957 jobs.created_at,
958 jobs.activated_at,
959 jobs.updated_at,
960 jobs.finished_at
961 FROM generation_jobs AS jobs
962 LEFT JOIN generation_runs AS runs ON runs.id = jobs.id
963 WHERE ${specializedJobGuards.join(' AND ') || '1'}
964 ${upsertSessionJob}
965 `)
966 }
967 if (legacyPageEditJobColumns.size > 0) {
968 const deckJobGuard =
969 legacyDeckEditJobColumns.size > 0
970 ? 'WHERE NOT EXISTS (SELECT 1 FROM deck_edit_jobs AS deck_jobs WHERE deck_jobs.id = page_edit_jobs.id)'
971 : 'WHERE 1'
972 await migration.execute(`
973 INSERT INTO session_jobs (
974 id, session_id, kind, previous_session_status, target_page_id, target_page_number,
975 selector, total_pages, status, abort_reason, created_at, activated_at, updated_at, finished_at
976 )
977 SELECT
978 id,
979 session_id,
980 'page-edit',
981 previous_session_status,
982 target_page_id,
983 target_page_number,
984 selector,
985 1,
986 status,
987 abort_reason,
988 created_at,
989 activated_at,
990 updated_at,
991 finished_at
992 FROM page_edit_jobs
993 ${deckJobGuard}
994 ${upsertSessionJob}
995 `)
996 }
997 if (legacyDeckEditJobColumns.size > 0) {
998 await migration.execute(`
999 INSERT INTO session_jobs (
1000 id, session_id, kind, previous_session_status, target_page_id, target_page_number,
1001 selector, total_pages, status, abort_reason, created_at, activated_at, updated_at, finished_at
1002 )
1003 SELECT
1004 id,
1005 session_id,
1006 'deck-edit',
1007 previous_session_status,
1008 NULL,
1009 NULL,
1010 NULL,
1011 total_pages,
1012 status,
1013 abort_reason,
1014 created_at,
1015 activated_at,
1016 updated_at,
1017 finished_at
1018 FROM deck_edit_jobs
1019 WHERE 1
1020 ${upsertSessionJob}
1021 `)
1022 }
1023 if (legacyGenerationJobColumns.size > 0) {
1024 await migration.execute('DROP TABLE generation_jobs')
1025 }
1026 if (legacyPageEditJobColumns.size > 0) {
1027 await migration.execute('DROP TABLE page_edit_jobs')
1028 }
1029 if (legacyDeckEditJobColumns.size > 0) {
1030 await migration.execute('DROP TABLE deck_edit_jobs')
1031 }
1032 await migration.commit()
1033 } catch (error) {
1034 try {
1035 await migration.rollback()
1036 } catch {
1037 // Preserve the migration error when a failed transaction has already closed itself.
1038 }
1039 throw error
1040 }
1041 const columns = await getTableColumns(client, 'generation_pages')
1042 if (!columns.has('content_outline')) {
1043 await client.execute('ALTER TABLE generation_pages ADD COLUMN content_outline TEXT')
1044 }
1045 if (!columns.has('layout_intent')) {
1046 await client.execute('ALTER TABLE generation_pages ADD COLUMN layout_intent TEXT')
1047 }
1048 if (!columns.has('layout_id')) {
1049 await client.execute('ALTER TABLE generation_pages ADD COLUMN layout_id TEXT')
1050 }
1051 if (!columns.has('layout_contract_version')) {
1052 await client.execute('ALTER TABLE generation_pages ADD COLUMN layout_contract_version INTEGER')
1053 }
1054 await client.execute(
1055 'CREATE INDEX IF NOT EXISTS idx_generation_runs_session ON generation_runs(session_id, created_at)'
1056 )
1057 await client.execute(
1058 'CREATE INDEX IF NOT EXISTS idx_generation_runs_model_config ON generation_runs(model_config_id)'
1059 )
1060 await client.execute(
1061 'CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status ON session_jobs(session_id, status, updated_at)'
1062 )
1063 await client.execute(
1064 'CREATE INDEX IF NOT EXISTS idx_session_jobs_status ON session_jobs(status, updated_at)'
1065 )
1066 await client.execute(
1067 'CREATE INDEX IF NOT EXISTS idx_generation_pages_run ON generation_pages(run_id, page_number)'
1068 )
1069 await client.execute(
1070 'CREATE INDEX IF NOT EXISTS idx_generation_pages_session_status ON generation_pages(session_id, status, page_number)'
1071 )
1072 }
1073
1074 const enforceSessionPagesSchema = async (client: LibSqlClient): Promise<void> => {
1075 const columns = await getTableColumns(client, 'session_pages')
1076 if (!columns.has('legacy_page_id')) {
1077 await client.execute('ALTER TABLE session_pages ADD COLUMN legacy_page_id TEXT')
1078 }
1079 if (!columns.has('file_slug')) {
1080 await client.execute("ALTER TABLE session_pages ADD COLUMN file_slug TEXT NOT NULL DEFAULT ''")
1081 }
1082 if (!columns.has('status')) {
1083 await client.execute(
1084 "ALTER TABLE session_pages ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'"
1085 )
1086 }
1087 if (!columns.has('error')) {
1088 await client.execute('ALTER TABLE session_pages ADD COLUMN error TEXT')
1089 }
1090 if (!columns.has('deleted_at')) {
1091 await client.execute('ALTER TABLE session_pages ADD COLUMN deleted_at INTEGER')
1092 }
1093 if (!columns.has('layout_intent')) {
1094 await client.execute('ALTER TABLE session_pages ADD COLUMN layout_intent TEXT')
1095 }
1096 if (!columns.has('layout_id')) {
1097 await client.execute('ALTER TABLE session_pages ADD COLUMN layout_id TEXT')
1098 }
1099 if (!columns.has('layout_contract_version')) {
1100 await client.execute('ALTER TABLE session_pages ADD COLUMN layout_contract_version INTEGER')
1101 }
1102 await client.execute(
1103 'CREATE INDEX IF NOT EXISTS idx_session_pages_session_number ON session_pages(session_id, page_number)'
1104 )
1105 }
1106
1107 const ensureDefaultSettings = async (client: LibSqlClient): Promise<void> => {
1108 const now = Math.floor(Date.now() / 1000)
1109 const defaults = [
1110 { key: 'theme', value: '"light"' },
1111 { key: 'locale', value: '"zh"' },
1112 { key: 'timeout_ms_planning', value: JSON.stringify(defaultModelTimeoutMs('planning')) },
1113 { key: 'timeout_ms_design', value: JSON.stringify(defaultModelTimeoutMs('design')) },
1114 { key: 'timeout_ms_agent', value: JSON.stringify(defaultModelTimeoutMs('agent')) },
1115 { key: 'timeout_ms_document', value: JSON.stringify(defaultModelTimeoutMs('document')) }
1116 ]
1117
1118 for (const { key, value } of defaults) {
1119 await client.execute({
1120 sql: 'INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
1121 args: [key, value, now]
1122 })
1123 }
1124 }
1125
1126 const resolveLegacyProjectDir = async (
1127 client: LibSqlClient,
1128 sessionId: string,
1129 metadata: Record<string, unknown>,
1130 resolveStoragePath: () => Promise<string>
1131 ): Promise<string> => {
1132 const projectResult = await client
1133 .execute({
1134 sql: 'SELECT root_path, output_path FROM projects WHERE session_id = ? LIMIT 1',
1135 args: [sessionId]
1136 })
1137 .catch(() => ({ rows: [] as unknown[] }))
1138 const rootPath = getRowValue(projectResult.rows?.[0], 'root_path')
1139 if (typeof rootPath === 'string' && rootPath.trim().length > 0) {
1140 return rootPath.trim()
1141 }
1142 const outputPath = getRowValue(projectResult.rows?.[0], 'output_path')
1143 const metadataProjectDir =
1144 typeof metadata.indexPath === 'string' && metadata.indexPath.trim().length > 0
1145 ? path.dirname(metadata.indexPath.trim())
1146 : ''
1147 if (
1148 metadataProjectDir &&
1149 fsExistsSafe(String(metadata.indexPath)) &&
1150 (!(typeof outputPath === 'string') ||
1151 outputPath.trim().length === 0 ||
1152 !fsExistsSafe(path.join(outputPath.trim(), 'index.html')))
1153 ) {
1154 return metadataProjectDir
1155 }
1156 if (typeof outputPath === 'string' && outputPath.trim().length > 0) {
1157 return outputPath.trim()
1158 }
1159 if (metadataProjectDir) return metadataProjectDir
1160 const storagePath = await resolveStoragePath().catch(() => '')
1161 return path.join(storagePath || process.cwd(), sessionId)
1162 }
1163
1164 const upsertPatchedGenerationPage = async (
1165 db: DrizzleDb,
1166 data: {
1167 runId: string
1168 sessionId: string
1169 pageId: string
1170 pageNumber: number
1171 title: string
1172 contentOutline?: string | null
1173 layoutIntent?: string | null
1174 htmlPath?: string | null
1175 status: GenerationPageStatus
1176 error?: string | null
1177 retryCount?: number
1178 }
1179 ): Promise<void> => {
1180 const now = Math.floor(Date.now() / 1000)
1181 const id = `${data.runId}:${data.pageId}`
1182 const values = {
1183 id,
1184 runId: data.runId,
1185 sessionId: data.sessionId,
1186 pageId: data.pageId,
1187 pageNumber: data.pageNumber,
1188 title: data.title,
1189 contentOutline: data.contentOutline || null,
1190 layoutIntent: data.layoutIntent || null,
1191 htmlPath: data.htmlPath || null,
1192 status: data.status,
1193 error: data.error || null,
1194 retryCount: Math.max(0, Math.floor(data.retryCount || 0)),
1195 createdAt: now,
1196 updatedAt: now
1197 }
1198 await db
1199 .insert(schema.generationPages)
1200 .values(values)
1201 .onConflictDoUpdate({
1202 target: schema.generationPages.id,
1203 set: {
1204 pageNumber: values.pageNumber,
1205 title: values.title,
1206 contentOutline: values.contentOutline,
1207 layoutIntent: values.layoutIntent,
1208 htmlPath: values.htmlPath,
1209 status: values.status,
1210 error: values.error,
1211 retryCount: values.retryCount,
1212 updatedAt: now
1213 }
1214 })
1215 .run()
1216 }
1217
1218 const patchGenerationRecordsFromMetadata = async (args: {
1219 client: LibSqlClient
1220 db: DrizzleDb
1221 resolveStoragePath: () => Promise<string>
1222 }): Promise<void> => {
1223 const { client, db, resolveStoragePath } = args
1224 const sessions = await client.execute(`
1225 SELECT id, page_count, status, metadata, updated_at
1226 FROM sessions
1227 WHERE metadata IS NOT NULL
1228 AND TRIM(metadata) <> ''
1229 AND NOT EXISTS (
1230 SELECT 1 FROM generation_runs WHERE generation_runs.session_id = sessions.id
1231 )
1232 ORDER BY updated_at DESC
1233 `)
1234
1235 for (const row of sessions.rows || []) {
1236 const sessionId = String(getRowValue(row, 'id') || '')
1237 if (!sessionId) continue
1238 const metadata = parseJsonObject(getRowValue(row, 'metadata'))
1239 const generatedPages = Array.isArray(metadata.generatedPages)
1240 ? metadata.generatedPages.filter(
1241 (page): page is Record<string, unknown> =>
1242 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1243 )
1244 : []
1245 const failedPages = Array.isArray(metadata.failedPages)
1246 ? metadata.failedPages.filter(
1247 (page): page is Record<string, unknown> =>
1248 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1249 )
1250 : []
1251 if (generatedPages.length === 0 && failedPages.length === 0) continue
1252
1253 const projectDir = await resolveLegacyProjectDir(
1254 client,
1255 sessionId,
1256 metadata,
1257 resolveStoragePath
1258 )
1259 const pageMap = new Map<
1260 string,
1261 {
1262 pageId: string
1263 pageNumber: number
1264 title: string
1265 contentOutline: string
1266 layoutIntent: string | null
1267 htmlPath: string
1268 status: GenerationPageStatus
1269 error: string | null
1270 retryCount: number
1271 }
1272 >()
1273
1274 generatedPages.forEach((page, index) => {
1275 const pageNumber = inferPageNumber(page, index + 1)
1276 const rawPageId =
1277 typeof (page.pageId ?? page.page_id) === 'string'
1278 ? String(page.pageId ?? page.page_id).trim()
1279 : ''
1280 const pageId = rawPageId || `page-${pageNumber}`
1281 pageMap.set(pageId, {
1282 pageId,
1283 pageNumber,
1284 title: String(page.title || `第 ${pageNumber} 页`),
1285 contentOutline: String(page.contentOutline ?? page.content_outline ?? ''),
1286 layoutIntent:
1287 typeof (page.layoutIntent ?? page.layout_intent) === 'string'
1288 ? String(page.layoutIntent ?? page.layout_intent)
1289 : null,
1290 htmlPath: resolveLegacyPagePath(page, projectDir, pageId),
1291 status: 'completed',
1292 error: null,
1293 retryCount: toPositiveInt(page.retryCount ?? page.retry_count, 0)
1294 })
1295 })
1296
1297 failedPages.forEach((page, index) => {
1298 const pageNumber = inferPageNumber(page, generatedPages.length + index + 1)
1299 const rawPageId =
1300 typeof (page.pageId ?? page.page_id) === 'string'
1301 ? String(page.pageId ?? page.page_id).trim()
1302 : ''
1303 const pageId = rawPageId || `page-${pageNumber}`
1304 pageMap.set(pageId, {
1305 pageId,
1306 pageNumber,
1307 title: String(page.title || `第 ${pageNumber} 页`),
1308 contentOutline: String(page.contentOutline ?? page.content_outline ?? ''),
1309 layoutIntent:
1310 typeof (page.layoutIntent ?? page.layout_intent) === 'string'
1311 ? String(page.layoutIntent ?? page.layout_intent)
1312 : null,
1313 htmlPath: resolveLegacyPagePath(page, projectDir, pageId),
1314 status: 'failed',
1315 error: String(page.reason || page.error || '旧 metadata 记录的失败页'),
1316 retryCount: toPositiveInt(page.retryCount ?? page.retry_count, 0)
1317 })
1318 })
1319
1320 const pages = Array.from(pageMap.values()).sort((a, b) => a.pageNumber - b.pageNumber)
1321 if (pages.length === 0) continue
1322
1323 const generatedCount = pages.filter((page) => page.status === 'completed').length
1324 const failedCount = pages.filter((page) => page.status === 'failed').length
1325 const totalPages = Math.max(toPositiveInt(getRowValue(row, 'page_count'), 0), pages.length)
1326 const runStatus: GenerationRunStatus =
1327 failedCount > 0 ? (generatedCount > 0 ? 'partial' : 'failed') : 'completed'
1328 const runId = `patch-${sessionId}`
1329 const updatedAt = toPositiveInt(getRowValue(row, 'updated_at'), Math.floor(Date.now() / 1000))
1330
1331 await db
1332 .insert(schema.generationRuns)
1333 .values({
1334 id: runId,
1335 sessionId,
1336 mode: 'generate',
1337 status: runStatus,
1338 totalPages,
1339 error: failedCount > 0 ? `${failedCount} page(s) failed in legacy metadata` : null,
1340 metadata: JSON.stringify({
1341 source: 'metadata_patch',
1342 generatedCount,
1343 failedCount,
1344 patchedAt: new Date().toISOString()
1345 }),
1346 createdAt: updatedAt,
1347 updatedAt: Math.floor(Date.now() / 1000)
1348 })
1349 .onConflictDoNothing()
1350 .run()
1351
1352 for (const page of pages) {
1353 await upsertPatchedGenerationPage(db, {
1354 runId,
1355 sessionId,
1356 pageId: page.pageId,
1357 pageNumber: page.pageNumber,
1358 title: page.title,
1359 contentOutline: page.contentOutline,
1360 layoutIntent: page.layoutIntent,
1361 htmlPath: page.htmlPath,
1362 status: page.status,
1363 error: page.error,
1364 retryCount: page.retryCount
1365 })
1366 }
1367 }
1368 }
1369
1370 const patchSessionPagesFromLegacy = async (args: {
1371 client: LibSqlClient
1372 db: DrizzleDb
1373 resolveStoragePath: () => Promise<string>
1374 }): Promise<void> => {
1375 const { client, db, resolveStoragePath } = args
1376 const sessions = await client.execute(`
1377 SELECT id, metadata, updated_at
1378 FROM sessions
1379 WHERE metadata IS NOT NULL
1380 AND TRIM(metadata) <> ''
1381 AND NOT EXISTS (
1382 SELECT 1 FROM session_pages WHERE session_pages.session_id = sessions.id
1383 )
1384 ORDER BY updated_at DESC
1385 `)
1386
1387 for (const row of sessions.rows || []) {
1388 const sessionId = String(getRowValue(row, 'id') || '')
1389 if (!sessionId) continue
1390 const metadata = parseJsonObject(getRowValue(row, 'metadata'))
1391 const generatedPages = Array.isArray(metadata.generatedPages)
1392 ? metadata.generatedPages.filter(
1393 (page): page is Record<string, unknown> =>
1394 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1395 )
1396 : []
1397 const failedPages = Array.isArray(metadata.failedPages)
1398 ? metadata.failedPages.filter(
1399 (page): page is Record<string, unknown> =>
1400 Boolean(page) && typeof page === 'object' && !Array.isArray(page)
1401 )
1402 : []
1403 if (generatedPages.length === 0 && failedPages.length === 0) continue
1404
1405 const pageMap = new Map<
1406 string,
1407 {
1408 pageNumber: number
1409 fileSlug: string
1410 title: string
1411 htmlPath: string
1412 status: 'completed' | 'failed'
1413 error: string | null
1414 }
1415 >()
1416 const failedById = new Map<string, string>()
1417 for (const failed of failedPages) {
1418 const pageNumber = inferPageNumber(failed, generatedPages.length + failedById.size + 1)
1419 const failedPageId =
1420 typeof (failed.pageId ?? failed.page_id) === 'string'
1421 ? String(failed.pageId ?? failed.page_id).trim()
1422 : ''
1423 const fileSlug = failedPageId || `page-${pageNumber}`
1424 failedById.set(fileSlug, String(failed.reason || failed.error || '页面生成失败'))
1425 pageMap.set(fileSlug, {
1426 pageNumber,
1427 fileSlug,
1428 title: String(failed.title || `第 ${pageNumber} 页`),
1429 htmlPath: '',
1430 status: 'failed',
1431 error: String(failed.reason || failed.error || '页面生成失败')
1432 })
1433 }
1434
1435 const projectDir = await resolveLegacyProjectDir(
1436 client,
1437 sessionId,
1438 metadata,
1439 resolveStoragePath
1440 )
1441 const now = Math.floor(Date.now() / 1000)
1442
1443 for (let index = 0; index < generatedPages.length; index += 1) {
1444 const page = generatedPages[index]
1445 const pageNumber = inferPageNumber(page, index + 1)
1446 const rawPageId =
1447 typeof (page.pageId ?? page.page_id) === 'string'
1448 ? String(page.pageId ?? page.page_id).trim()
1449 : ''
1450 const fileSlug = rawPageId || `page-${pageNumber}`
1451 const htmlPath = resolveLegacyPagePath(page, projectDir, fileSlug)
1452 const title = String(page.title || `第 ${pageNumber} 页`)
1453 const failedReason = failedById.get(fileSlug)
1454 const status = failedReason
1455 ? ('failed' as const)
1456 : fsExistsSafe(htmlPath)
1457 ? ('completed' as const)
1458 : ('failed' as const)
1459 const error = failedReason || (status === 'failed' ? '页面文件不存在' : null)
1460 pageMap.set(fileSlug, {
1461 pageNumber,
1462 fileSlug,
1463 title,
1464 htmlPath,
1465 status,
1466 error
1467 })
1468 }
1469
1470 for (const page of pageMap.values()) {
1471 if (!page.htmlPath) {
1472 page.htmlPath = path.join(projectDir, `${page.fileSlug}.html`)
1473 }
1474 await db
1475 .insert(schema.sessionPages)
1476 .values({
1477 id: nanoid(),
1478 sessionId,
1479 legacyPageId: /^page-\d+$/i.test(page.fileSlug) ? page.fileSlug : null,
1480 fileSlug: page.fileSlug,
1481 pageNumber: page.pageNumber,
1482 title: page.title,
1483 htmlPath: page.htmlPath,
1484 status: page.status,
1485 error: page.error,
1486 createdAt: now,
1487 updatedAt: now,
1488 deletedAt: null
1489 })
1490 .onConflictDoNothing()
1491 .run()
1492 }
1493 }
1494 }
1495
1496 const patchSessionPagesFromGenerationPages = async (args: {
1497 client: LibSqlClient
1498 db: DrizzleDb
1499 resolveStoragePath: () => Promise<string>
1500 }): Promise<void> => {
1501 const { client, db, resolveStoragePath } = args
1502 const rows = await client.execute(`
1503 SELECT
1504 sessions.id AS session_id,
1505 sessions.metadata AS metadata,
1506 sessions.updated_at AS session_updated_at,
1507 generation_pages.page_id AS page_id,
1508 generation_pages.page_number AS page_number,
1509 generation_pages.title AS title,
1510 generation_pages.html_path AS html_path,
1511 generation_pages.status AS status,
1512 generation_pages.error AS error,
1513 generation_pages.created_at AS created_at,
1514 generation_pages.updated_at AS updated_at
1515 FROM sessions
1516 INNER JOIN generation_pages ON generation_pages.session_id = sessions.id
1517 WHERE NOT EXISTS (
1518 SELECT 1 FROM session_pages WHERE session_pages.session_id = sessions.id
1519 )
1520 ORDER BY sessions.updated_at DESC, generation_pages.page_number ASC, generation_pages.updated_at DESC
1521 `)
1522
1523 const bySession = new Map<string, unknown[]>()
1524 for (const row of rows.rows || []) {
1525 const sessionId = String(getRowValue(row, 'session_id') || '')
1526 if (!sessionId) continue
1527 const list = bySession.get(sessionId) || []
1528 list.push(row)
1529 bySession.set(sessionId, list)
1530 }
1531
1532 for (const [sessionId, sessionRows] of bySession.entries()) {
1533 const firstRow = sessionRows[0]
1534 const metadata = parseJsonObject(getRowValue(firstRow, 'metadata'))
1535 const projectDir = await resolveLegacyProjectDir(
1536 client,
1537 sessionId,
1538 metadata,
1539 resolveStoragePath
1540 )
1541 const latestBySlug = new Map<string, Record<string, unknown>>()
1542 for (const row of sessionRows) {
1543 const pageId = String(getRowValue(row, 'page_id') || '').trim()
1544 if (!pageId || latestBySlug.has(pageId)) continue
1545 latestBySlug.set(pageId, row as Record<string, unknown>)
1546 }
1547
1548 const now = Math.floor(Date.now() / 1000)
1549 const pages = Array.from(latestBySlug.values()).sort((a, b) => {
1550 const aNumber = toPositiveInt(getRowValue(a, 'page_number'), 0)
1551 const bNumber = toPositiveInt(getRowValue(b, 'page_number'), 0)
1552 return aNumber - bNumber
1553 })
1554
1555 for (let index = 0; index < pages.length; index += 1) {
1556 const row = pages[index]
1557 const fileSlug = String(getRowValue(row, 'page_id') || `page-${index + 1}`).trim()
1558 const pageNumber = toPositiveInt(getRowValue(row, 'page_number'), index + 1)
1559 const rawHtmlPath = String(getRowValue(row, 'html_path') || '').trim()
1560 const htmlPath = rawHtmlPath
1561 ? path.isAbsolute(rawHtmlPath)
1562 ? rawHtmlPath
1563 : path.join(projectDir, rawHtmlPath)
1564 : path.join(projectDir, `${fileSlug}.html`)
1565 const rawStatus = String(getRowValue(row, 'status') || '').trim()
1566 const status =
1567 rawStatus === 'completed' && fsExistsSafe(htmlPath)
1568 ? ('completed' as const)
1569 : ('failed' as const)
1570 const error =
1571 status === 'failed'
1572 ? String(
1573 getRowValue(row, 'error') ||
1574 (fsExistsSafe(htmlPath) ? '页面生成失败' : '页面文件不存在')
1575 )
1576 : null
1577
1578 await db
1579 .insert(schema.sessionPages)
1580 .values({
1581 id: nanoid(),
1582 sessionId,
1583 legacyPageId: /^page-\d+$/i.test(fileSlug) ? fileSlug : null,
1584 fileSlug,
1585 pageNumber,
1586 title: String(getRowValue(row, 'title') || `第 ${pageNumber} 页`),
1587 htmlPath,
1588 status,
1589 error,
1590 createdAt: toPositiveInt(getRowValue(row, 'created_at'), now),
1591 updatedAt: now,
1592 deletedAt: null
1593 })
1594 .onConflictDoNothing()
1595 .run()
1596 }
1597 }
1598 }
1599
1600 const fsExistsSafe = (filePath: string): boolean => {
1601 try {
1602 return fs.existsSync(filePath)
1603 } catch {
1604 return false
1605 }
1606 }
1607
1608 const HTML_EDITOR_DOCS_TABLE_SQL = `
1609 CREATE TABLE IF NOT EXISTS html_edit_documents (
1610 id TEXT PRIMARY KEY,
1611 title TEXT NOT NULL DEFAULT '',
1612 source_path TEXT,
1613 html_path TEXT NOT NULL,
1614 design_width INTEGER NOT NULL DEFAULT 1280,
1615 created_at INTEGER NOT NULL,
1616 updated_at INTEGER NOT NULL
1617 );
1618 `
1619
1620 const HTML_EDITOR_MESSAGES_TABLE_SQL = `
1621 CREATE TABLE IF NOT EXISTS html_edit_messages (
1622 id TEXT PRIMARY KEY,
1623 doc_id TEXT NOT NULL REFERENCES html_edit_documents(id) ON DELETE CASCADE,
1624 role TEXT NOT NULL,
1625 content TEXT NOT NULL,
1626 intent TEXT,
1627 plan_json TEXT,
1628 requires_confirmation INTEGER NOT NULL DEFAULT 0,
1629 selected_selector TEXT,
1630 selected_label TEXT,
1631 selected_element_tag TEXT,
1632 selected_element_text TEXT,
1633 created_at INTEGER NOT NULL
1634 );
1635 `
1636
1637 const HTML_EDITOR_VERSIONS_TABLE_SQL = `
1638 CREATE TABLE IF NOT EXISTS html_edit_versions (
1639 id TEXT PRIMARY KEY,
1640 doc_id TEXT NOT NULL REFERENCES html_edit_documents(id) ON DELETE CASCADE,
1641 commit_sha TEXT NOT NULL,
1642 message TEXT NOT NULL DEFAULT '',
1643 created_at INTEGER NOT NULL
1644 );
1645 `
1646
1647 const enforceHtmlEditorSchema = async (client: LibSqlClient): Promise<void> => {
1648 await client.execute(HTML_EDITOR_DOCS_TABLE_SQL)
1649 await client.execute(HTML_EDITOR_MESSAGES_TABLE_SQL)
1650 await client.execute(HTML_EDITOR_VERSIONS_TABLE_SQL)
1651 const messageColumns = await getTableColumns(client, 'html_edit_messages')
1652 if (!messageColumns.has('selected_selector')) {
1653 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_selector TEXT')
1654 }
1655 if (!messageColumns.has('selected_label')) {
1656 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_label TEXT')
1657 }
1658 if (!messageColumns.has('selected_element_tag')) {
1659 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_element_tag TEXT')
1660 }
1661 if (!messageColumns.has('selected_element_text')) {
1662 await client.execute('ALTER TABLE html_edit_messages ADD COLUMN selected_element_text TEXT')
1663 }
1664 await client.execute(
1665 'CREATE INDEX IF NOT EXISTS idx_html_edit_messages_doc ON html_edit_messages(doc_id, created_at);'
1666 )
1667 await client.execute(
1668 'CREATE INDEX IF NOT EXISTS idx_html_edit_versions_doc ON html_edit_versions(doc_id, created_at);'
1669 )
1670 }
1671
1672 const enforceImageFulfillmentSchema = async (client: LibSqlClient): Promise<void> => {
1673 await client.execute(`
1674 CREATE TABLE IF NOT EXISTS image_fulfillment_jobs (
1675 id TEXT PRIMARY KEY,
1676 run_id TEXT NOT NULL REFERENCES generation_runs(id) ON DELETE CASCADE,
1677 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
1678 session_page_id TEXT NOT NULL REFERENCES session_pages(id) ON DELETE CASCADE,
1679 page_id TEXT NOT NULL,
1680 layout_id TEXT,
1681 layout_contract_version INTEGER,
1682 image_model_config_id TEXT REFERENCES image_model_configs(id) ON DELETE SET NULL,
1683 image_provider TEXT,
1684 image_model TEXT,
1685 attempt INTEGER NOT NULL,
1686 retry_of_job_id TEXT REFERENCES image_fulfillment_jobs(id) ON DELETE SET NULL,
1687 idempotency_key TEXT,
1688 status TEXT NOT NULL DEFAULT 'pending',
1689 error TEXT,
1690 cancel_requested_at INTEGER,
1691 lease_owner TEXT,
1692 lease_expires_at INTEGER,
1693 finalization_manifest_path TEXT,
1694 created_at INTEGER NOT NULL,
1695 started_at INTEGER,
1696 updated_at INTEGER NOT NULL,
1697 finished_at INTEGER
1698 )
1699 `)
1700 await client.execute(`
1701 CREATE TABLE IF NOT EXISTS image_fulfillment_intents (
1702 id TEXT PRIMARY KEY,
1703 job_id TEXT NOT NULL REFERENCES image_fulfillment_jobs(id) ON DELETE CASCADE,
1704 slot_id TEXT NOT NULL,
1705 layout_slot_id TEXT NOT NULL,
1706 role TEXT NOT NULL,
1707 layer TEXT NOT NULL,
1708 request_version INTEGER NOT NULL DEFAULT 1,
1709 size_hint TEXT,
1710 subject TEXT NOT NULL,
1711 text_zone TEXT,
1712 subject_zone TEXT,
1713 negative_space TEXT,
1714 avoid_json TEXT,
1715 request_json TEXT NOT NULL,
1716 image_history_id TEXT,
1717 asset_path TEXT,
1718 width INTEGER,
1719 height INTEGER,
1720 mime_type TEXT,
1721 attempt INTEGER NOT NULL DEFAULT 1,
1722 retry_of_intent_id TEXT REFERENCES image_fulfillment_intents(id) ON DELETE SET NULL,
1723 status TEXT NOT NULL DEFAULT 'pending',
1724 error TEXT,
1725 created_at INTEGER NOT NULL,
1726 updated_at INTEGER NOT NULL
1727 )
1728 `)
1729 await client.execute(
1730 'CREATE UNIQUE INDEX IF NOT EXISTS image_fulfillment_run_page_attempt_unique ON image_fulfillment_jobs(run_id, session_page_id, attempt)'
1731 )
1732 await client.execute(
1733 'CREATE UNIQUE INDEX IF NOT EXISTS image_fulfillment_idempotency_unique ON image_fulfillment_jobs(session_id, idempotency_key)'
1734 )
1735 await client.execute(
1736 "CREATE UNIQUE INDEX IF NOT EXISTS idx_image_fulfillment_one_active_page ON image_fulfillment_jobs(session_id, session_page_id) WHERE status IN ('pending', 'running', 'finalizing')"
1737 )
1738 await client.execute(
1739 'CREATE INDEX IF NOT EXISTS idx_image_fulfillment_jobs_session_page_status ON image_fulfillment_jobs(session_id, session_page_id, status, updated_at)'
1740 )
1741 await client.execute(
1742 'CREATE UNIQUE INDEX IF NOT EXISTS image_fulfillment_intent_slot_unique ON image_fulfillment_intents(job_id, slot_id)'
1743 )
1744 await client.execute(
1745 'CREATE INDEX IF NOT EXISTS idx_image_fulfillment_intents_job_status ON image_fulfillment_intents(job_id, status, updated_at)'
1746 )
1747 }
1748
1749 export const runDatabasePatches = async (args: {
1750 client: LibSqlClient
1751 db: DrizzleDb
1752 resolveStoragePath: () => Promise<string>
1753 }): Promise<void> => {
1754 const { client, db, resolveStoragePath } = args
1755 await client.executeMultiple(INIT_SQL)
1756 await enforceSessionsSchema(client)
1757 await enforceProjectsSchema(client)
1758 await enforceSettingsSchema(client)
1759 await enforceModelConfigsSchema(client)
1760 await enforceImageModelConfigsSchema(client)
1761 await enforceMessagesSchema(client)
1762 await client.executeMultiple(MODEL_USAGE_TABLE_SQL)
1763 await enforceGenerationSchema(client)
1764 await enforceSessionPagesSchema(client)
1765 await enforceImageFulfillmentSchema(client)
1766 await enforceSessionOperationsSchema(client)
1767 await enforceSessionOperationPagesSchema(client)
1768 await enforceHtmlEditorSchema(client)
1769 await patchStylesColumns(client)
1770 await client.execute('PRAGMA foreign_keys = ON;')
1771 await ensureDefaultSettings(client)
1772 await patchProjectRootPaths({ client, resolveStoragePath })
1773 await patchDesignContractFonts(client)
1774 await patchSourcePageSkeletonAgendaItems(client)
1775 await patchGenerationRecordsFromMetadata({ client, db, resolveStoragePath })
1776 await patchSessionPagesFromLegacy({ client, db, resolveStoragePath })
1777 await patchSessionPagesFromGenerationPages({ client, db, resolveStoragePath })
1778 await patchModelConfigMaxTokens(client)
1779 await patchModelConfigDisableTemperature(client)
1780 await patchModelConfigThinkingParameterMode(client)
1781 }
1782
1782 lines TYPESCRIPT