@@ -9,8 +9,11 @@ import cls from "../../services/cls.js";
99import log from "../../services/log.js" ;
1010import protectedSessionService from "../../services/protected_session.js" ;
1111import blobService from "../../services/blob.js" ;
12+ import blobStorageService from "../../services/blob-storage.js" ;
13+ import type { Blob } from "../../services/blob-interface.js" ;
1214import type { default as Becca , ConstructorData } from "../becca-interface.js" ;
1315import becca from "../becca.js" ;
16+ import type { BlobContentLocation , BlobRow } from "@triliumnext/commons" ;
1417
1518interface ContentOpts {
1619 forceSave ?: boolean ;
@@ -195,6 +198,21 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
195198 return ;
196199 }
197200
201+ try {
202+ const row = sql . getRow < { contentLocation : string } > ( "SELECT contentLocation FROM blobs WHERE blobId = ?" , [ oldBlobId ] ) ;
203+ if ( row ?. contentLocation . startsWith ( 'file://' ) ) {
204+ const filePath = row . contentLocation . replace ( 'file://' , '' ) ;
205+ blobStorageService . deleteExternal ( filePath ) ;
206+ }
207+ } catch ( error ) {
208+ // contentLocation column might not be present when applying older migrations
209+ if ( error instanceof Error && error . name === 'SqliteError' && error . message . includes ( "no such column: contentLocation" ) ) {
210+ // ignore
211+ } else {
212+ log . error ( `Failed to delete external content file for ${ oldBlobId } : ${ error } ` ) ;
213+ }
214+ }
215+
198216 sql . execute ( "DELETE FROM blobs WHERE blobId = ?" , [ oldBlobId ] ) ;
199217 // blobs are not marked as erased in entity_changes, they are just purged completely
200218 // this is because technically every keystroke can create a new blob, and there would be just too many
@@ -225,14 +243,41 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
225243 return newBlobId ;
226244 }
227245
228- const pojo = {
246+ // Check if we should store this blob externally
247+ const shouldStoreExternally = blobStorageService . shouldStoreExternally ( content ) ;
248+ let contentLocation : BlobContentLocation = 'internal' ;
249+ if ( shouldStoreExternally ) {
250+ try {
251+ const filePath = blobStorageService . saveExternal ( newBlobId , content ) ;
252+ contentLocation = `file://${ filePath } ` as BlobContentLocation ;
253+ } catch ( error ) {
254+ log . error ( `Failed to store blob ${ newBlobId } externally, falling back to internal storage: ${ error } ` ) ;
255+ contentLocation = 'internal' ;
256+ }
257+ }
258+
259+ const contentLength = blobService . getContentLength ( content ) ;
260+
261+ const pojo : BlobRow = {
229262 blobId : newBlobId ,
230- content : content ,
263+ content : contentLocation === 'internal' ? content : null ,
264+ contentLocation,
265+ contentLength,
231266 dateModified : dateUtils . localNowDateTime ( ) ,
232267 utcDateModified : dateUtils . utcNowDateTime ( )
233268 } ;
234269
235- sql . upsert ( "blobs" , "blobId" , pojo ) ;
270+ // external content columns might not be present when applying older migrations
271+ const hasExternalContentColumns = sql . getValue ( "SELECT 1 FROM pragma_table_info('blobs') WHERE name = 'contentLocation'" ) ;
272+ const pojoToSave = hasExternalContentColumns
273+ ? pojo
274+ : {
275+ blobId : pojo . blobId ,
276+ content,
277+ dateModified : pojo . dateModified ,
278+ utcDateModified : pojo . utcDateModified
279+ } ;
280+ sql . upsert ( "blobs" , "blobId" , pojoToSave ) ;
236281
237282 // we can't reuse blobId as an entity_changes hash, because this one has to be calculatable without having
238283 // access to the decrypted content
@@ -259,14 +304,26 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
259304 }
260305
261306 protected _getContent ( ) : string | Buffer {
262- const row = sql . getRow < { content : string | Buffer } > ( /*sql*/ `SELECT content FROM blobs WHERE blobId = ?` , [ this . blobId ] ) ;
307+ let row : { content : string | Buffer , contentLocation : string } ;
308+ try {
309+ row = sql . getRow < { content : string | Buffer , contentLocation : string } > ( /*sql*/ `SELECT content, contentLocation FROM blobs WHERE blobId = ?` , [ this . blobId ] ) ;
310+ } catch ( error ) {
311+ // contentLocation column might not be present when applying older migrations
312+ if ( error instanceof Error && error . name === 'SqliteError' && error . message . includes ( "no such column: contentLocation" ) ) {
313+ row = sql . getRow < { content : string | Buffer , contentLocation : string } > ( /*sql*/ `SELECT content, 'internal' as contentLocation FROM blobs WHERE blobId = ?` , [ this . blobId ] ) ;
314+ } else {
315+ throw error ;
316+ }
317+ }
263318
264319 if ( ! row ) {
265320 const constructorData = this . constructor as unknown as ConstructorData < T > ;
266321 throw new Error ( `Cannot find content for ${ constructorData . primaryKeyName } '${ ( this as any ) [ constructorData . primaryKeyName ] } ', blobId '${ this . blobId } '` ) ;
267322 }
268323
269- return blobService . processContent ( row . content , this . isProtected || false , this . hasStringContent ( ) ) ;
324+ const content = blobStorageService . getContent ( row ) ;
325+
326+ return blobService . processContent ( content , this . isProtected || false , this . hasStringContent ( ) ) ;
270327 }
271328
272329 /**
0 commit comments