2025-06-30 08:41:22 +02:00
import { BlobElementEntity , ElementEntity , Entity , ListElementEntity , ServerModelParsedInstance , SomeEntity , TypeModel } from "../../common/EntityTypes.js"
2025-06-03 10:59:13 +02:00
import {
CUSTOM_MIN_ID ,
customIdToBase64Url ,
elementIdPart ,
ensureBase64Ext ,
firstBiggerThanSecond ,
GENERATED_MIN_ID ,
getElementId ,
isCustomIdType ,
listIdPart ,
} from "../../common/utils/EntityUtils.js"
import type { CacheStorage , LastUpdateTime } from "../rest/DefaultEntityRestCache.js"
2022-01-12 14:43:01 +01:00
import * as cborg from "cborg"
2022-12-27 15:37:40 +01:00
import { EncodeOptions , Token , Type } from "cborg"
2023-08-16 10:47:09 +02:00
import {
assert ,
assertNotNull ,
2025-06-30 08:41:22 +02:00
Base64Ext ,
2025-03-13 16:37:55 +01:00
getFirstOrThrow ,
2025-05-06 11:25:24 +02:00
getTypeString ,
2025-03-13 16:37:55 +01:00
groupBy ,
groupByAndMap ,
isEmpty ,
2023-08-16 10:47:09 +02:00
mapNullable ,
2025-09-29 14:18:34 +02:00
Nullable ,
2025-03-13 16:37:55 +01:00
parseTypeString ,
2023-08-16 10:47:09 +02:00
splitInChunks ,
2025-05-28 12:21:25 +02:00
typedEntries ,
typedValues ,
2023-08-16 10:47:09 +02:00
TypeRef ,
} from "@tutao/tutanota-utils"
2022-12-27 15:37:40 +01:00
import { isDesktop , isOfflineStorageAvailable , isTest } from "../../common/Env.js"
import { DateProvider } from "../../common/DateProvider.js"
2024-01-08 17:14:09 +01:00
import { TokenOrNestedTokens } from "cborg/interface"
2022-12-27 15:37:40 +01:00
import { OfflineStorageMigrator } from "./OfflineStorageMigrator.js"
2025-03-13 16:37:55 +01:00
import { CustomCacheHandlerMap } from "../rest/cacheHandler/CustomCacheHandler.js"
2022-12-27 15:37:40 +01:00
import { InterWindowEventFacadeSendDispatcher } from "../../../native/common/generatedipc/InterWindowEventFacadeSendDispatcher.js"
import { SqlCipherFacade } from "../../../native/common/generatedipc/SqlCipherFacade.js"
2025-06-30 08:41:22 +02:00
import { FormattedQuery , SqlValue , TaggedSqlValue , tagSqlValue , untagSqlObject , untagSqlValue } from "./SqlValue.js"
2025-06-03 10:59:13 +02:00
import { Type as TypeId } from "../../common/EntityConstants.js"
2023-01-04 10:54:28 +01:00
import { OutOfSyncError } from "../../common/error/OutOfSyncError.js"
2024-03-11 16:25:43 +01:00
import { sql , SqlFragment } from "./Sql.js"
2025-03-10 16:19:11 +01:00
import { ModelMapper } from "../crypto/ModelMapper"
2025-05-06 11:25:24 +02:00
import { AttributeModel } from "../../common/AttributeModel"
2025-05-16 16:03:24 +02:00
import { TypeModelResolver } from "../../common/EntityFunctions"
2025-06-03 10:59:13 +02:00
import { collapseId , expandId } from "../rest/RestClientIdUtils"
2025-07-02 10:07:53 +02:00
import { Category , syncMetrics } from "../utils/SyncMetrics"
2022-01-12 14:43:01 +01:00
2023-08-16 10:47:09 +02:00
/ * *
* this is the value of SQLITE_MAX_VARIABLE_NUMBER in sqlite3 . c
* it may change if the sqlite version is updated .
* * /
const MAX_SAFE_SQL_VARS = 32766
2025-06-30 08:41:22 +02:00
type StorableInstance = {
typeString : string
table : string
rowId : Nullable < string >
listId : Nullable < Id >
elementId : Id
encodedElementId : Base64Ext
ownerGroup : Id
serializedInstance : Uint8Array
instance : ServerModelParsedInstance
}
const tableNameByTypeId : Map < string , string > = new Map ( [
[ TypeId . Element , "element_entities" ] ,
[ TypeId . ListElement , "list_entities" ] ,
[ TypeId . BlobElement , "blob_element_entities" ] ,
] )
2022-01-12 14:43:01 +01:00
function dateEncoder ( data : Date , typ : string , options : EncodeOptions ) : TokenOrNestedTokens | null {
2022-10-13 13:29:00 +02:00
const time = data . getTime ( )
2022-01-12 14:43:01 +01:00
return [
// https://datatracker.ietf.org/doc/rfc8943/
new Token ( Type . tag , 100 ) ,
2022-12-27 15:37:40 +01:00
new Token ( time < 0 ? Type.negint : Type.uint , time ) ,
2022-01-12 14:43:01 +01:00
]
}
function dateDecoder ( bytes : number ) : Date {
return new Date ( bytes )
}
2022-12-27 15:37:40 +01:00
export const customTypeEncoders : { [ typeName : string ] : typeof dateEncoder } = Object . freeze ( {
Date : dateEncoder ,
2022-01-12 14:43:01 +01:00
} )
type TypeDecoder = ( _ : any ) = > any
export const customTypeDecoders : Array < TypeDecoder > = ( ( ) = > {
const tags : Array < TypeDecoder > = [ ]
tags [ 100 ] = dateDecoder
return tags
} ) ( )
2025-05-06 11:25:24 +02:00
export interface OfflineDbMeta {
2022-12-27 15:37:40 +01:00
lastUpdateTime : number
2022-04-20 10:39:52 +02:00
timeRangeDays : number
2025-05-06 11:25:24 +02:00
// offline db schema version
"offline-version" : number
2025-10-14 12:32:17 +02:00
lastTrainedTime : number
2025-10-14 12:11:22 +02:00
lastTrainedFromScratchTime : number
2022-04-20 10:39:52 +02:00
}
2025-02-10 13:15:28 +01:00
export const TableDefinitions = Object . freeze ( {
2022-10-21 15:53:39 +02:00
// plus ownerGroup added in a migration
2025-06-16 11:45:49 +02:00
list_entities : {
definition :
"CREATE TABLE IF NOT EXISTS list_entities (type TEXT NOT NULL, listId TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, listId, elementId))" ,
purgedWithCache : true ,
} ,
2022-10-21 15:53:39 +02:00
// plus ownerGroup added in a migration
2025-06-16 11:45:49 +02:00
element_entities : {
definition :
"CREATE TABLE IF NOT EXISTS element_entities (type TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, elementId))" ,
purgedWithCache : true ,
} ,
ranges : {
definition :
"CREATE TABLE IF NOT EXISTS ranges (type TEXT NOT NULL, listId TEXT NOT NULL, lower TEXT NOT NULL, upper TEXT NOT NULL, PRIMARY KEY (type, listId))" ,
purgedWithCache : true ,
} ,
lastUpdateBatchIdPerGroupId : {
definition : "CREATE TABLE IF NOT EXISTS lastUpdateBatchIdPerGroupId (groupId TEXT NOT NULL, batchId TEXT NOT NULL, PRIMARY KEY (groupId))" ,
purgedWithCache : true ,
} ,
metadata : {
definition : "CREATE TABLE IF NOT EXISTS metadata (key TEXT NOT NULL, value BLOB, PRIMARY KEY (key))" ,
purgedWithCache : false ,
onBeforePurged : async ( sqlCipherFacade : SqlCipherFacade ) = > {
if ( await tableExists ( sqlCipherFacade , "metadata" ) ) {
await sqlCipherFacade . run ( "DELETE FROM metadata WHERE key = 'lastUpdateTime'" , [ ] )
}
} ,
} ,
blob_element_entities : {
definition :
"CREATE TABLE IF NOT EXISTS blob_element_entities (type TEXT NOT NULL, listId TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, listId, elementId))" ,
purgedWithCache : true ,
} ,
} as const ) satisfies Record < string , OfflineStorageTable >
2022-08-11 16:38:53 +02:00
2024-08-23 13:00:37 +02:00
type Range = { lower : Id ; upper : Id }
2022-08-11 16:38:53 +02:00
2022-10-21 15:53:39 +02:00
export interface OfflineStorageInitArgs {
2022-12-27 15:37:40 +01:00
userId : Id
databaseKey : Uint8Array
2025-03-18 17:27:59 +01:00
timeRangeDate : Date | null
2022-10-21 15:53:39 +02:00
forceNewDatabase : boolean
}
2025-05-28 12:21:25 +02:00
/ * *
* Describes an externally - defined table to be stored in offline storage .
*
* Table definitions should be passed into the additionalTables record of OfflineStorage ' s constructor , setting the key
* to the name of the table ( as written in the { @link definition } statement ) .
* /
export interface OfflineStorageTable {
/ * *
* Initialization statement for the table .
*
* This will always be run even if the table exists , thus it should be a statement that won ' t error if run multiple
* times ( e . g . the "CREATE TABLE" command should use the "IF NOT EXISTS" clause ) .
* /
definition : string
/ * *
* Set this to true if the table should be dropped whenever the cache is dropped .
*
* It is recommended to only set this to true if the contents of the table are dependent on the cache being in sync .
*
* If true , then the table is dropped whenever purgeStorage is called , such as due to an out - of - sync error
*
* If false , this will only be deleted if the offline database is completely deleted , such as when credentials are
* deleted .
* /
purgedWithCache : boolean
2025-06-16 11:45:49 +02:00
/ * *
* Action to perform before the table is dropped
*
* Could also be used to perform an action * * instead * * of dropping the table when { @link purgedWithStorage } is false
* /
onBeforePurged ? : ( sqlCipherFacade : SqlCipherFacade ) = > Promise < void >
2025-05-28 12:21:25 +02:00
}
2025-05-06 11:25:24 +02:00
export class OfflineStorage implements CacheStorage {
2022-10-21 15:53:39 +02:00
private userId : Id | null = null
2025-05-06 11:25:24 +02:00
private databaseKey : Uint8Array | null = null
2025-03-18 17:27:59 +01:00
private timeRangeDate : Date | null = null
2025-06-16 11:45:49 +02:00
private readonly allTables : Record < string , OfflineStorageTable >
2022-02-10 16:32:47 +01:00
2022-01-12 14:43:01 +01:00
constructor (
2022-08-11 16:38:53 +02:00
private readonly sqlCipherFacade : SqlCipherFacade ,
2022-07-20 15:28:38 +02:00
private readonly interWindowEventSender : InterWindowEventFacadeSendDispatcher ,
2022-04-20 10:39:52 +02:00
private readonly dateProvider : DateProvider ,
2022-05-17 17:40:44 +02:00
private readonly migrator : OfflineStorageMigrator ,
2024-08-15 16:11:44 +02:00
private readonly cleaner : OfflineStorageCleaner ,
2025-03-10 16:19:11 +01:00
private readonly modelMapper : ModelMapper ,
2025-05-16 16:03:24 +02:00
private readonly typeModelResolver : TypeModelResolver ,
2025-03-13 16:37:55 +01:00
private readonly customCacheHandler : CustomCacheHandlerMap ,
2025-06-16 11:45:49 +02:00
additionalTables : Record < string , OfflineStorageTable > ,
2022-01-12 14:43:01 +01:00
) {
2022-02-10 16:32:47 +01:00
assert ( isOfflineStorageAvailable ( ) || isTest ( ) , "Offline storage is not available." )
2025-06-16 11:45:49 +02:00
this . allTables = Object . freeze ( Object . assign ( { } , additionalTables , TableDefinitions ) )
2022-02-10 16:32:47 +01:00
}
2025-05-06 11:25:24 +02:00
async getWholeListParsed ( typeRef : TypeRef < unknown > , listId : string ) : Promise < ServerModelParsedInstance [ ] > {
const { query , params } = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { getTypeString ( typeRef ) }
AND listId = $ { listId } `
2025-05-06 11:25:24 +02:00
const items = ( await this . sqlCipherFacade . all ( query , params ) ) ? ? [ ]
const instanceBytes = items . map ( ( row ) = > row . entity . value as Uint8Array )
return await this . deserializeList ( instanceBytes )
}
async get < T extends Entity > ( typeRef : TypeRef < T > , listId : string | null , id : string ) : Promise < T | null > {
const parsedInstance = await this . getParsed ( typeRef , listId , id )
if ( parsedInstance == null ) {
return null
}
return await this . modelMapper . mapToInstance < T > ( typeRef , parsedInstance )
}
async provideMultiple < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : string , elementIds : string [ ] ) : Promise < T [ ] > {
const parsedInstances = await this . provideMultipleParsed ( typeRef , listId , elementIds )
return await this . modelMapper . mapToInstances ( typeRef , parsedInstances )
}
2022-05-17 17:40:44 +02:00
/ * *
* @return { boolean } whether the database was newly created or not
* /
2025-03-18 17:27:59 +01:00
async init ( { userId , databaseKey , timeRangeDate , forceNewDatabase } : OfflineStorageInitArgs ) : Promise < boolean > {
2022-10-21 15:53:39 +02:00
this . userId = userId
2025-05-06 11:25:24 +02:00
this . databaseKey = databaseKey
2025-03-18 17:27:59 +01:00
this . timeRangeDate = timeRangeDate
2022-07-20 15:28:38 +02:00
if ( forceNewDatabase ) {
2022-08-11 16:38:53 +02:00
if ( isDesktop ( ) ) {
await this . interWindowEventSender . localUserDataInvalidated ( userId )
}
await this . sqlCipherFacade . deleteDb ( userId )
2022-07-20 15:28:38 +02:00
}
2022-11-28 17:38:17 +01:00
// We open database here, and it is closed in the native side when the window is closed or the page is reloaded
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . openDb ( userId , databaseKey )
await this . createTables ( )
2024-02-01 13:54:19 +01:00
2023-01-04 10:54:28 +01:00
try {
await this . migrator . migrate ( this , this . sqlCipherFacade )
} catch ( e ) {
2023-01-04 16:54:58 +01:00
if ( e instanceof OutOfSyncError ) {
2023-02-02 14:17:02 +01:00
console . warn ( "Offline db is out of sync!" , e )
2023-01-04 16:54:58 +01:00
await this . recreateDbFile ( userId , databaseKey )
2023-02-02 14:17:02 +01:00
await this . migrator . migrate ( this , this . sqlCipherFacade )
2023-01-04 16:54:58 +01:00
} else {
2023-01-04 10:54:28 +01:00
throw e
}
}
2025-06-16 11:45:49 +02:00
// We createTables again in case they were purged in a migration
await this . createTables ( )
2022-05-17 17:40:44 +02:00
// if nothing is written here, it means it's a new database
2024-01-22 10:42:11 +01:00
return ( await this . getLastUpdateTime ( ) ) . type === "never"
2023-01-04 10:54:28 +01:00
}
2022-11-28 17:38:17 +01:00
2023-01-04 10:54:28 +01:00
private async recreateDbFile ( userId : string , databaseKey : Uint8Array ) : Promise < void > {
console . log ( ` recreating DB file for userId ${ userId } ` )
await this . sqlCipherFacade . closeDb ( )
await this . sqlCipherFacade . deleteDb ( userId )
await this . sqlCipherFacade . openDb ( userId , databaseKey )
await this . createTables ( )
2022-02-10 16:32:47 +01:00
}
2022-08-15 14:22:44 +02:00
/ * *
* currently , we close DBs from the native side ( mainly on things like reload and on android ' s onDestroy )
* /
2022-07-20 15:28:38 +02:00
async deinit() {
2022-10-21 15:53:39 +02:00
this . userId = null
2025-05-06 11:25:24 +02:00
this . databaseKey = null
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . closeDb ( )
2022-07-20 15:28:38 +02:00
}
2025-03-13 16:37:55 +01:00
async deleteIfExists < T extends SomeEntity > (
typeRef : TypeRef < T > ,
listId : T extends ListElementEntity | BlobElementEntity ? Id : null ,
elementId : Id ,
) : Promise < void > {
const fullId : T [ "_id" ] = listId == null ? elementId : [ listId , elementId ]
await this . deleteByIds ( typeRef , [ fullId ] )
2022-01-12 14:43:01 +01:00
}
2023-02-02 17:21:34 +01:00
async deleteAllOfType ( typeRef : TypeRef < SomeEntity > ) : Promise < void > {
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2025-03-13 16:37:55 +01:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2023-08-15 16:43:50 +02:00
let formattedQuery
2023-02-02 17:21:34 +01:00
switch ( typeModel . type ) {
case TypeId . Element :
2025-03-13 16:37:55 +01:00
formattedQuery = sql ` SELECT elementId
2025-10-14 12:11:22 +02:00
FROM element_entities
WHERE type = $ { type } `
2023-02-02 17:21:34 +01:00
break
case TypeId . ListElement :
2025-03-13 16:37:55 +01:00
formattedQuery = sql ` SELECT listId, elementId
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { type } `
2023-03-29 14:46:04 +02:00
await this . deleteAllRangesForType ( type )
2025-03-13 16:37:55 +01:00
break
2023-02-02 17:21:34 +01:00
case TypeId . BlobElement :
2025-03-13 16:37:55 +01:00
formattedQuery = sql ` SELECT listId, elementId
2025-10-14 12:11:22 +02:00
FROM blob_element_entities
WHERE type = $ { type } `
2023-02-02 17:21:34 +01:00
break
default :
throw new Error ( "must be a persistent type" )
}
2025-03-13 16:37:55 +01:00
const taggedRows = await this . sqlCipherFacade . all ( formattedQuery . query , formattedQuery . params )
const rows = taggedRows . map ( untagSqlObject ) as { listId? : Id ; elementId : Id } [ ]
const ids = rows . map ( ( row ) = > collapseId ( row . listId ? ? null , customIdToBase64Url ( typeModel , row . elementId ) ) )
await this . deleteByIds ( typeRef , ids )
2023-02-02 17:21:34 +01:00
}
2025-02-25 10:48:12 +01:00
/ * *
* Remove all ranges ( and only ranges , without associated data ) for the specified { @param typeRef } .
* /
async deleteAllRangesOfType ( typeRef : TypeRef < SomeEntity > ) : Promise < void > {
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2025-02-25 10:48:12 +01:00
await this . deleteAllRangesForType ( type )
}
2023-03-29 14:46:04 +02:00
private async deleteAllRangesForType ( type : string ) : Promise < void > {
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM ranges
WHERE type = $ { type } `
2023-03-29 14:46:04 +02:00
await this . sqlCipherFacade . run ( query , params )
}
2025-05-06 11:25:24 +02:00
async getParsed ( typeRef : TypeRef < unknown > , listId : Id | null , id : Id ) : Promise < ServerModelParsedInstance | null > {
2025-07-02 10:07:53 +02:00
const tm = syncMetrics ? . beginMeasurement ( Category . GetDb )
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2025-05-06 11:25:24 +02:00
const encodedElementId = ensureBase64Ext ( typeModel , id )
2023-08-15 16:43:50 +02:00
let formattedQuery
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
switch ( typeModel . type ) {
2023-01-12 16:48:28 +01:00
case TypeId . Element :
2024-12-17 15:46:05 +01:00
formattedQuery = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
from element_entities
WHERE type = $ { type }
AND elementId = $ { encodedElementId } `
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
break
2023-01-12 16:48:28 +01:00
case TypeId . ListElement :
2024-12-17 15:46:05 +01:00
formattedQuery = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
from list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND elementId = $ { encodedElementId } `
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
break
2023-01-12 16:48:28 +01:00
case TypeId . BlobElement :
2024-12-17 15:46:05 +01:00
formattedQuery = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
from blob_element_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND elementId = $ { encodedElementId } `
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
break
default :
throw new Error ( "must be a persistent type" )
2022-08-11 16:38:53 +02:00
}
2025-07-02 10:07:53 +02:00
const dbResult = await this . sqlCipherFacade . get ( formattedQuery . query , formattedQuery . params )
const result = dbResult ? . entity ? await this . deserialize ( dbResult . entity . value as Uint8Array ) : null
tm ? . endMeasurement ( )
return result
2022-01-12 14:43:01 +01:00
}
2025-05-06 11:25:24 +02:00
async provideMultipleParsed < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , elementIds : Id [ ] ) : Promise < Array < ServerModelParsedInstance > > {
2025-07-02 10:07:53 +02:00
const tm = syncMetrics ? . beginMeasurement ( Category . ProvideMultipleDb )
2024-08-07 08:38:58 +02:00
if ( elementIds . length === 0 ) return [ ]
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2025-02-05 13:00:51 +01:00
const encodedElementIds = elementIds . map ( ( elementId ) = > ensureBase64Ext ( typeModel , elementId ) )
2024-08-23 13:00:37 +02:00
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2025-06-30 08:41:22 +02:00
const serializedList : ReadonlyArray < Record < string , TaggedSqlValue > > = await this . allChunked ( 1000 , encodedElementIds , ( c ) = > {
if ( typeModel . type === TypeId . Element ) {
return sql ` SELECT entity
2025-10-14 12:11:22 +02:00
FROM element_entities
WHERE type = $ { type }
AND elementId IN $ { paramList ( c ) } `
2025-06-30 08:41:22 +02:00
} else if ( typeModel . type === TypeId . ListElement ) {
return sql ` SELECT entity
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND elementId IN $ { paramList ( c ) } `
2025-06-30 08:41:22 +02:00
} else if ( typeModel . type === TypeId . BlobElement ) {
return sql ` SELECT entity
2025-10-14 12:11:22 +02:00
FROM blob_element_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND elementId IN $ { paramList ( c ) } `
2025-06-30 08:41:22 +02:00
} else {
throw new Error ( ` can't provideMultipleParsed for ${ JSON . stringify ( typeRef ) } ` )
}
} )
2025-07-02 10:07:53 +02:00
const result = await this . deserializeList ( serializedList . map ( ( r ) = > r . entity . value as Uint8Array ) )
tm ? . endMeasurement ( )
return result
2024-08-07 08:38:58 +02:00
}
2022-01-12 14:43:01 +01:00
async getIdsInRange < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id ) : Promise < Array < Id > > {
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2024-08-23 13:00:37 +02:00
const range = await this . getRange ( typeRef , listId )
2022-08-11 16:38:53 +02:00
if ( range == null ) {
throw new Error ( ` no range exists for ${ type } and list ${ listId } ` )
}
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` SELECT elementId
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND ( elementId = $ { range . lower }
OR $ { firstIdBigger ( "elementId" , range . lower ) } )
AND NOT ( $ { firstIdBigger ( "elementId" , range . upper ) } ) `
2022-08-11 16:38:53 +02:00
const rows = await this . sqlCipherFacade . all ( query , params )
2024-08-28 17:20:32 +02:00
return rows . map ( ( row ) = > customIdToBase64Url ( typeModel , row . elementId . value as string ) )
2022-01-12 14:43:01 +01:00
}
2024-08-23 13:00:37 +02:00
/ * * d o n ' t u s e t h i s i n t e r n a l l y i n t h i s c l a s s , u s e O f f l i n e S t o r a g e : : g e t R a n g e i n s t e a d . O f f l i n e S t o r a g e i s
* using converted custom IDs internally which is undone when using this to access the range .
* /
2022-08-11 16:38:53 +02:00
async getRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id ) : Promise < Range | null > {
2024-08-23 13:00:37 +02:00
let range = await this . getRange ( typeRef , listId )
if ( range == null ) return range
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2024-08-23 13:00:37 +02:00
return {
lower : customIdToBase64Url ( typeModel , range . lower ) ,
upper : customIdToBase64Url ( typeModel , range . upper ) ,
}
2022-01-12 14:43:01 +01:00
}
2024-08-23 13:00:37 +02:00
async isElementIdInCacheRange < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , elementId : Id ) : Promise < boolean > {
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2025-02-05 13:00:51 +01:00
const encodedElementId = ensureBase64Ext ( typeModel , elementId )
2024-08-23 13:00:37 +02:00
const range = await this . getRange ( typeRef , listId )
2025-02-05 13:00:51 +01:00
return range != null && ! firstBiggerThanSecond ( encodedElementId , range . upper ) && ! firstBiggerThanSecond ( range . lower , encodedElementId )
2022-01-12 14:43:01 +01:00
}
2025-05-06 11:25:24 +02:00
async provideFromRangeParsed < T extends ListElementEntity > (
typeRef : TypeRef < T > ,
listId : Id ,
start : Id ,
count : number ,
reverse : boolean ,
) : Promise < ServerModelParsedInstance [ ] > {
2025-07-02 10:07:53 +02:00
const tm = syncMetrics ? . beginMeasurement ( Category . ProvideRangeDb )
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2025-02-05 13:00:51 +01:00
const encodedStartId = ensureBase64Ext ( typeModel , start )
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2023-08-15 16:43:50 +02:00
let formattedQuery
2022-08-11 16:38:53 +02:00
if ( reverse ) {
2024-12-17 15:46:05 +01:00
formattedQuery = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND $ { firstIdBigger ( encodedStartId , "elementId" ) }
ORDER BY LENGTH ( elementId ) DESC , elementId DESC LIMIT $ { count } `
2022-08-11 16:38:53 +02:00
} else {
2024-12-17 15:46:05 +01:00
formattedQuery = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND $ { firstIdBigger ( "elementId" , encodedStartId ) }
ORDER BY LENGTH ( elementId ) ASC , elementId ASC LIMIT $ { count } `
2022-08-11 16:38:53 +02:00
}
2023-08-15 16:43:50 +02:00
const { query , params } = formattedQuery
2022-08-11 16:38:53 +02:00
const serializedList : ReadonlyArray < Record < string , TaggedSqlValue > > = await this . sqlCipherFacade . all ( query , params )
2025-07-02 10:07:53 +02:00
const result = await this . deserializeList ( serializedList . map ( ( r ) = > r . entity . value as Uint8Array ) )
tm ? . endMeasurement ( )
return result
2022-01-12 14:43:01 +01:00
}
2025-05-06 11:25:24 +02:00
async provideFromRange < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , start : Id , count : number , reverse : boolean ) : Promise < Array < T > > {
const parsed = await this . provideFromRangeParsed ( typeRef , listId , start , count , reverse )
return await this . modelMapper . mapToInstances ( typeRef , parsed )
}
2025-03-13 16:37:55 +01:00
async put ( typeRef : TypeRef < SomeEntity > , instance : ServerModelParsedInstance ) : Promise < void > {
2025-07-02 10:07:53 +02:00
const tm = syncMetrics ? . beginMeasurement ( Category . PutDb )
await this . putMultiple ( typeRef , [ instance ] )
tm ? . endMeasurement ( )
2025-06-30 08:41:22 +02:00
}
async putMultiple ( typeRef : TypeRef < SomeEntity > , instances : ServerModelParsedInstance [ ] ) : Promise < void > {
2025-07-02 10:07:53 +02:00
const tm = instances . length > 1 ? syncMetrics ? . beginMeasurement ( Category . PutMultipleDb ) : null
2025-03-13 16:37:55 +01:00
const handler = this . getCustomCacheHandlerMap ( ) . get ( typeRef )
2025-06-30 08:41:22 +02:00
const typeModel = await this . typeModelResolver . resolveServerTypeReference ( typeRef )
const typeString = getTypeString ( typeRef )
if ( typeModel . type === TypeId . Aggregated || typeModel . type === TypeId . DataTransfer ) {
throw new Error ( "must be a persistent type" )
2025-03-13 16:37:55 +01:00
}
2025-06-30 08:41:22 +02:00
const type = typeModel . type
const table = assertNotNull ( tableNameByTypeId . get ( type ) )
const storables = await this . toStorables ( instances , typeModel , typeString , table )
2025-05-06 11:25:24 +02:00
2025-06-30 08:41:22 +02:00
const groupedByListId = groupBy ( storables , ( dbRef ) = > dbRef . listId )
for ( const [ listId , storableInstances ] of groupedByListId ) {
await this . fetchRowIds ( typeModel , table , typeString , listId , storableInstances )
}
2025-03-13 16:37:55 +01:00
2025-06-30 08:41:22 +02:00
for ( const [ listId , storableInstances ] of groupedByListId ) {
for ( const storable of storableInstances ) {
if ( handler ? . onBeforeCacheUpdate ) {
const typedInstance = await this . modelMapper . mapToInstance ( typeRef , storable . instance )
await handler . onBeforeCacheUpdate ( typedInstance as SomeEntity )
}
}
const chunks = splitInChunks ( 1000 , storableInstances ) // respect MAX_SAFE_SQL_VARS
for ( const chunk of chunks ) {
let formattedQuery : FormattedQuery
// Note that we have to also select and re-insert the rowid or else it will not match search index.
//
// A null rowid (i.e. not found) is fine if this is an insertion.
if ( typeModel . type === TypeId . Element ) {
const nestedlistOfParams : Array < Array < SqlValue > > = chunk . map ( ( storable ) = > {
const { rowId , typeString , encodedElementId , ownerGroup , serializedInstance } = storable
2025-07-15 16:16:06 +02:00
return [ rowId , typeString , encodedElementId , ownerGroup , serializedInstance ] satisfies Array < SqlValue >
2025-06-30 08:41:22 +02:00
} )
formattedQuery = this . insertMultipleFormattedQuery (
"INSERT OR REPLACE INTO element_entities (rowid, type, elementId, ownerGroup, entity) VALUES " ,
nestedlistOfParams ,
)
} else if ( typeModel . type === TypeId . ListElement ) {
const nestedlistOfParams : Array < Array < SqlValue > > = chunk . map ( ( storable ) = > {
const { rowId , typeString , encodedElementId , ownerGroup , serializedInstance } = storable
return [ rowId , typeString , listId , encodedElementId , ownerGroup , serializedInstance ] as Array < SqlValue >
} )
formattedQuery = this . insertMultipleFormattedQuery (
"INSERT OR REPLACE INTO list_entities (rowid, type, listId, elementId, ownerGroup, entity) VALUES " ,
nestedlistOfParams ,
)
} else if ( typeModel . type === TypeId . BlobElement ) {
const nestedlistOfParams : Array < Array < SqlValue > > = chunk . map ( ( storable ) = > {
const { rowId , typeString , encodedElementId , ownerGroup , serializedInstance } = storable
return [ rowId , typeString , listId , encodedElementId , ownerGroup , serializedInstance ] as Array < SqlValue >
} )
formattedQuery = this . insertMultipleFormattedQuery (
"INSERT OR REPLACE INTO blob_element_entities (rowid, type, listId, elementId, ownerGroup, entity) VALUES " ,
nestedlistOfParams ,
)
} else {
throw new Error ( "must be a persistent type" )
}
await this . sqlCipherFacade . run ( formattedQuery . query , formattedQuery . params )
}
}
2025-07-02 10:07:53 +02:00
tm ? . endMeasurement ( )
2025-06-30 08:41:22 +02:00
}
private insertMultipleFormattedQuery ( query : string , nestedlistOfParams : Array < Array < SqlValue > > ) : FormattedQuery {
const paramLists = nestedlistOfParams . map ( ( param ) = > paramList ( param ) )
let params : TaggedSqlValue [ ] = [ ]
query += paramLists
. map ( ( p ) = > {
params . push ( . . . p . params . map ( tagSqlValue ) )
return p . text
} )
. join ( "," )
return {
query ,
params ,
}
}
private async toStorables (
instances : Array < ServerModelParsedInstance > ,
typeModel : TypeModel ,
typeString : string ,
table : string ,
) : Promise < Array < StorableInstance > > {
const storables = await Promise . all (
2025-08-11 14:57:24 +02:00
instances . map ( async ( instance ) : Promise < Nullable < StorableInstance > > = > {
2025-06-30 08:41:22 +02:00
const { listId , elementId } = expandId ( AttributeModel . getAttribute < IdTuple | Id > ( instance , "_id" , typeModel ) )
const ownerGroup = AttributeModel . getAttribute < Id > ( instance , "_ownerGroup" , typeModel )
const serializedInstance = await this . serialize ( instance )
return {
typeString ,
table ,
rowId : null ,
listId ,
elementId ,
encodedElementId : ensureBase64Ext ( typeModel , elementId ) ,
ownerGroup ,
serializedInstance ,
instance ,
}
} ) ,
)
2025-08-11 14:57:24 +02:00
return storables . filter ( ( storable ) = > storable !== null )
2025-06-30 08:41:22 +02:00
}
2025-07-15 16:16:06 +02:00
private async fetchRowIds (
typeModel : TypeModel ,
table : string ,
typeString : string ,
listId : Nullable < Id > ,
storableInstances : StorableInstance [ ] ,
) : Promise < void > {
2025-06-30 08:41:22 +02:00
const ids = storableInstances . map ( ( dbRefs ) = > dbRefs . encodedElementId )
let formattedQuery : FormattedQuery
if ( typeModel . type === TypeId . Element ) {
formattedQuery = sql ` SELECT elementId, rowid
2025-10-14 12:11:22 +02:00
FROM element_entities
WHERE type = $ { typeString }
and elementId IN $ { paramList ( ids ) } `
2025-06-30 08:41:22 +02:00
} else if ( typeModel . type === TypeId . ListElement ) {
formattedQuery = sql ` SELECT elementId, listId, rowid
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { typeString }
and listId = $ { listId }
and elementId IN $ { paramList ( ids ) } `
2025-06-30 08:41:22 +02:00
} else if ( typeModel . type === TypeId . BlobElement ) {
formattedQuery = sql ` SELECT elementId, listId, rowid
2025-10-14 12:11:22 +02:00
FROM blob_element_entities
WHERE type = $ { typeString }
and listId = $ { listId }
and elementId IN $ { paramList ( ids ) } `
2025-06-30 08:41:22 +02:00
} else {
throw new Error ( "Can't fetch row ids for invalid type" )
}
const resultRows = await this . sqlCipherFacade . all ( formattedQuery . query , formattedQuery . params )
2025-07-15 16:16:06 +02:00
// important: rowid is all-lowercase how SQLite names it. It is important that it is consistent with the query.
type Row = { elementId : Id ; listId : Id ; rowid : Id }
2025-06-30 08:41:22 +02:00
const rows = resultRows . map ( ( row ) = > untagSqlObject ( row ) as Row )
for ( const row of rows ) {
const storable = storableInstances . find (
( storableInstance ) = >
( storableInstance . listId != null ? storableInstance . listId === row.listId : true ) && storableInstance . encodedElementId === row . elementId ,
)
2025-07-15 16:16:06 +02:00
assertNotNull ( storable ) . rowId = assertNotNull ( row . rowid )
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
}
2022-01-12 14:43:01 +01:00
}
2024-08-23 13:00:37 +02:00
async setLowerRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , lowerId : Id ) : Promise < void > {
2025-05-16 16:03:24 +02:00
let typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2025-05-19 16:45:37 +02:00
lowerId = ensureBase64Ext ( typeModel , lowerId )
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` UPDATE ranges
2025-10-14 12:11:22 +02:00
SET lower = $ { lowerId }
WHERE type = $ { type }
AND listId = $ { listId } `
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
2024-08-23 13:00:37 +02:00
async setUpperRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , upperId : Id ) : Promise < void > {
2025-05-16 16:03:24 +02:00
upperId = ensureBase64Ext ( await this . typeModelResolver . resolveClientTypeReference ( typeRef ) , upperId )
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` UPDATE ranges
2025-10-14 12:11:22 +02:00
SET upper = $ { upperId }
WHERE type = $ { type }
AND listId = $ { listId } `
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
async setNewRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , lower : Id , upper : Id ) : Promise < void > {
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2024-08-23 13:00:37 +02:00
lower = ensureBase64Ext ( typeModel , lower )
upper = ensureBase64Ext ( typeModel , upper )
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` INSERT
2025-10-14 12:11:22 +02:00
OR REPLACE INTO ranges VALUES (
$ { type } ,
$ { listId } ,
$ { lower } ,
$ { upper }
) `
2022-08-11 16:38:53 +02:00
return this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
2022-08-11 16:38:53 +02:00
async getLastBatchIdForGroup ( groupId : Id ) : Promise < Id | null > {
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` SELECT batchId
2025-10-14 12:11:22 +02:00
from lastUpdateBatchIdPerGroupId
WHERE groupId = $ { groupId } `
2022-12-27 15:37:40 +01:00
const row = ( await this . sqlCipherFacade . get ( query , params ) ) as { batchId : TaggedSqlValue } | null
2022-08-11 16:38:53 +02:00
return ( row ? . batchId ? . value ? ? null ) as Id | null
2022-01-12 14:43:01 +01:00
}
2022-08-11 16:38:53 +02:00
async putLastBatchIdForGroup ( groupId : Id , batchId : Id ) : Promise < void > {
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` INSERT
2025-10-14 12:11:22 +02:00
OR REPLACE INTO lastUpdateBatchIdPerGroupId VALUES (
$ { groupId } ,
$ { batchId }
) `
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
2022-09-07 17:09:25 +02:00
async getLastUpdateTime ( ) : Promise < LastUpdateTime > {
const time = await this . getMetadata ( "lastUpdateTime" )
2022-12-27 15:37:40 +01:00
return time ? { type : "recorded" , time } : { type : "never" }
2022-04-20 10:39:52 +02:00
}
2022-08-11 16:38:53 +02:00
async putLastUpdateTime ( ms : number ) : Promise < void > {
await this . putMetadata ( "lastUpdateTime" , ms )
2022-01-12 14:43:01 +01:00
}
2025-10-14 12:32:17 +02:00
async getLastTrainedTime ( ) : Promise < number > {
return ( await this . getMetadata ( "lastTrainedTime" ) ) ? ? 0
}
async setLastTrainedTime ( ms : number ) : Promise < void > {
await this . putMetadata ( "lastTrainedTime" , ms )
}
2025-10-14 12:11:22 +02:00
async getLastTrainedFromScratchTime ( ) : Promise < number > {
return ( await this . getMetadata ( "lastTrainedFromScratchTime" ) ) ? ? Date . now ( )
}
async setLastTrainedFromScratchTime ( ms : number ) : Promise < void > {
await this . putMetadata ( "lastTrainedFromScratchTime" , ms )
}
2022-08-11 16:38:53 +02:00
async purgeStorage ( ) : Promise < void > {
2025-05-06 11:25:24 +02:00
if ( this . userId == null || this . databaseKey == null ) {
console . warn ( "not purging storage since we don't have an open db" )
return
2022-08-11 16:38:53 +02:00
}
2025-05-28 12:21:25 +02:00
2025-06-16 11:45:49 +02:00
for ( const [ tableName , { purgedWithCache , onBeforePurged } ] of typedEntries ( this . allTables ) ) {
if ( onBeforePurged != null ) {
await onBeforePurged ( this . sqlCipherFacade )
}
2025-05-28 12:21:25 +02:00
if ( purgedWithCache ) {
await this . sqlCipherFacade . run ( ` DROP TABLE IF EXISTS ${ tableName } ` , [ ] )
}
}
2022-01-12 14:43:01 +01:00
}
2022-08-11 16:38:53 +02:00
async deleteRange ( typeRef : TypeRef < unknown > , listId : string ) : Promise < void > {
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM ranges
WHERE type = $ { getTypeString ( typeRef ) }
AND listId = $ { listId } `
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
2024-08-15 16:11:44 +02:00
async getElementsOfType < T extends ElementEntity > ( typeRef : TypeRef < T > ) : Promise < Array < T > > {
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` SELECT entity
2025-10-14 12:11:22 +02:00
from element_entities
WHERE type = $ { getTypeString ( typeRef ) } `
2022-12-27 15:37:40 +01:00
const items = ( await this . sqlCipherFacade . all ( query , params ) ) ? ? [ ]
2025-05-06 11:25:24 +02:00
const instanceBytes = items . map ( ( row ) = > row . entity . value as Uint8Array )
const parsedInstances = await this . deserializeList ( instanceBytes )
return await this . modelMapper . mapToInstances ( typeRef , parsedInstances )
2022-06-16 17:23:48 +02:00
}
2022-08-11 16:38:53 +02:00
async getWholeList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id ) : Promise < Array < T > > {
2025-05-06 11:25:24 +02:00
const parsedInstances = await this . getWholeListParsed ( typeRef , listId )
return await this . modelMapper . mapToInstances ( typeRef , parsedInstances )
2022-02-10 16:32:47 +01:00
}
2022-08-11 16:38:53 +02:00
async dumpMetadata ( ) : Promise < Partial < OfflineDbMeta > > {
const query = "SELECT * from metadata"
2022-12-27 15:37:40 +01:00
const stored = ( await this . sqlCipherFacade . all ( query , [ ] ) ) . map ( ( row ) = > [ row . key . value as string , row . value . value as Uint8Array ] as const )
2022-08-11 16:38:53 +02:00
return Object . fromEntries ( stored . map ( ( [ key , value ] ) = > [ key , cborg . decode ( value ) ] ) ) as OfflineDbMeta
2022-01-12 14:43:01 +01:00
}
2025-05-06 11:25:24 +02:00
async setCurrentOfflineSchemaVersion ( version : number ) {
return this . putMetadata ( "offline-version" , version )
2022-08-11 16:38:53 +02:00
}
2025-03-13 16:37:55 +01:00
getCustomCacheHandlerMap ( ) : CustomCacheHandlerMap {
2022-08-11 16:38:53 +02:00
return this . customCacheHandler
}
2022-10-21 15:53:39 +02:00
getUserId ( ) : Id {
return assertNotNull ( this . userId , "No user id, not initialized?" )
}
async deleteAllOwnedBy ( owner : Id ) : Promise < void > {
2025-03-13 16:37:55 +01:00
await this . deleteAllElementTypesOwnedBy ( owner )
await this . deleteAllListElementTypesOwnedBy ( owner )
await this . deleteAllBlobElementTypesOwnedBy ( owner )
2022-10-21 15:53:39 +02:00
{
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM lastUpdateBatchIdPerGroupId
WHERE groupId = $ { owner } `
2022-10-21 15:53:39 +02:00
await this . sqlCipherFacade . run ( query , params )
}
2025-03-13 16:37:55 +01:00
}
private async deleteAllBlobElementTypesOwnedBy ( owner : Id ) {
const { query , params } = sql ` SELECT listId, elementId, type
2025-10-14 12:11:22 +02:00
FROM blob_element_entities
WHERE ownerGroup = $ { owner } `
2025-03-13 16:37:55 +01:00
const taggedRows = await this . sqlCipherFacade . all ( query , params )
const rows = taggedRows . map ( untagSqlObject ) as { listId : Id ; elementId : Id ; type : string } [ ]
const groupedByType = groupBy ( rows , ( row ) = > row . type )
for ( const [ type , rows ] of groupedByType ) {
const typeRef = parseTypeString ( type ) as TypeRef < BlobElementEntity >
await this . deleteByIds (
typeRef ,
rows . map ( ( row ) = > [ row . listId , row . elementId ] ) ,
2022-12-27 15:37:40 +01:00
)
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
}
2025-03-13 16:37:55 +01:00
}
private async deleteAllListElementTypesOwnedBy ( owner : Id ) {
// first, check which list Ids contain entities owned by the lost group
const { query , params } = sql ` SELECT elementId, listId, type
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE ownerGroup = $ { owner } `
2025-03-13 16:37:55 +01:00
const rangeRows = await this . sqlCipherFacade . all ( query , params )
type Row = { elementId : Id ; listId : Id ; type : string }
const rows = rangeRows . map ( ( row ) = > untagSqlObject ( row ) as Row )
const listIdsByType : Map < string , Array < Row > > = groupByAndMap (
rows ,
( row ) = > row . type + row . listId ,
( row ) = > row ,
)
// delete the ranges for those listIds
for ( const [ _ , rows ] of listIdsByType . entries ( ) ) {
const { type } = getFirstOrThrow ( rows )
const typeRef = parseTypeString ( type ) as TypeRef < ListElementEntity >
// this particular query uses one other SQL var for the type.
const safeChunkSize = MAX_SAFE_SQL_VARS - 1
const listIdArr = rows . map ( ( row ) = > row . listId )
await this . runChunked (
safeChunkSize ,
listIdArr ,
( c ) = > sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM ranges
WHERE type = $ { type }
AND listId IN $ { paramList ( c ) } ` ,
2025-03-13 16:37:55 +01:00
)
await this . deleteByIds (
typeRef ,
rows . map ( ( row ) = > [ row . listId , row . elementId ] ) ,
)
2022-10-21 15:53:39 +02:00
}
}
2025-03-13 16:37:55 +01:00
private async deleteAllElementTypesOwnedBy ( owner : Id ) {
const { query , params } = sql ` SELECT elementId, type
2025-10-14 12:11:22 +02:00
FROM element_entities
WHERE ownerGroup = $ { owner } `
2025-03-13 16:37:55 +01:00
const taggedRows = await this . sqlCipherFacade . all ( query , params )
const rows = taggedRows . map ( untagSqlObject ) as { elementId : Id ; type : string } [ ]
const groupedByType = groupByAndMap (
rows ,
( row ) = > row . type ,
( row ) = > row . elementId ,
)
for ( const [ type , ids ] of groupedByType ) {
const typeRef = parseTypeString ( type ) as TypeRef < ElementEntity >
await this . deleteByIds ( typeRef , ids )
}
2024-09-09 11:57:46 +02:00
}
2022-08-11 16:38:53 +02:00
private async putMetadata < K extends keyof OfflineDbMeta > ( key : K , value : OfflineDbMeta [ K ] ) : Promise < void > {
2023-02-07 10:18:22 +01:00
let encodedValue
try {
encodedValue = cborg . encode ( value )
} catch ( e ) {
console . log ( "[OfflineStorage] failed to encode metadata for key" , key , "with value" , value )
throw e
}
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` INSERT
2025-10-14 12:11:22 +02:00
OR REPLACE INTO metadata VALUES (
$ { key } ,
$ { encodedValue }
) `
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( query , params )
}
private async getMetadata < K extends keyof OfflineDbMeta > ( key : K ) : Promise < OfflineDbMeta [ K ] | null > {
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` SELECT value
2025-10-14 12:11:22 +02:00
from metadata
WHERE key = $ { key } `
2022-08-11 16:38:53 +02:00
const encoded = await this . sqlCipherFacade . get ( query , params )
return encoded && cborg . decode ( encoded . value . value as Uint8Array )
2022-01-12 14:43:01 +01:00
}
2022-02-10 16:32:47 +01:00
2022-05-06 14:39:31 +02:00
/ * *
2025-04-10 14:51:39 +02:00
* Clear out unneeded data from the offline database ( i . e . old data ) .
2022-11-28 17:38:17 +01:00
* This will be called after login ( CachePostLoginActions . ts ) to ensure fast login time .
2025-03-18 17:27:59 +01:00
* @param timeRangeDate the maximum age that mails should be to be kept in the database
2022-11-28 17:38:17 +01:00
* @param userId id of the current user . default , last stored userId
2022-05-06 14:39:31 +02:00
* /
2025-03-18 17:27:59 +01:00
async clearExcludedData ( timeRangeDate : Date | null = this . timeRangeDate , userId : Id = this . getUserId ( ) ) : Promise < void > {
await this . cleaner . cleanOfflineDb ( this , timeRangeDate , userId , this . dateProvider . now ( ) )
2022-08-11 16:38:53 +02:00
}
private async createTables() {
2025-06-16 11:45:49 +02:00
for ( const { definition } of typedValues ( this . allTables ) ) {
2025-05-28 12:21:25 +02:00
await this . sqlCipherFacade . run ( definition , [ ] )
}
2022-08-11 16:38:53 +02:00
}
2025-02-05 13:00:51 +01:00
private async getRange ( typeRef : TypeRef < ElementEntity | ListElementEntity > , listId : Id ) : Promise < Range | null > {
2025-05-06 11:25:24 +02:00
const type = getTypeString ( typeRef )
2024-12-17 15:46:05 +01:00
const { query , params } = sql ` SELECT upper, lower
2025-10-14 12:11:22 +02:00
FROM ranges
WHERE type = $ { type }
AND listId = $ { listId } `
2022-12-27 15:37:40 +01:00
const row = ( await this . sqlCipherFacade . get ( query , params ) ) ? ? null
2024-08-23 13:00:37 +02:00
2022-08-11 16:38:53 +02:00
return mapNullable ( row , untagSqlObject ) as Range | null
2022-04-20 10:39:52 +02:00
}
2025-03-13 16:37:55 +01:00
/ * *
* A neat helper which can delete types in any lists as long as they belong to the same type .
2025-04-15 11:24:41 +02:00
* Will invoke { @link CustomCacheHandler # onBeforeCacheDeletion } .
2025-03-13 16:37:55 +01:00
* /
private async deleteByIds < T extends SomeEntity > ( typeRef : TypeRef < T > , ids : T [ "_id" ] [ ] ) {
if ( isEmpty ( ids ) ) {
return
}
const type = getTypeString ( typeRef )
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2025-03-13 16:37:55 +01:00
const handler = this . getCustomCacheHandlerMap ( ) . get ( typeRef )
2025-04-15 11:24:41 +02:00
if ( handler && handler . onBeforeCacheDeletion ) {
2025-03-13 16:37:55 +01:00
for ( const id of ids ) {
2025-04-15 11:24:41 +02:00
await handler . onBeforeCacheDeletion ( id )
2025-03-13 16:37:55 +01:00
}
}
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
switch ( typeModel . type ) {
2023-01-12 16:48:28 +01:00
case TypeId . Element :
2025-03-13 16:37:55 +01:00
await this . runChunked (
2023-08-16 10:47:09 +02:00
MAX_SAFE_SQL_VARS - 1 ,
2025-03-13 16:37:55 +01:00
( ids as Id [ ] ) . map ( ( id ) = > ensureBase64Ext ( typeModel , id ) ) ,
2024-12-17 15:46:05 +01:00
( c ) = > sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM element_entities
WHERE type = $ { type }
AND elementId IN $ { paramList ( c ) } ` ,
2023-08-16 10:47:09 +02:00
)
2025-03-13 16:37:55 +01:00
break
2023-01-12 16:48:28 +01:00
case TypeId . ListElement :
2025-03-13 16:37:55 +01:00
{
const byListId = groupByAndMap ( ids as IdTuple [ ] , listIdPart , ( id ) = > ensureBase64Ext ( typeModel , elementIdPart ( id ) ) )
for ( const [ listId , elementIds ] of byListId ) {
await this . runChunked (
MAX_SAFE_SQL_VARS - 2 ,
elementIds ,
( c ) = > sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND elementId IN $ { paramList ( c ) } ` ,
2025-03-13 16:37:55 +01:00
)
}
}
break
2023-01-12 16:48:28 +01:00
case TypeId . BlobElement :
2025-03-13 16:37:55 +01:00
{
const byListId = groupByAndMap ( ids as IdTuple [ ] , listIdPart , ( id ) = > ensureBase64Ext ( typeModel , elementIdPart ( id ) ) )
for ( const [ listId , elementIds ] of byListId ) {
await this . runChunked (
MAX_SAFE_SQL_VARS - 2 ,
elementIds ,
( c ) = > sql ` DELETE
2025-10-14 12:11:22 +02:00
FROM blob_element_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND elementId IN $ { paramList ( c ) } ` ,
2025-03-13 16:37:55 +01:00
)
}
}
break
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
default :
throw new Error ( "must be a persistent type" )
}
2022-04-20 10:39:52 +02:00
}
2025-03-13 16:37:55 +01:00
async deleteIn < T extends SomeEntity > (
typeRef : TypeRef < T > ,
listId : T extends ListElementEntity | BlobElementEntity ? Id : null ,
elementIds : Id [ ] ,
) : Promise < void > {
if ( isEmpty ( elementIds ) ) return
const fullIds : T [ "_id" ] [ ] = listId == null ? elementIds : elementIds.map ( ( id ) = > [ listId , id ] )
await this . deleteByIds ( typeRef , fullIds )
}
2025-02-19 11:57:08 +01:00
async updateRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , rawCutoffId : Id ) : Promise < void > {
2025-05-16 16:03:24 +02:00
const typeModel = await this . typeModelResolver . resolveClientTypeReference ( typeRef )
2024-08-23 13:00:37 +02:00
const isCustomId = isCustomIdType ( typeModel )
2025-02-05 13:00:51 +01:00
const encodedCutoffId = ensureBase64Ext ( typeModel , rawCutoffId )
2022-04-20 10:39:52 +02:00
2024-08-23 13:00:37 +02:00
const range = await this . getRange ( typeRef , listId )
2022-04-20 10:39:52 +02:00
if ( range == null ) {
return
}
2025-02-19 11:57:08 +01:00
// If the range for a given list is complete from the beginning (starts at GENERATED_MIN_ID or CUSTOM_MIN_ID), then we only want to actually modify the
2022-04-20 10:39:52 +02:00
// saved range if we would be removing elements from the list, in order to not lose the information that the range is complete in storage.
// So we have to check how old the oldest element in said range is. If it is newer than cutoffId, then we will not modify the range,
// otherwise we will just modify it normally
2024-08-23 13:00:37 +02:00
const expectedMinId = isCustomId ? CUSTOM_MIN_ID : GENERATED_MIN_ID
if ( range . lower === expectedMinId ) {
const entities = await this . provideFromRange ( typeRef , listId , expectedMinId , 1 , false )
2022-04-20 10:39:52 +02:00
const id = mapNullable ( entities [ 0 ] , getElementId )
2025-02-19 11:57:08 +01:00
// !!IMPORTANT!!
// Ids on entities with a customId are always base64Url encoded,
// !!however ids for entities with a customId used to QUERY the offline database
// MUST always be base64Ext encoded
// Therefore, we need to compare against the rawCutoffId here!
const rangeWontBeModified = id != null && ( firstBiggerThanSecond ( id , rawCutoffId ) || id === rawCutoffId )
2022-04-20 10:39:52 +02:00
if ( rangeWontBeModified ) {
return
}
}
2025-02-05 13:00:51 +01:00
if ( firstBiggerThanSecond ( encodedCutoffId , range . lower ) ) {
2022-04-20 10:39:52 +02:00
// If the upper id of the range is below the cutoff, then the entire range will be deleted from the storage
// so we just delete the range as well
// Otherwise, we only want to modify
2025-02-05 13:00:51 +01:00
if ( firstBiggerThanSecond ( encodedCutoffId , range . upper ) ) {
2022-08-11 16:38:53 +02:00
await this . deleteRange ( typeRef , listId )
2022-04-20 10:39:52 +02:00
} else {
2024-08-26 15:07:37 +02:00
await this . setLowerRangeForList ( typeRef , listId , rawCutoffId )
2022-04-20 10:39:52 +02:00
}
}
}
2025-05-06 11:25:24 +02:00
private async serialize ( parsedInstance : ServerModelParsedInstance ) : Promise < Uint8Array > {
2023-02-07 10:18:22 +01:00
try {
2025-05-06 11:25:24 +02:00
return cborg . encode ( parsedInstance , { typeEncoders : customTypeEncoders } )
2023-02-07 10:18:22 +01:00
} catch ( e ) {
2025-05-06 11:25:24 +02:00
console . log ( "[OfflineStorage] failed to encode entity with attribute ids: " + Object . keys ( parsedInstance ) )
2023-02-07 10:18:22 +01:00
throw e
}
2022-04-20 10:39:52 +02:00
}
2024-08-20 14:31:57 +02:00
/ * *
* Convert the type from CBOR representation to the runtime type
* /
2025-05-06 11:25:24 +02:00
private async deserialize ( loaded : Uint8Array ) : Promise < ServerModelParsedInstance | null > {
2024-09-20 16:10:37 +02:00
try {
2025-05-06 11:25:24 +02:00
return cborg . decode ( loaded , { tags : customTypeDecoders } )
2024-09-20 16:10:37 +02:00
} catch ( e ) {
console . log ( ` Error with CBOR decode. Trying to decode (of type: ${ typeof loaded } ): ${ loaded } ` )
return null
}
2022-04-20 10:39:52 +02:00
}
2022-05-17 17:40:44 +02:00
2025-05-06 11:25:24 +02:00
private async deserializeList ( loaded : Array < Uint8Array > ) : Promise < Array < ServerModelParsedInstance > > {
2024-09-04 16:19:59 +02:00
// manually reimplementing promiseMap to make sure we don't hit the scheduler since there's nothing actually async happening
2025-05-06 11:25:24 +02:00
const result : Array < ServerModelParsedInstance > = [ ]
2024-09-04 16:19:59 +02:00
for ( const entity of loaded ) {
2025-05-06 11:25:24 +02:00
const deserialized = await this . deserialize ( entity )
2024-09-20 16:10:37 +02:00
if ( deserialized != null ) {
result . push ( deserialized )
}
2024-09-04 16:19:59 +02:00
}
return result
2022-05-17 17:40:44 +02:00
}
2023-08-16 10:47:09 +02:00
/ * *
* convenience method to run a potentially too large query over several chunks .
* chunkSize must be chosen such that the total number of SQL variables in the final query does not exceed MAX_SAFE_SQL_VARS
* * /
private async runChunked ( chunkSize : number , originalList : SqlValue [ ] , formatter : ( chunk : SqlValue [ ] ) = > FormattedQuery ) : Promise < void > {
for ( const chunk of splitInChunks ( chunkSize , originalList ) ) {
const formattedQuery = formatter ( chunk )
await this . sqlCipherFacade . run ( formattedQuery . query , formattedQuery . params )
}
}
2024-08-07 08:38:58 +02:00
/ * *
* convenience method to execute a potentially too large query over several chunks .
* chunkSize must be chosen such that the total number of SQL variables in the final query does not exceed MAX_SAFE_SQL_VARS
* * /
private async allChunked (
chunkSize : number ,
originalList : SqlValue [ ] ,
formatter : ( chunk : SqlValue [ ] ) = > FormattedQuery ,
) : Promise < Array < Record < string , TaggedSqlValue > > > {
const result : Array < Record < string , TaggedSqlValue > > = [ ]
for ( const chunk of splitInChunks ( chunkSize , originalList ) ) {
const formattedQuery = formatter ( chunk )
result . push ( . . . ( await this . sqlCipherFacade . all ( formattedQuery . query , formattedQuery . params ) ) )
}
return result
}
2022-08-11 16:38:53 +02:00
}
2022-07-04 14:55:17 +02:00
2022-08-11 16:38:53 +02:00
/ *
2023-08-16 10:47:09 +02:00
* used to automatically create the right amount of SQL variables for selecting ids from a dynamic list .
* must be used within sql ` <query> ` template string to inline the logic into the query .
*
* It is very important that params is kept to a size such that the total amount of SQL variables is
* less than MAX_SAFE_SQL_VARS .
2022-08-11 16:38:53 +02:00
* /
function paramList ( params : SqlValue [ ] ) : SqlFragment {
2022-12-27 15:37:40 +01:00
const qs = params . map ( ( ) = > "?" ) . join ( "," )
2022-08-11 16:38:53 +02:00
return new SqlFragment ( ` ( ${ qs } ) ` , params )
}
/ * *
2022-08-15 14:22:44 +02:00
* comparison to select ids that are bigger or smaller than a parameter id
2023-08-16 10:47:09 +02:00
* must be used within sql ` <query> ` template string to inline the logic into the query .
*
* will always insert 3 constants and 3 SQL variables into the query .
2022-08-11 16:38:53 +02:00
* /
function firstIdBigger ( . . . args : [ string , "elementId" ] | [ "elementId" , string ] ) : SqlFragment {
let [ l , r ] : [ string , string ] = args
let v
if ( l === "elementId" ) {
v = r
r = "?"
} else {
v = l
l = "?"
}
2022-12-27 15:37:40 +01:00
return new SqlFragment ( ` (CASE WHEN length( ${ l } ) > length( ${ r } ) THEN 1 WHEN length( ${ l } ) < length( ${ r } ) THEN 0 ELSE ${ l } > ${ r } END) ` , [ v , v , v ] )
2022-08-11 16:38:53 +02:00
}
2024-08-23 13:00:37 +02:00
2024-08-15 16:11:44 +02:00
export interface OfflineStorageCleaner {
2025-03-18 17:27:59 +01:00
/ * *
* Delete instances from db that are older than timeRangeDays .
* /
cleanOfflineDb ( offlineStorage : OfflineStorage , timeRangeDate : Date | null , userId : Id , now : number ) : Promise < void >
2024-08-15 16:11:44 +02:00
}
2025-06-16 11:45:49 +02:00
export async function tableExists ( sqlCipherFacade : SqlCipherFacade , table : string ) : Promise < boolean > {
// Read the schema for the table https://sqlite.org/schematab.html
const { query , params } = sql ` SELECT COUNT(*) as metadata_exists
2025-10-14 12:11:22 +02:00
FROM sqlite_schema
WHERE name = $ { table } `
2025-06-16 11:45:49 +02:00
const result = assertNotNull ( await sqlCipherFacade . get ( query , params ) )
return untagSqlValue ( result [ "metadata_exists" ] ) === 1
}