* Add project versioning & publish flow Introduce project model versioning and publishing support. - DB: add published_model_version column to projects (migration + schema + types). - Models: add version status types and helpers, implement getProjectVersionStatus, enhanced getProjectModel to load draft/published/legacy fallbacks, saveProjectModel now manages draft creation/updating and avoids no-op saves, saveProjectVersion to lock/save/publish versions and create next draft, publishProjectModel to republish specific saved versions. Includes scene-graph equality checks and authenticated project ownership checks. - UI: AppSidebar shows publish/draft status, polling refresh, and Save / Save & publish / Publish actions that flush the editor scene before version operations. Hook update: load scene from result.data.model.scene_graph. - Public project loader: prefer published version with legacy fallbacks. This enables safe draft editing, explicit saves, publishing, and keeps autosave working across version locks. * Add project versioning UI, APIs & preview mode Introduce full version management support: UI, backend actions, and editor integration. - UI: Add a Versions popover to the app sidebar with search, relative timestamps, preview, restore and publish controls; disable version actions while previewing and show proper labels/states. Imported new icons, popover and tooltip primitives and added formatRelativeTime helper. - Actions: Add ProjectVersionListItem type and APIs getProjectVersionList and getProjectVersionByNumber to list and fetch saved (non-draft) versions. - Hooks: Extract applySceneGraphToEditor and sync editor selection logic; ensure scene loading applies selection and supports preview mode (which suppresses autosave). - Store: Add isVersionPreviewMode to project store with setter and isSceneLoading flag usage to prevent autosave while previewing. - Misc: Wire up version list loading, previewing, restoring and publishing flows, and refresh version status/list after actions. These changes enable browsing historical project versions, previewing them without triggering autosaves, and restoring or publishing selected versions. * Improve version preview, restore flow & draft handling - Add scene graph snapshot to preserve unsaved work during preview - Include draft versions and metadata in version list - Add getProjectVersionById for direct ID-based lookups - Add autosave status to project store - Refactor sidebar version UI (remove search, use save icon) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1001 lines
26 KiB
TypeScript
1001 lines
26 KiB
TypeScript
/**
|
|
* Project model actions - Server actions for scene loading/saving
|
|
* Manages 3D models (scene graphs) stored in projects_models table
|
|
*/
|
|
|
|
'use server'
|
|
|
|
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
|
import { createServerSupabaseClient } from '../database/server'
|
|
import { getSession } from '../auth/server'
|
|
import { createId } from '../utils/id-generator'
|
|
import type { ActionResult } from '../projects/actions'
|
|
import { isSceneGraphEmpty } from './scene-graph-utils'
|
|
|
|
export interface SceneGraph {
|
|
nodes: Record<AnyNodeId, AnyNode>
|
|
rootNodeIds: AnyNodeId[]
|
|
}
|
|
|
|
export interface ProjectModel {
|
|
id: string
|
|
name: string
|
|
version: number
|
|
draft: boolean
|
|
project_id: string
|
|
scene_graph: SceneGraph | null
|
|
metadata: Record<string, unknown> | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface ProjectVersionStatus {
|
|
publishedVersion: number | null
|
|
draftVersion: number | null
|
|
latestSavedVersion: number | null
|
|
hasUnsavedDraftChanges: boolean
|
|
hasPublishableVersion: boolean
|
|
}
|
|
|
|
export interface ProjectModelState extends ProjectVersionStatus {
|
|
model: ProjectModel | null
|
|
}
|
|
|
|
export interface ProjectVersionListItem {
|
|
id: string
|
|
version: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
isPublished: boolean
|
|
isDraft: boolean
|
|
restoredFromVersion: number | null
|
|
}
|
|
|
|
type ProjectVersionListRow = {
|
|
id: string
|
|
version: number
|
|
draft: boolean
|
|
metadata: unknown
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface ProjectOwnershipRow {
|
|
id: string
|
|
owner_id: string
|
|
name: string
|
|
published_model_version: number | null
|
|
}
|
|
|
|
type AuthenticatedProjectContext = {
|
|
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>
|
|
project: ProjectOwnershipRow
|
|
}
|
|
|
|
function sceneGraphsEqual(
|
|
left: SceneGraph | null | undefined,
|
|
right: SceneGraph | null | undefined,
|
|
): boolean {
|
|
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
|
}
|
|
|
|
function parseModelMetadata(input: unknown): Record<string, unknown> {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
return {}
|
|
}
|
|
|
|
return { ...(input as Record<string, unknown>) }
|
|
}
|
|
|
|
function readRestoredFromVersion(input: unknown): number | null {
|
|
const metadata = parseModelMetadata(input)
|
|
const restoredFromVersion = metadata.restoredFromVersion
|
|
if (typeof restoredFromVersion !== 'number' || !Number.isFinite(restoredFromVersion)) {
|
|
return null
|
|
}
|
|
|
|
return restoredFromVersion
|
|
}
|
|
|
|
function buildVersionStatus(params: {
|
|
publishedVersion: number | null
|
|
draftModel: ProjectModel | null
|
|
latestSavedModel: ProjectModel | null
|
|
}): ProjectVersionStatus {
|
|
const publishedVersion = params.publishedVersion
|
|
const draftVersion = params.draftModel?.version ?? null
|
|
const latestSavedVersion = params.latestSavedModel?.version ?? null
|
|
|
|
const hasUnsavedDraftChanges = params.draftModel
|
|
? params.latestSavedModel
|
|
? !sceneGraphsEqual(params.draftModel.scene_graph, params.latestSavedModel.scene_graph)
|
|
: true
|
|
: false
|
|
|
|
const hasPublishableVersion =
|
|
latestSavedVersion !== null && latestSavedVersion !== publishedVersion
|
|
|
|
return {
|
|
publishedVersion,
|
|
draftVersion: draftVersion,
|
|
latestSavedVersion,
|
|
hasUnsavedDraftChanges,
|
|
hasPublishableVersion,
|
|
}
|
|
}
|
|
|
|
async function getProjectVersionModels(
|
|
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>,
|
|
projectId: string,
|
|
): Promise<
|
|
ActionResult<{
|
|
draftModel: ProjectModel | null
|
|
latestSavedModel: ProjectModel | null
|
|
}>
|
|
> {
|
|
const { data: draftModel, error: draftModelError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('draft', true)
|
|
.is('deleted_at', null)
|
|
.order('version', { ascending: false })
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (draftModelError) {
|
|
return {
|
|
success: false,
|
|
error: draftModelError.message,
|
|
}
|
|
}
|
|
|
|
const { data: latestSavedModel, error: latestSavedModelError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('draft', false)
|
|
.is('deleted_at', null)
|
|
.order('version', { ascending: false })
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (latestSavedModelError) {
|
|
return {
|
|
success: false,
|
|
error: latestSavedModelError.message,
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
draftModel: draftModel ?? null,
|
|
latestSavedModel: latestSavedModel ?? null,
|
|
},
|
|
}
|
|
}
|
|
|
|
async function getAuthenticatedProjectContext(
|
|
projectId: string,
|
|
): Promise<ActionResult<AuthenticatedProjectContext>> {
|
|
const session = await getSession()
|
|
|
|
if (!session?.user) {
|
|
return {
|
|
success: false,
|
|
error: 'Not authenticated',
|
|
}
|
|
}
|
|
|
|
const supabase = await createServerSupabaseClient()
|
|
const { data: project, error: projectError } = await supabase
|
|
.from('projects')
|
|
.select('id, owner_id, name, published_model_version')
|
|
.eq('id', projectId)
|
|
.single<ProjectOwnershipRow>()
|
|
|
|
if (projectError || !project) {
|
|
return {
|
|
success: false,
|
|
error: 'Project not found',
|
|
}
|
|
}
|
|
|
|
if (project.owner_id !== session.user.id) {
|
|
return {
|
|
success: false,
|
|
error: 'Unauthorized',
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
supabase,
|
|
project,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns publish/draft status for the current project.
|
|
*/
|
|
export async function getProjectVersionStatus(
|
|
projectId: string,
|
|
): Promise<ActionResult<ProjectVersionStatus>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
}
|
|
}
|
|
|
|
const { supabase, project } = contextResult.data
|
|
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
|
if (!versionModelsResult.success || !versionModelsResult.data) {
|
|
return {
|
|
success: false,
|
|
error: versionModelsResult.error,
|
|
}
|
|
}
|
|
|
|
const { draftModel, latestSavedModel } = versionModelsResult.data
|
|
|
|
return {
|
|
success: true,
|
|
data: buildVersionStatus({
|
|
publishedVersion: project.published_model_version ?? null,
|
|
draftModel,
|
|
latestSavedModel,
|
|
}),
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch project version status',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all project versions (including current draft), newest first.
|
|
*/
|
|
export async function getProjectVersionList(
|
|
projectId: string,
|
|
): Promise<ActionResult<ProjectVersionListItem[]>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
data: [],
|
|
}
|
|
}
|
|
|
|
const { supabase, project } = contextResult.data
|
|
const { data: versions, error: versionsError } = await supabase
|
|
.from('projects_models')
|
|
.select('id, version, draft, metadata, created_at, updated_at')
|
|
.eq('project_id', projectId)
|
|
.is('deleted_at', null)
|
|
.order('version', { ascending: false })
|
|
.order('created_at', { ascending: false })
|
|
.returns<ProjectVersionListRow[]>()
|
|
|
|
if (versionsError) {
|
|
return {
|
|
success: false,
|
|
error: versionsError.message,
|
|
data: [],
|
|
}
|
|
}
|
|
|
|
const publishedVersion = project.published_model_version ?? null
|
|
return {
|
|
success: true,
|
|
data: (versions ?? []).map((item) => ({
|
|
id: item.id,
|
|
version: item.version,
|
|
createdAt: item.created_at,
|
|
updatedAt: item.updated_at,
|
|
isPublished: publishedVersion !== null && item.version === publishedVersion,
|
|
isDraft: item.draft,
|
|
restoredFromVersion: readRestoredFromVersion(item.metadata),
|
|
})),
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch project version list',
|
|
data: [],
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a single version by version number (saved or draft).
|
|
*/
|
|
export async function getProjectVersionByNumber(
|
|
projectId: string,
|
|
version: number,
|
|
): Promise<ActionResult<ProjectModel | null>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
data: null,
|
|
}
|
|
}
|
|
|
|
const { supabase } = contextResult.data
|
|
const { data: model, error: modelError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('version', version)
|
|
.is('deleted_at', null)
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (modelError) {
|
|
return {
|
|
success: false,
|
|
error: modelError.message,
|
|
data: null,
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: model ?? null,
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch project version',
|
|
data: null,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a single version by model id.
|
|
*/
|
|
export async function getProjectVersionById(
|
|
projectId: string,
|
|
modelId: string,
|
|
): Promise<ActionResult<ProjectModel | null>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
data: null,
|
|
}
|
|
}
|
|
|
|
const { supabase } = contextResult.data
|
|
const { data: model, error: modelError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('id', modelId)
|
|
.is('deleted_at', null)
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (modelError) {
|
|
return {
|
|
success: false,
|
|
error: modelError.message,
|
|
data: null,
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: model ?? null,
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch project version',
|
|
data: null,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the editor model for a project:
|
|
* - Draft if one exists
|
|
* - Otherwise the published version
|
|
* - Otherwise latest available model (legacy fallback)
|
|
*/
|
|
export async function getProjectModel(projectId: string): Promise<ActionResult<ProjectModelState>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
}
|
|
}
|
|
|
|
const { supabase, project } = contextResult.data
|
|
const publishedVersion = project.published_model_version ?? null
|
|
|
|
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
|
if (!versionModelsResult.success || !versionModelsResult.data) {
|
|
return {
|
|
success: false,
|
|
error: versionModelsResult.error,
|
|
}
|
|
}
|
|
|
|
const { draftModel, latestSavedModel } = versionModelsResult.data
|
|
let modelToLoad = draftModel ?? null
|
|
|
|
if (!modelToLoad && publishedVersion !== null) {
|
|
const { data: publishedModel, error: publishedModelError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('version', publishedVersion)
|
|
.eq('draft', false)
|
|
.is('deleted_at', null)
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (publishedModelError) {
|
|
return {
|
|
success: false,
|
|
error: publishedModelError.message,
|
|
}
|
|
}
|
|
|
|
modelToLoad = publishedModel ?? null
|
|
}
|
|
|
|
if (!modelToLoad && latestSavedModel) {
|
|
modelToLoad = latestSavedModel
|
|
}
|
|
|
|
if (!modelToLoad) {
|
|
const { data: latestModel, error: latestModelError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.is('deleted_at', null)
|
|
.order('version', { ascending: false })
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (latestModelError) {
|
|
return {
|
|
success: false,
|
|
error: latestModelError.message,
|
|
}
|
|
}
|
|
|
|
modelToLoad = latestModel ?? null
|
|
}
|
|
|
|
const status = buildVersionStatus({
|
|
publishedVersion,
|
|
draftModel,
|
|
latestSavedModel,
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
model: modelToLoad,
|
|
...status,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch project model',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save or update the project's draft model scene graph.
|
|
*/
|
|
export interface SaveProjectModelOptions {
|
|
restoredFromVersion?: number | null
|
|
}
|
|
|
|
export async function saveProjectModel(
|
|
projectId: string,
|
|
sceneGraph: SceneGraph,
|
|
options?: SaveProjectModelOptions,
|
|
): Promise<ActionResult<ProjectModelState>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
}
|
|
}
|
|
|
|
const { supabase, project } = contextResult.data
|
|
|
|
// Determine if scene graph is empty
|
|
const isEmpty = isSceneGraphEmpty(sceneGraph)
|
|
|
|
// Update the project's is_empty flag
|
|
await (supabase.from('projects') as any)
|
|
.update({ is_empty: isEmpty })
|
|
.eq('id', projectId)
|
|
|
|
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
|
if (!versionModelsResult.success || !versionModelsResult.data) {
|
|
return {
|
|
success: false,
|
|
error: versionModelsResult.error,
|
|
}
|
|
}
|
|
|
|
const { draftModel: existingDraftModel, latestSavedModel } = versionModelsResult.data
|
|
let savedModel: ProjectModel | null = null
|
|
const restoredFromVersionOption = options?.restoredFromVersion
|
|
const metadataOverride =
|
|
restoredFromVersionOption === undefined
|
|
? undefined
|
|
: (() => {
|
|
const metadata = parseModelMetadata(existingDraftModel?.metadata ?? null)
|
|
|
|
if (typeof restoredFromVersionOption === 'number') {
|
|
metadata.restoredFromVersion = restoredFromVersionOption
|
|
} else {
|
|
delete metadata.restoredFromVersion
|
|
}
|
|
|
|
return Object.keys(metadata).length > 0 ? metadata : null
|
|
})()
|
|
|
|
if (existingDraftModel) {
|
|
const updateData: Record<string, unknown> = {
|
|
scene_graph: sceneGraph,
|
|
updated_at: new Date().toISOString(),
|
|
}
|
|
if (metadataOverride !== undefined) {
|
|
updateData.metadata = metadataOverride
|
|
}
|
|
const { data: updatedModel, error: updateError } = (await (supabase
|
|
.from('projects_models') as any)
|
|
.update(updateData)
|
|
.eq('id', existingDraftModel.id)
|
|
.select()
|
|
.single()) as { data: ProjectModel | null; error: any }
|
|
|
|
if (updateError) {
|
|
return {
|
|
success: false,
|
|
error: updateError.message,
|
|
}
|
|
}
|
|
|
|
savedModel = updatedModel as ProjectModel
|
|
} else {
|
|
const baselineModel = latestSavedModel
|
|
|
|
if (baselineModel && sceneGraphsEqual(baselineModel.scene_graph, sceneGraph)) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
model: baselineModel,
|
|
...buildVersionStatus({
|
|
publishedVersion: project.published_model_version ?? null,
|
|
draftModel: null,
|
|
latestSavedModel: baselineModel,
|
|
}),
|
|
},
|
|
message: 'No draft changes to save',
|
|
}
|
|
}
|
|
|
|
const { data: latestModel, error: latestModelError } = await supabase
|
|
.from('projects_models')
|
|
.select('version')
|
|
.eq('project_id', projectId)
|
|
.is('deleted_at', null)
|
|
.order('version', { ascending: false })
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle<{ version: number }>()
|
|
|
|
if (latestModelError) {
|
|
return {
|
|
success: false,
|
|
error: latestModelError.message,
|
|
}
|
|
}
|
|
|
|
const nextVersion = (latestModel?.version ?? 0) + 1
|
|
const modelId = createId('model')
|
|
|
|
const insertData = {
|
|
id: modelId,
|
|
project_id: projectId,
|
|
name: `${project.name} - Draft v${nextVersion}`,
|
|
version: nextVersion,
|
|
draft: true,
|
|
scene_graph: sceneGraph,
|
|
...(metadataOverride !== undefined ? { metadata: metadataOverride } : {}),
|
|
}
|
|
const { data: newModel, error: createError } = (await (supabase
|
|
.from('projects_models') as any)
|
|
.insert(insertData)
|
|
.select()
|
|
.single()) as { data: ProjectModel | null; error: any }
|
|
|
|
if (createError) {
|
|
return {
|
|
success: false,
|
|
error: createError.message,
|
|
}
|
|
}
|
|
|
|
savedModel = newModel as ProjectModel
|
|
}
|
|
|
|
if (!savedModel) {
|
|
return {
|
|
success: false,
|
|
error: 'Failed to save project model',
|
|
}
|
|
}
|
|
|
|
const status = buildVersionStatus({
|
|
publishedVersion: project.published_model_version ?? null,
|
|
draftModel: savedModel,
|
|
latestSavedModel,
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
model: savedModel,
|
|
...status,
|
|
},
|
|
message: existingDraftModel
|
|
? 'Draft model updated successfully'
|
|
: 'Draft model created successfully',
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to save project model',
|
|
}
|
|
}
|
|
}
|
|
|
|
async function createNextDraftVersion(
|
|
supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>,
|
|
params: {
|
|
projectId: string
|
|
projectName: string
|
|
sceneGraph: SceneGraph | null
|
|
},
|
|
): Promise<ActionResult<ProjectModel>> {
|
|
const { data: latestModel, error: latestModelError } = await supabase
|
|
.from('projects_models')
|
|
.select('version')
|
|
.eq('project_id', params.projectId)
|
|
.is('deleted_at', null)
|
|
.order('version', { ascending: false })
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle<{ version: number }>()
|
|
|
|
if (latestModelError) {
|
|
return {
|
|
success: false,
|
|
error: latestModelError.message,
|
|
}
|
|
}
|
|
|
|
const nextVersion = (latestModel?.version ?? 0) + 1
|
|
const modelId = createId('model')
|
|
const insertData = {
|
|
id: modelId,
|
|
project_id: params.projectId,
|
|
name: `${params.projectName} - Draft v${nextVersion}`,
|
|
version: nextVersion,
|
|
draft: true,
|
|
scene_graph: params.sceneGraph,
|
|
}
|
|
|
|
const { data: newDraftModel, error: createError } = (await (supabase
|
|
.from('projects_models') as any)
|
|
.insert(insertData)
|
|
.select()
|
|
.single()) as { data: ProjectModel | null; error: any }
|
|
|
|
if (createError || !newDraftModel) {
|
|
return {
|
|
success: false,
|
|
error: createError?.message ?? 'Failed to create next draft version',
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: newDraftModel as ProjectModel,
|
|
}
|
|
}
|
|
|
|
export interface SaveProjectVersionOptions {
|
|
publish?: boolean
|
|
}
|
|
|
|
/**
|
|
* Save the current draft into a locked version, and optionally publish it.
|
|
*
|
|
* Behavior:
|
|
* - Save only: lock draft as a saved version, then create the next draft.
|
|
* - Save + publish: lock draft, publish it, then create the next draft.
|
|
* - Publish when already saved: publish latest saved version directly.
|
|
*/
|
|
export async function saveProjectVersion(
|
|
projectId: string,
|
|
options?: SaveProjectVersionOptions,
|
|
): Promise<ActionResult<ProjectVersionStatus>> {
|
|
try {
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
}
|
|
}
|
|
|
|
const { supabase, project } = contextResult.data
|
|
const shouldPublish = options?.publish ?? false
|
|
let publishedVersion = project.published_model_version ?? null
|
|
|
|
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
|
if (!versionModelsResult.success || !versionModelsResult.data) {
|
|
return {
|
|
success: false,
|
|
error: versionModelsResult.error,
|
|
}
|
|
}
|
|
|
|
let { draftModel, latestSavedModel } = versionModelsResult.data
|
|
let didSaveVersion = false
|
|
let didPublishVersion = false
|
|
|
|
if (draftModel) {
|
|
const draftDiffersFromSaved = latestSavedModel
|
|
? !sceneGraphsEqual(draftModel.scene_graph, latestSavedModel.scene_graph)
|
|
: true
|
|
|
|
if (draftDiffersFromSaved) {
|
|
const { data: lockedModel, error: lockDraftError } = (await (supabase
|
|
.from('projects_models') as any)
|
|
.update({
|
|
draft: false,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', draftModel.id)
|
|
.select()
|
|
.single()) as { data: ProjectModel | null; error: any }
|
|
|
|
if (lockDraftError || !lockedModel) {
|
|
return {
|
|
success: false,
|
|
error: lockDraftError?.message ?? 'Failed to lock draft version',
|
|
}
|
|
}
|
|
|
|
latestSavedModel = lockedModel as ProjectModel
|
|
draftModel = null
|
|
didSaveVersion = true
|
|
}
|
|
}
|
|
|
|
if (shouldPublish) {
|
|
if (!latestSavedModel) {
|
|
return {
|
|
success: false,
|
|
error: 'No saved version available to publish',
|
|
}
|
|
}
|
|
|
|
if (publishedVersion !== latestSavedModel.version) {
|
|
const { error: updateProjectError } = await (supabase
|
|
.from('projects') as any)
|
|
.update({
|
|
published_model_version: latestSavedModel.version,
|
|
})
|
|
.eq('id', projectId)
|
|
|
|
if (updateProjectError) {
|
|
return {
|
|
success: false,
|
|
error: updateProjectError.message,
|
|
}
|
|
}
|
|
|
|
publishedVersion = latestSavedModel.version
|
|
didPublishVersion = true
|
|
}
|
|
}
|
|
|
|
// Keep autosave flowing onto a fresh draft whenever we lock/publish a version.
|
|
if ((didSaveVersion || didPublishVersion) && !draftModel && latestSavedModel) {
|
|
const nextDraftResult = await createNextDraftVersion(supabase, {
|
|
projectId,
|
|
projectName: project.name,
|
|
sceneGraph: latestSavedModel.scene_graph,
|
|
})
|
|
|
|
if (!nextDraftResult.success || !nextDraftResult.data) {
|
|
return {
|
|
success: false,
|
|
error: nextDraftResult.error,
|
|
}
|
|
}
|
|
|
|
draftModel = nextDraftResult.data
|
|
}
|
|
|
|
const status = buildVersionStatus({
|
|
publishedVersion,
|
|
draftModel,
|
|
latestSavedModel,
|
|
})
|
|
|
|
let message = 'No version changes'
|
|
if (didSaveVersion && didPublishVersion && latestSavedModel) {
|
|
message = `Saved and published v${latestSavedModel.version}`
|
|
} else if (didSaveVersion && latestSavedModel) {
|
|
message = `Saved version v${latestSavedModel.version}`
|
|
} else if (didPublishVersion && latestSavedModel) {
|
|
message = `Published version v${latestSavedModel.version}`
|
|
} else if (shouldPublish && latestSavedModel && publishedVersion === latestSavedModel.version) {
|
|
message = `Version v${latestSavedModel.version} is already published`
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: status,
|
|
message,
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to save project version',
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface PublishProjectModelOptions {
|
|
version?: number
|
|
}
|
|
|
|
/**
|
|
* Publish a project model version to the community.
|
|
*
|
|
* - If `options.version` is provided, republish that saved version.
|
|
* - Otherwise publish using saveProjectVersion(publish=true) behavior.
|
|
*/
|
|
export async function publishProjectModel(
|
|
projectId: string,
|
|
options?: PublishProjectModelOptions,
|
|
): Promise<ActionResult<ProjectVersionStatus>> {
|
|
try {
|
|
if (typeof options?.version !== 'number') {
|
|
return await saveProjectVersion(projectId, { publish: true })
|
|
}
|
|
|
|
const contextResult = await getAuthenticatedProjectContext(projectId)
|
|
if (!contextResult.success || !contextResult.data) {
|
|
return {
|
|
success: false,
|
|
error: contextResult.error,
|
|
}
|
|
}
|
|
|
|
const { supabase, project } = contextResult.data
|
|
const { data: targetVersionModel, error: targetVersionError } = await supabase
|
|
.from('projects_models')
|
|
.select('*')
|
|
.eq('project_id', projectId)
|
|
.eq('version', options.version)
|
|
.eq('draft', false)
|
|
.is('deleted_at', null)
|
|
.limit(1)
|
|
.maybeSingle<ProjectModel>()
|
|
|
|
if (targetVersionError) {
|
|
return {
|
|
success: false,
|
|
error: targetVersionError.message,
|
|
}
|
|
}
|
|
|
|
if (!targetVersionModel) {
|
|
return {
|
|
success: false,
|
|
error: `Version ${options.version} is not a saved version`,
|
|
}
|
|
}
|
|
|
|
const { error: updateProjectError } = await (supabase
|
|
.from('projects') as any)
|
|
.update({
|
|
published_model_version: targetVersionModel.version,
|
|
})
|
|
.eq('id', projectId)
|
|
|
|
if (updateProjectError) {
|
|
return {
|
|
success: false,
|
|
error: updateProjectError.message,
|
|
}
|
|
}
|
|
|
|
const versionModelsResult = await getProjectVersionModels(supabase, projectId)
|
|
if (!versionModelsResult.success || !versionModelsResult.data) {
|
|
return {
|
|
success: false,
|
|
error: versionModelsResult.error,
|
|
}
|
|
}
|
|
|
|
let { draftModel, latestSavedModel } = versionModelsResult.data
|
|
if (!latestSavedModel || latestSavedModel.version < targetVersionModel.version) {
|
|
latestSavedModel = targetVersionModel
|
|
}
|
|
|
|
if (!draftModel) {
|
|
const nextDraftResult = await createNextDraftVersion(supabase, {
|
|
projectId,
|
|
projectName: project.name,
|
|
sceneGraph: targetVersionModel.scene_graph,
|
|
})
|
|
|
|
if (!nextDraftResult.success || !nextDraftResult.data) {
|
|
return {
|
|
success: false,
|
|
error: nextDraftResult.error,
|
|
}
|
|
}
|
|
|
|
draftModel = nextDraftResult.data
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: buildVersionStatus({
|
|
publishedVersion: targetVersionModel.version,
|
|
draftModel,
|
|
latestSavedModel,
|
|
}),
|
|
message: `Published version v${targetVersionModel.version}`,
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Failed to publish project model',
|
|
}
|
|
}
|
|
}
|