Coverage for src/local_deep_research/database/library_init.py: 100%

94 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Database initialization for Library - Unified Document Architecture. 

3 

4This module handles: 

5- Seeding source_types table with predefined types 

6- Creating the default "Library" collection 

7- Must be called on app startup for each user 

8""" 

9 

10import threading 

11import uuid 

12from loguru import logger 

13from sqlalchemy.exc import IntegrityError 

14 

15from .models import SourceType, Collection 

16from .session_context import get_user_db_session 

17from ..constants import ( 

18 RESEARCH_HISTORY_COLLECTION_NAME, 

19 RESEARCH_HISTORY_COLLECTION_DESCRIPTION, 

20) 

21 

22 

23# Per-user locks serialise the check-then-insert critical sections below. 

24# Under IMMEDIATE isolation this was unnecessary; under DEFERRED, two 

25# concurrent invocations (e.g. two logins of the same user from two 

26# browser tabs) could both see the absent row and both insert, creating 

27# duplicate default collections. An application-level lock is simpler 

28# than a migration adding a partial UNIQUE constraint, and cheap. 

29# 

30# Entries deliberately retain stable identity for the process lifetime. A 

31# caller receives its lock before acquiring it, so deleting an apparently idle 

32# entry has a lookup-to-acquire race: that caller can later acquire the old lock 

33# while a second caller creates and acquires a replacement. 

34_user_init_locks: dict[str, threading.Lock] = {} 

35_user_init_locks_lock = threading.Lock() 

36 

37 

38def _get_user_init_lock(username: str) -> threading.Lock: 

39 """Get (or lazily create) the per-user lock used to serialise the 

40 check-then-insert idempotent collection initialisers. 

41 """ 

42 with _user_init_locks_lock: 

43 lock = _user_init_locks.get(username) 

44 if lock is None: 

45 lock = threading.Lock() 

46 _user_init_locks[username] = lock 

47 return lock 

48 

49 

50def pop_user_init_lock(username: str) -> None: 

51 """Compatibility no-op: per-user init locks keep stable identity. 

52 

53 User-close cleanup can race both a held lock and a caller that has looked 

54 up the lock but not acquired it yet. Removing the registry entry in either 

55 case permits a later initializer to create a second lock and enter the same 

56 user's check-then-insert critical section concurrently. 

57 

58 Keep one small lock per distinct username for the process lifetime. The 

59 function remains as a no-op for existing user-close cleanup callers. 

60 """ 

61 del username 

62 

63 

64def seed_source_types(username: str, password: str = None) -> None: 

65 """ 

66 Seed the source_types table with predefined document source types. 

67 

68 Args: 

69 username: User to seed types for 

70 password: User's password (optional, uses session context) 

71 """ 

72 predefined_types = [ 

73 { 

74 "name": "research_download", 

75 "display_name": "Research Download", 

76 "description": "Documents downloaded from research sessions (arXiv, PubMed, etc.)", 

77 "icon": "download", 

78 }, 

79 { 

80 "name": "user_upload", 

81 "display_name": "User Upload", 

82 "description": "Documents manually uploaded by the user", 

83 "icon": "upload", 

84 }, 

85 { 

86 "name": "manual_entry", 

87 "display_name": "Manual Entry", 

88 "description": "Documents manually created or entered", 

89 "icon": "edit", 

90 }, 

91 { 

92 "name": "research_report", 

93 "display_name": "Research Report", 

94 "description": "Generated research reports (markdown) for semantic search", 

95 "icon": "file-alt", 

96 }, 

97 { 

98 "name": "research_source", 

99 "display_name": "Research Source", 

100 "description": "Sources discovered during research with content for semantic search", 

101 "icon": "link", 

102 }, 

103 { 

104 "name": "note", 

105 "display_name": "Note", 

106 "description": "User-created notes with AI-enhanced features", 

107 "icon": "sticky-note", 

108 }, 

109 { 

110 "name": "zotero", 

111 "display_name": "Zotero", 

112 "description": "Documents imported from a Zotero library or collection", 

113 "icon": "book", 

114 }, 

115 ] 

116 

117 try: 

118 with get_user_db_session(username, password) as session: 

119 for type_data in predefined_types: 

120 # Check if type already exists 

121 existing = ( 

122 session.query(SourceType) 

123 .filter_by(name=type_data["name"]) 

124 .first() 

125 ) 

126 

127 if not existing: 

128 source_type = SourceType(id=str(uuid.uuid4()), **type_data) 

129 session.add(source_type) 

130 logger.info(f"Created source type: {type_data['name']}") 

131 

132 session.commit() 

133 logger.info("Source types seeded successfully") 

134 

135 except IntegrityError: 

136 logger.warning("Source types may already exist") 

137 except Exception: 

138 logger.warning("Error seeding source types") 

139 raise 

140 

141 

142def ensure_default_library_collection( 

143 username: str, password: str = None 

144) -> str: 

145 """ 

146 Ensure the default "Library" collection exists for a user. 

147 Creates it if it doesn't exist. 

148 

149 Args: 

150 username: User to check/create library for 

151 password: User's password (optional, uses session context) 

