@@ -9,8 +9,11 @@ import cls from "../../services/cls.js";
9
9
import log from "../../services/log.js" ;
10
10
import protectedSessionService from "../../services/protected_session.js" ;
11
11
import blobService from "../../services/blob.js" ;
12
+ import blobStorageService from "../../services/blob-storage.js" ;
13
+ import type { Blob } from "../../services/blob-interface.js" ;
12
14
import type { default as Becca , ConstructorData } from "../becca-interface.js" ;
13
15
import becca from "../becca.js" ;
16
+ import type { BlobContentLocation , BlobRow } from "@triliumnext/commons" ;
14
17
15
18
interface ContentOpts {
16
19
forceSave ?: boolean ;
@@ -195,6 +198,21 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
195
198
return ;
196
199
}
197
200
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
+
198
216
sql . execute ( "DELETE FROM blobs WHERE blobId = ?" , [ oldBlobId ] ) ;
199
217
// blobs are not marked as erased in entity_changes, they are just purged completely
200
218
// 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>> {
225
243
return newBlobId ;
226
244
}
227
245
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 = {
229
262
blobId : newBlobId ,
230
- content : content ,
263
+ content : contentLocation === 'internal' ? content : null ,
264
+ contentLocation,
265
+ contentLength,
231
266
dateModified : dateUtils . localNowDateTime ( ) ,
232
267
utcDateModified : dateUtils . utcNowDateTime ( )
233
268
} ;
234
269
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 ) ;
236
281
237
282
// we can't reuse blobId as an entity_changes hash, because this one has to be calculatable without having
238
283
// access to the decrypted content
@@ -259,14 +304,26 @@ abstract class AbstractBeccaEntity<T extends AbstractBeccaEntity<T>> {
259
304
}
260
305
261
306
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
+ }
263
318
264
319
if ( ! row ) {
265
320
const constructorData = this . constructor as unknown as ConstructorData < T > ;
266
321
throw new Error ( `Cannot find content for ${ constructorData . primaryKeyName } '${ ( this as any ) [ constructorData . primaryKeyName ] } ', blobId '${ this . blobId } '` ) ;
267
322
}
268
323
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 ( ) ) ;
270
327
}
271
328
272
329
/**
0 commit comments