1010 * nothing migrates into one.
1111 */
1212
13- import { and , desc , eq , isNull } from "drizzle-orm" ;
13+ import { and , desc , eq , isNull , sql } from "drizzle-orm" ;
1414import {
1515 DBModel ,
1616 ModelChangeEvent ,
@@ -24,12 +24,21 @@ import { Thread } from "./thread.js";
2424
2525/** The bucket documents land in when no project is active. */
2626export const LOOSE_PROJECT_ID = "default" ;
27+ export const PERSONAL_PROJECT_KIND = "personal" ;
28+ export const PERSONAL_PROJECT_NAME = "Personal" ;
29+
30+ export interface PersonalMigrationReport {
31+ project : Project ;
32+ migrated : number ;
33+ dangling : number ;
34+ }
2735
2836export interface ProjectResponse {
2937 id : string ;
3038 name : string ;
3139 /** Free text — "spot", "trailer", "report". Not an enum on purpose. */
3240 kind : string ;
41+ isPersonal : boolean ;
3342 /** The conversation that builds it, or null while nobody has asked for one. */
3443 threadId : string | null ;
3544 createdAt : string ;
@@ -67,6 +76,7 @@ export class Project extends DBModel {
6776 id : this . id ,
6877 name : this . name ,
6978 kind : this . kind ,
79+ isPersonal : this . kind === PERSONAL_PROJECT_KIND ,
7080 threadId : this . thread_id ,
7181 createdAt : this . created_at ,
7282 updatedAt : this . updated_at
@@ -77,6 +87,110 @@ export class Project extends DBModel {
7787 return Project . get < Project > ( id ) ;
7888 }
7989
90+ static async ensurePersonal ( userId : string ) : Promise < Project > {
91+ const db = getDb ( ) ;
92+ const existing = await db
93+ . select ( )
94+ . from ( projects )
95+ . where (
96+ and ( eq ( projects . user_id , userId ) , eq ( projects . kind , PERSONAL_PROJECT_KIND ) )
97+ )
98+ . orderBy ( projects . created_at )
99+ . limit ( 1 ) ;
100+ if ( existing [ 0 ] ) return new Project ( existing [ 0 ] ) ;
101+
102+ const created = await Project . insertNew ( {
103+ id : `personal:${ userId } ` ,
104+ user_id : userId ,
105+ name : PERSONAL_PROJECT_NAME ,
106+ kind : PERSONAL_PROJECT_KIND
107+ } ) ;
108+ if ( created ) return created ;
109+
110+ const resolved = await db
111+ . select ( )
112+ . from ( projects )
113+ . where (
114+ and ( eq ( projects . user_id , userId ) , eq ( projects . kind , PERSONAL_PROJECT_KIND ) )
115+ )
116+ . orderBy ( projects . created_at )
117+ . limit ( 1 ) ;
118+ if ( ! resolved [ 0 ] ) throw new Error ( "Unable to resolve Personal project" ) ;
119+ return new Project ( resolved [ 0 ] ) ;
120+ }
121+
122+ /** Claim only loose legacy rows. Explicit project ids are never rewritten. */
123+ static async migrateToPersonal ( userId : string ) : Promise < PersonalMigrationReport > {
124+ const personal = await Project . ensurePersonal ( userId ) ;
125+ const db = getDb ( ) ;
126+ const owner = userId . replace ( / ' / g, "''" ) ;
127+ const target = personal . id . replace ( / ' / g, "''" ) ;
128+ let migrated = 0 ;
129+ // Restore the legacy project.thread_id association before claiming
130+ // remaining threads for Personal.
131+ await db . execute (
132+ sql . raw (
133+ `UPDATE nodetool_threads SET project_id = (` +
134+ `SELECT p.id FROM projects p WHERE p.thread_id = nodetool_threads.id ` +
135+ `AND p.user_id = nodetool_threads.user_id) ` +
136+ `WHERE user_id = '${ owner } ' AND EXISTS (` +
137+ `SELECT 1 FROM projects p WHERE p.thread_id = nodetool_threads.id ` +
138+ `AND p.user_id = nodetool_threads.user_id)`
139+ )
140+ ) ;
141+ // Runs created from an already-assigned workflow inherit that ownership.
142+ // Jobs without a project column were otherwise indistinguishable from
143+ // genuinely unassigned runs.
144+ await db . execute (
145+ sql . raw (
146+ `UPDATE nodetool_jobs SET project_id = (` +
147+ `SELECT w.project_id FROM nodetool_workflows w ` +
148+ `WHERE w.id = nodetool_jobs.workflow_id AND w.user_id = nodetool_jobs.user_id) ` +
149+ `WHERE user_id = '${ owner } ' AND (project_id IS NULL OR project_id = '' ` +
150+ `OR project_id = 'default') AND EXISTS (` +
151+ `SELECT 1 FROM nodetool_workflows w WHERE w.id = nodetool_jobs.workflow_id ` +
152+ `AND w.user_id = nodetool_jobs.user_id AND w.project_id <> 'default')`
153+ )
154+ ) ;
155+ const tables = [
156+ "storyboards" , "scripts" , "timeline_sequences" , "image_documents" ,
157+ "applications" , "js_scripts" , "nodetool_assets" , "nodetool_workflows" ,
158+ "nodetool_threads" , "nodetool_jobs" , "nodetool_workspaces" ,
159+ "nodetool_predictions"
160+ ] ;
161+ for ( const table of tables ) {
162+ const result = await db . execute (
163+ sql . raw (
164+ `UPDATE ${ table } SET project_id = '${ target } ' ` +
165+ `WHERE user_id = '${ owner } ' AND ` +
166+ `(project_id IS NULL OR project_id = '' OR project_id = 'default')`
167+ )
168+ ) ;
169+ const changes = ( result as { changes ?: unknown } ) . changes ;
170+ if ( typeof changes === "number" ) migrated += changes ;
171+ }
172+ // Dangling non-default ids are intentionally left in place. They need a
173+ // repair decision, and moving them would hide a broken legacy reference.
174+ let dangling = 0 ;
175+ for ( const table of tables ) {
176+ const result = await db . execute (
177+ sql . raw (
178+ `SELECT COUNT(*) AS count FROM ${ table } r ` +
179+ `WHERE r.user_id = '${ owner } ' AND r.project_id IS NOT NULL ` +
180+ `AND r.project_id <> 'default' AND r.project_id <> '${ target } ' ` +
181+ `AND NOT EXISTS (SELECT 1 FROM projects p ` +
182+ `WHERE p.id = r.project_id AND p.user_id = r.user_id)`
183+ )
184+ ) ;
185+ const rows = Array . isArray ( result )
186+ ? result
187+ : ( ( result as { rows ?: unknown [ ] } ) . rows ?? [ ] ) ;
188+ const count = ( rows [ 0 ] as { count ?: unknown } | undefined ) ?. count ;
189+ dangling += Number ( count ?? 0 ) ;
190+ }
191+ return { project : personal , migrated, dangling } ;
192+ }
193+
80194 static async findOwned ( userId : string , id : string ) : Promise < Project | null > {
81195 const row = await Project . findById ( id ) ;
82196 return row && row . user_id === userId ? row : null ;
@@ -158,6 +272,7 @@ export class Project extends DBModel {
158272 static async deleteOwned ( userId : string , id : string ) : Promise < boolean > {
159273 const row = await Project . findOwned ( userId , id ) ;
160274 if ( ! row ) return false ;
275+ if ( row . kind === PERSONAL_PROJECT_KIND ) return false ;
161276 await reassignProjectDocuments ( userId , id , LOOSE_PROJECT_ID ) ;
162277 await row . delete ( ) ;
163278 return true ;
@@ -182,7 +297,8 @@ export class Project extends DBModel {
182297
183298 const thread = await Thread . create < Thread > ( {
184299 user_id : userId ,
185- title : project . name
300+ title : project . name ,
301+ project_id : project . id
186302 } ) ;
187303 const db = getDb ( ) ;
188304 const rows = await db
0 commit comments