152 

153 Returns: 

154 UUID of the Library collection 

155 """ 

156 try: 

157 with ( 

158 _get_user_init_lock(username), 

159 get_user_db_session(username, password) as session, 

160 ): 

161 # Check if default library exists 

162 library = ( 

163 session.query(Collection).filter_by(is_default=True).first() 

164 ) 

165 

166 if library: 

167 logger.debug(f"Default Library collection exists: {library.id}") 

168 return library.id 

169 

170 # Create default Library collection 

171 library_id = str(uuid.uuid4()) 

172 library = Collection( 

173 id=library_id, 

174 name="Library", 

175 description="Default collection for research downloads and documents", 

176 collection_type="default_library", 

177 is_default=True, 

178 ) 

179 session.add(library) 

180 session.commit() 

181 

182 logger.info(f"Created default Library collection: {library_id}") 

183 return library_id 

184 

185 except Exception: 

186 logger.warning("Error ensuring default Library collection") 

187 raise 

188 

189 

190def ensure_research_history_collection( 

191 username: str, password: str = None 

192) -> str: 

193 """ 

194 Ensure the "Research History" collection exists for a user. 

195 This collection is used for semantic search over research reports and sources. 

196 Creates it if it doesn't exist. 

197 

198 Args: 

199 username: User to check/create collection for 

200 password: User's password (optional, uses session context) 

201 

202 Returns: 

203 UUID of the Research History collection 

204 """ 

205 try: 

206 with ( 

207 _get_user_init_lock(username), 

208 get_user_db_session(username, password) as session, 

209 ): 

210 # Check if research history collection exists 

211 collection = ( 

212 session.query(Collection) 

213 .filter_by(collection_type="research_history") 

214 .first() 

215 ) 

216 

217 if collection: 

218 logger.debug( 

219 f"Research History collection exists: {collection.id}" 

220 ) 

221 return collection.id 

222 

223 # Create Research History collection 

224 collection_id = str(uuid.uuid4()) 

225 collection = Collection( 

226 id=collection_id, 

227 name=RESEARCH_HISTORY_COLLECTION_NAME, 

228 description=RESEARCH_HISTORY_COLLECTION_DESCRIPTION, 

229 collection_type="research_history", 

230 is_default=False, 

231 ) 

232 session.add(collection) 

233 session.commit() 

234 

235 logger.info(f"Created Research History collection: {collection_id}") 

236 return collection_id 

237 

238 except Exception: 

239 logger.warning("Error ensuring Research History collection") 

240 raise 

241 

242 

243def initialize_library_for_user(username: str, password: str = None) -> dict: 

244 """ 

245 Complete initialization of library system for a user. 

246 Seeds source types and ensures default Library and Research History collections exist. 

247 

248 Args: 

249 username: User to initialize for 

250 password: User's password (optional, uses session context) 

251 

252 Returns: 

253 Dict with initialization results 

254 """ 

255 results = { 

256 "source_types_seeded": False, 

257 "library_collection_id": None, 

258 "research_history_collection_id": None, 

259 "success": False, 

260 } 

261 

262 try: 

263 # Seed source types 

264 seed_source_types(username, password) 

265 results["source_types_seeded"] = True 

266 

267 # Ensure Library collection 

268 library_id = ensure_default_library_collection(username, password) 

269 results["library_collection_id"] = library_id 

270 

271 # Ensure Research History collection 

272 research_history_id = ensure_research_history_collection( 

273 username, password 

274 ) 

275 results["research_history_collection_id"] = research_history_id 

276 

277 results["success"] = True 

278 logger.info(f"Library initialization complete for user: {username}") 

279 

280 except Exception as e: 

281 logger.warning(f"Library initialization failed for {username}") 

282 results["error"] = str(e) 

283 

284 return results 

285 

286 

287def get_default_library_id(username: str, password: str = None) -> str: 

288 """ 

289 Get the ID of the default Library collection for a user. 

290 Creates it if it doesn't exist. 

291 

292 Args: 

293 username: User to get library for 

294 password: User's password (optional, uses session context) 

295 

296 Returns: 

297 UUID of the Library collection 

298 """ 

299 return ensure_default_library_collection(username, password) 

300 

301 

302def get_source_type_id( 

303 username: str, type_name: str, password: str = None 

304) -> str: 

305 """ 

306 Get the ID of a source type by name. 

307 

308 Args: 

309 username: User to query for 

310 type_name: Name of source type (e.g., 'research_download', 'user_upload') 

311 password: User's password (optional, uses session context) 

312 

313 Returns: 

314 UUID of the source type 

315 

316 Raises: 

317 ValueError: If source type not found 

318 """ 

319 try: 

320 with get_user_db_session(username, password) as session: 

321 source_type = ( 

322 session.query(SourceType).filter_by(name=type_name).first() 

323 ) 

324 

325 if not source_type: 

326 raise ValueError(f"Source type not found: {type_name}") # noqa: TRY301 — inside db session context, except logs and re-raises 

327 

328 return source_type.id 

329 

330 except Exception: 

331 logger.warning("Error getting source type ID") 

332 raise