2022-06-16 17:23:48 +02:00
import { ElementEntity , ListElementEntity , SomeEntity } from "../../common/EntityTypes.js"
2022-04-20 10:39:52 +02:00
import {
elementIdPart ,
firstBiggerThanSecond ,
GENERATED_MAX_ID ,
GENERATED_MIN_ID ,
getElementId ,
listIdPart ,
timestampToGeneratedId
} from "../../common/utils/EntityUtils.js"
2022-09-07 17:09:25 +02:00
import { CacheStorage , expandId , ExposedCacheStorage , LastUpdateTime } from "../rest/DefaultEntityRestCache.js"
2022-01-12 14:43:01 +01:00
import * as cborg from "cborg"
import { EncodeOptions , Token , Type } from "cborg"
2022-11-02 11:37:37 +01:00
import { assert , assertNotNull , DAY_IN_MILLIS , getTypeId , groupByAndMap , groupByAndMapUniquely , mapNullable , TypeRef } from "@tutao/tutanota-utils"
2022-08-11 16:38:53 +02:00
import { isDesktop , isOfflineStorageAvailable , isTest } from "../../common/Env.js"
2022-05-17 17:40:44 +02:00
import { modelInfos } from "../../common/EntityFunctions.js"
import { AccountType , MailFolderType , OFFLINE_STORAGE_DEFAULT_TIME_RANGE_DAYS } from "../../common/TutanotaConstants.js"
import { DateProvider } from "../../common/DateProvider.js"
2022-04-20 10:39:52 +02:00
import { TokenOrNestedTokens } from "cborg/types/interface"
2022-07-04 14:55:17 +02:00
import { CalendarEventTypeRef , FileTypeRef , MailBodyTypeRef , MailFolderTypeRef , MailHeadersTypeRef , MailTypeRef } from "../../entities/tutanota/TypeRefs.js"
2022-05-17 17:40:44 +02:00
import { UserTypeRef } from "../../entities/sys/TypeRefs.js"
import { OfflineStorageMigrator } from "./OfflineStorageMigrator.js"
2022-07-04 14:55:17 +02:00
import { CustomCacheHandlerMap , CustomCalendarEventCacheHandler } from "../rest/CustomCacheHandler.js"
import { EntityRestClient } from "../rest/EntityRestClient.js"
2022-07-20 15:28:38 +02:00
import { InterWindowEventFacadeSendDispatcher } from "../../../native/common/generatedipc/InterWindowEventFacadeSendDispatcher.js"
2022-08-11 16:38:53 +02:00
import { SqlCipherFacade } from "../../../native/common/generatedipc/SqlCipherFacade.js"
import { SqlValue , TaggedSqlValue , tagSqlValue , untagSqlObject } from "./SqlValue.js"
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-10-13 13:29:00 +02: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 )
}
export const customTypeEncoders : { [ typeName : string ] : typeof dateEncoder } = Object . freeze ( {
"Date" : dateEncoder
} )
type TypeDecoder = ( _ : any ) = > any
export const customTypeDecoders : Array < TypeDecoder > = ( ( ) = > {
const tags : Array < TypeDecoder > = [ ]
tags [ 100 ] = dateDecoder
return tags
} ) ( )
2022-10-21 15:53:39 +02:00
/ * *
* For each of these keys we track the current version in the database .
* The keys are different model versions ( because we need to migrate the data with certain model version changes ) and "offline" key which is used to track
* migrations that are needed for other reasons e . g . if DB structure changes or if we need to invalidate some tables .
* /
export type VersionMetadataBaseKey = keyof typeof modelInfos | "offline"
2022-04-20 10:39:52 +02:00
2022-10-21 15:53:39 +02:00
type VersionMetadataEntries = {
2022-04-20 10:39:52 +02:00
// Yes this is cursed, give me a break
2022-10-21 15:53:39 +02:00
[ P in VersionMetadataBaseKey as ` ${ P } -version ` ] : number
2022-04-20 10:39:52 +02:00
}
2022-10-21 15:53:39 +02:00
export interface OfflineDbMeta extends VersionMetadataEntries {
2022-04-20 10:39:52 +02:00
lastUpdateTime : number ,
timeRangeDays : number
}
2022-08-11 16:38:53 +02:00
const TableDefinitions = Object . freeze ( {
2022-10-21 15:53:39 +02:00
// plus ownerGroup added in a migration
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)" ,
// plus ownerGroup added in a migration
element_entities : "type TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, elementId)" ,
2022-08-11 16:38:53 +02:00
ranges : "type TEXT NOT NULL, listId TEXT NOT NULL, lower TEXT NOT NULL, upper TEXT NOT NULL, PRIMARY KEY (type, listId)" ,
lastUpdateBatchIdPerGroupId : "groupId TEXT NOT NULL, batchId TEXT NOT NULL, PRIMARY KEY (groupId)" ,
metadata : "key TEXT NOT NULL, value BLOB, PRIMARY KEY (key)" ,
} as const )
type Range = { lower : string , upper : string }
2022-10-21 15:53:39 +02:00
export interface OfflineStorageInitArgs {
userId : Id ,
databaseKey : Uint8Array ,
timeRangeDays : number | null ,
forceNewDatabase : boolean
}
2022-05-12 17:06:57 +02:00
export class OfflineStorage implements CacheStorage , ExposedCacheStorage {
2022-07-04 14:55:17 +02:00
private customCacheHandler : CustomCacheHandlerMap | null = null
2022-10-21 15:53:39 +02:00
private userId : Id | null = null
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 ,
2022-01-12 14:43:01 +01:00
) {
2022-02-10 16:32:47 +01:00
assert ( isOfflineStorageAvailable ( ) || isTest ( ) , "Offline storage is not available." )
}
2022-05-17 17:40:44 +02:00
/ * *
* @return { boolean } whether the database was newly created or not
* /
2022-07-20 15:28:38 +02:00
async init ( { userId , databaseKey , timeRangeDays , forceNewDatabase } : OfflineStorageInitArgs ) : Promise < boolean > {
2022-10-21 15:53:39 +02:00
this . userId = userId
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-04-11 12:05:44 +02: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 ( )
2022-10-21 15:53:39 +02:00
await this . migrator . migrate ( this , this . sqlCipherFacade )
2022-05-17 17:40:44 +02:00
// if nothing is written here, it means it's a new database
const isNewOfflineDb = await this . getLastUpdateTime ( ) == null
2022-08-11 16:38:53 +02:00
await this . clearExcludedData ( timeRangeDays , userId )
2022-05-17 17:40:44 +02:00
return isNewOfflineDb
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
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . closeDb ( )
2022-07-20 15:28:38 +02:00
}
2022-08-11 16:38:53 +02:00
async deleteIfExists ( typeRef : TypeRef < SomeEntity > , listId : Id | null , elementId : Id ) : Promise < void > {
const type = getTypeId ( typeRef )
let preparedQuery
if ( listId == null ) {
preparedQuery = sql ` DELETE FROM element_entities WHERE type = ${ type } AND elementId = ${ elementId } `
} else {
preparedQuery = sql ` DELETE FROM list_entities WHERE type = ${ type } AND listId = ${ listId } AND elementId = ${ elementId } `
2022-02-10 16:32:47 +01:00
}
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( preparedQuery . query , preparedQuery . params )
2022-01-12 14:43:01 +01:00
}
2022-08-11 16:38:53 +02:00
async get < T extends SomeEntity > ( typeRef : TypeRef < T > , listId : Id | null , elementId : Id ) : Promise < T | null > {
const type = getTypeId ( typeRef )
2022-01-12 14:43:01 +01:00
2022-08-11 16:38:53 +02:00
let preparedQuery
if ( listId == null ) {
preparedQuery = sql ` SELECT entity from element_entities WHERE type = ${ type } AND elementId = ${ elementId } `
} else {
preparedQuery = sql ` SELECT entity from list_entities WHERE type = ${ type } AND listId = ${ listId } AND elementId = ${ elementId } `
}
const result = await this . sqlCipherFacade . get ( preparedQuery . query , preparedQuery . params )
return result ? . entity
? this . deserialize ( typeRef , result . entity . value as Uint8Array )
: null
2022-01-12 14:43:01 +01:00
}
async getIdsInRange < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id ) : Promise < Array < Id > > {
2022-08-11 16:38:53 +02:00
const type = getTypeId ( typeRef )
const range = await this . getRange ( type , listId )
if ( range == null ) {
throw new Error ( ` no range exists for ${ type } and list ${ listId } ` )
}
const { lower , upper } = range
const { query , params } = sql ` SELECT elementId FROM list_entities
WHERE type = $ { type }
AND listId = $ { listId }
AND ( elementId = $ { lower }
OR $ { firstIdBigger ( "elementId" , lower ) } )
AND NOT ( $ { firstIdBigger ( "elementId" , upper ) } ) `
const rows = await this . sqlCipherFacade . all ( query , params )
return rows . map ( ( row ) = > row . elementId . value as string )
2022-01-12 14:43:01 +01:00
}
2022-08-11 16:38:53 +02:00
async getRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id ) : Promise < Range | null > {
return this . getRange ( getTypeId ( typeRef ) , listId )
2022-01-12 14:43:01 +01:00
}
async isElementIdInCacheRange < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , id : Id ) : Promise < boolean > {
const range = await this . getRangeForList ( typeRef , listId )
return range != null
&& ! firstBiggerThanSecond ( id , range . upper )
&& ! firstBiggerThanSecond ( range . lower , id )
}
async provideFromRange < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , start : Id , count : number , reverse : boolean ) : Promise < T [ ] > {
2022-08-11 16:38:53 +02:00
const type = getTypeId ( typeRef )
let preparedQuery
if ( reverse ) {
preparedQuery = sql ` SELECT entity FROM list_entities WHERE type = ${ type } AND listId = ${ listId } AND ${ firstIdBigger ( start , "elementId" ) } ORDER BY LENGTH(elementId) DESC, elementId DESC LIMIT ${ count } `
} else {
preparedQuery = sql ` SELECT entity FROM list_entities WHERE type = ${ type } AND listId = ${ listId } AND ${ firstIdBigger ( "elementId" , start ) } ORDER BY LENGTH(elementId) ASC, elementId ASC LIMIT ${ count } `
}
const { query , params } = preparedQuery
const serializedList : ReadonlyArray < Record < string , TaggedSqlValue > > = await this . sqlCipherFacade . all ( query , params )
return this . deserializeList ( typeRef , serializedList . map ( ( r ) = > r . entity . value as Uint8Array ) )
2022-01-12 14:43:01 +01:00
}
async put ( originalEntity : SomeEntity ) : Promise < void > {
const serializedEntity = this . serialize ( originalEntity )
const { listId , elementId } = expandId ( originalEntity . _id )
2022-08-11 16:38:53 +02:00
const type = getTypeId ( originalEntity . _type )
2022-10-21 15:53:39 +02:00
const ownerGroup = originalEntity . _ownerGroup
2022-08-11 16:38:53 +02:00
const preparedQuery = listId == null
2022-10-21 15:53:39 +02:00
? sql ` INSERT OR REPLACE INTO element_entities (type, elementId, ownerGroup, entity) VALUES ( ${ type } , ${ elementId } , ${ ownerGroup } , ${ serializedEntity } ) `
: sql ` INSERT OR REPLACE INTO list_entities (type, listId, elementId, ownerGroup, entity) VALUES ( ${ type } , ${ listId } , ${ elementId } , ${ ownerGroup } , ${ serializedEntity } ) `
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( preparedQuery . query , preparedQuery . params )
2022-01-12 14:43:01 +01:00
}
async setLowerRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , id : Id ) : Promise < void > {
2022-08-11 16:38:53 +02:00
const type = getTypeId ( typeRef )
const { query , params } = sql ` UPDATE ranges SET lower = ${ id } WHERE type = ${ type } AND listId = ${ listId } `
await this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
async setUpperRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , id : Id ) : Promise < void > {
2022-08-11 16:38:53 +02:00
const type = getTypeId ( typeRef )
const { query , params } = sql ` UPDATE ranges SET upper = ${ id } WHERE type = ${ type } AND listId = ${ listId } `
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 > {
2022-08-11 16:38:53 +02:00
const type = getTypeId ( typeRef )
const { query , params } = sql ` INSERT OR REPLACE INTO ranges VALUES ( ${ type } , ${ listId } , ${ lower } , ${ upper } ) `
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 > {
const { query , params } = sql ` SELECT batchId from lastUpdateBatchIdPerGroupId WHERE groupId = ${ groupId } `
const row = await this . sqlCipherFacade . get ( query , params ) as { batchId : TaggedSqlValue } | null
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 > {
const { query , params } = sql ` INSERT OR REPLACE INTO lastUpdateBatchIdPerGroupId VALUES ( ${ groupId } , ${ batchId } ) `
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" )
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
}
2022-08-11 16:38:53 +02:00
async purgeStorage ( ) : Promise < void > {
for ( let name of Object . keys ( TableDefinitions ) ) {
await this . sqlCipherFacade . run ( ` DELETE FROM ${ name } ` , [ ] )
}
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 > {
const { query , params } = sql ` DELETE FROM ranges WHERE type = ${ getTypeId ( typeRef ) } AND listId = ${ listId } `
await this . sqlCipherFacade . run ( query , params )
2022-01-12 14:43:01 +01:00
}
2022-08-11 16:38:53 +02:00
async getListElementsOfType < T extends ListElementEntity > ( typeRef : TypeRef < T > ) : Promise < Array < T > > {
const { query , params } = sql ` SELECT entity from list_entities WHERE type = ${ getTypeId ( typeRef ) } `
const items = await this . sqlCipherFacade . all ( query , params ) ? ? [ ]
return this . deserializeList ( typeRef , items . map ( row = > row . entity . value as Uint8Array ) )
2022-02-10 16:32:47 +01:00
}
2022-06-16 17:23:48 +02:00
async getElementsOfType < T extends ElementEntity > ( typeRef : TypeRef < T > ) : Promise < Array < T > > {
2022-10-05 10:44:40 +02:00
const { query , params } = sql ` SELECT entity from element_entities WHERE type = ${ getTypeId ( typeRef ) } `
2022-06-16 17:23:48 +02:00
const items = await this . sqlCipherFacade . all ( query , params ) ? ? [ ]
return this . deserializeList ( typeRef , items . map ( row = > row . entity . value as Uint8Array ) )
}
2022-08-11 16:38:53 +02:00
async getWholeList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id ) : Promise < Array < T > > {
const { query , params } = sql ` SELECT entity FROM list_entities WHERE type = ${ getTypeId ( typeRef ) } AND listId = ${ listId } `
const items = await this . sqlCipherFacade . all ( query , params ) ? ? [ ]
return this . deserializeList ( typeRef , items . map ( row = > row . entity . value as Uint8Array ) )
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"
const stored = ( await this . sqlCipherFacade . all ( query , [ ] ) ) . map ( row = > [ row . key . value as string , row . value . value as Uint8Array ] as const )
return Object . fromEntries ( stored . map ( ( [ key , value ] ) = > [ key , cborg . decode ( value ) ] ) ) as OfflineDbMeta
2022-01-12 14:43:01 +01:00
}
2022-10-21 15:53:39 +02:00
async setStoredModelVersion ( model : VersionMetadataBaseKey , version : number ) {
2022-08-11 16:38:53 +02:00
return this . putMetadata ( ` ${ model } -version ` , version )
}
getCustomCacheHandlerMap ( entityRestClient : EntityRestClient ) : CustomCacheHandlerMap {
if ( this . customCacheHandler == null ) {
this . customCacheHandler = new CustomCacheHandlerMap ( { ref : CalendarEventTypeRef , handler : new CustomCalendarEventCacheHandler ( entityRestClient ) } )
}
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 > {
{
const { query , params } = sql ` DELETE FROM element_entities WHERE ownerGroup = ${ owner } `
await this . sqlCipherFacade . run ( query , params )
}
{
2022-11-02 11:37:37 +01:00
// first, check which list Ids contain entities owned by the lost group
const { query , params } = sql ` SELECT listId, type FROM list_entities WHERE ownerGroup = ${ owner } `
const rangeRows = await this . sqlCipherFacade . all ( query , params )
const rows = rangeRows . map ( row = > untagSqlObject ( row ) as { listId : string , type : string } )
const listIdsByType : Map < string , Set < Id > > = groupByAndMapUniquely ( rows , ( row ) = > row . type , row = > row . listId )
for ( const [ type , listIds ] of listIdsByType . entries ( ) ) {
// delete the ranges for those listIds
const deleteRangeQuery = sql ` DELETE FROM ranges WHERE type = ${ type } AND listId IN ${ paramList ( Array . from ( listIds ) ) } `
await this . sqlCipherFacade . run ( deleteRangeQuery . query , deleteRangeQuery . params )
// delete all entities that have one of those list Ids.
const deleteEntitiesQuery = sql ` DELETE FROM list_entities WHERE type = ${ type } AND listId IN ${ paramList ( Array . from ( listIds ) ) } `
await this . sqlCipherFacade . run ( deleteEntitiesQuery . query , deleteEntitiesQuery . params )
}
2022-11-03 11:04:26 +01:00
}
{
const { query , params } = sql ` DELETE FROM lastUpdateBatchIdPerGroupId WHERE groupId = ${ owner } `
await this . sqlCipherFacade . run ( query , params )
2022-10-21 15:53:39 +02:00
}
}
2022-08-11 16:38:53 +02:00
private async putMetadata < K extends keyof OfflineDbMeta > ( key : K , value : OfflineDbMeta [ K ] ) : Promise < void > {
const { query , params } = sql ` INSERT OR REPLACE INTO metadata VALUES ( ${ key } , ${ cborg . encode ( value ) } ) `
await this . sqlCipherFacade . run ( query , params )
}
private async getMetadata < K extends keyof OfflineDbMeta > ( key : K ) : Promise < OfflineDbMeta [ K ] | null > {
const { query , params } = sql ` SELECT value from metadata WHERE key = ${ key } `
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
/ * *
* Clear out unneeded data from the offline database ( i . e . trash and spam lists , old data )
* @param timeRangeDays : the maxiumum age of days that mails should be to be kept in the database . if null , will use a default value
* /
2022-08-11 16:38:53 +02:00
private async clearExcludedData ( timeRangeDays : number | null , userId : Id ) : Promise < void > {
const user = await this . get ( UserTypeRef , null , userId )
2022-04-12 14:58:52 +02:00
2022-05-06 14:39:31 +02:00
// Free users always have default time range regardless of what is stored
const isFreeUser = user ? . accountType === AccountType . FREE
const timeRange = isFreeUser || timeRangeDays == null ? OFFLINE_STORAGE_DEFAULT_TIME_RANGE_DAYS : timeRangeDays
2022-05-24 18:35:24 +02:00
const cutoffTimestamp = this . dateProvider . now ( ) - timeRange * DAY_IN_MILLIS
2022-04-20 10:39:52 +02:00
const cutoffId = timestampToGeneratedId ( cutoffTimestamp )
2022-04-06 16:55:16 +02:00
2022-04-20 10:39:52 +02:00
const folders = await this . getListElementsOfType ( MailFolderTypeRef )
for ( const folder of folders ) {
if ( folder . folderType === MailFolderType . TRASH || folder . folderType === MailFolderType . SPAM ) {
await this . deleteMailList ( folder . mails , GENERATED_MAX_ID )
} else {
await this . deleteMailList ( folder . mails , cutoffId )
}
}
2022-08-11 16:38:53 +02:00
await this . sqlCipherFacade . run ( "VACUUM" , [ ] )
}
private async createTables() {
for ( let [ name , definition ] of Object . entries ( TableDefinitions ) ) {
await this . sqlCipherFacade . run ( ` CREATE TABLE IF NOT EXISTS ${ name } ( ${ definition } ) ` , [ ] )
}
}
private async getRange ( type : string , listId : Id ) : Promise < Range | null > {
const { query , params } = sql ` SELECT upper, lower FROM ranges WHERE type = ${ type } AND listId = ${ listId } `
const row = await this . sqlCipherFacade . get ( query , params ) ? ? null
return mapNullable ( row , untagSqlObject ) as Range | null
2022-04-20 10:39:52 +02:00
}
/ * *
* This method deletes mails from { @param listId } what are older than { @param cutoffId } . as well as associated data
*
* For each mail we delete its body , headers , and all referenced attachments .
*
* When we delete the Files , we also delete the whole range for the user ' s File list . We need to delete the whole
* range because we only have one file list per mailbox , so if we delete something from the middle of it , the range
* will no longer be valid . ( this is future proofing , because as of now there is not going to be a Range set for the
* File list anyway , since we currently do not do range requests for Files .
*
* We do not delete ConversationEntries because :
* 1 . They are in the same list for the whole conversation so we can ' t adjust the range
* 2 . We might need them in the future for showing the whole thread
* /
private async deleteMailList ( listId : Id , cutoffId : Id ) : Promise < void > {
// This must be done before deleting mails to know what the new range has to be
await this . updateRangeForList ( MailTypeRef , listId , cutoffId )
const mailsToDelete : IdTuple [ ] = [ ]
const headersToDelete : Id [ ] = [ ]
const attachmentsTodelete : IdTuple [ ] = [ ]
const mailbodiesToDelete : Id [ ] = [ ]
const mails = await this . getWholeList ( MailTypeRef , listId )
for ( let mail of mails ) {
if ( firstBiggerThanSecond ( cutoffId , getElementId ( mail ) ) ) {
mailsToDelete . push ( mail . _id )
mailbodiesToDelete . push ( mail . body )
if ( mail . headers ) {
headersToDelete . push ( mail . headers )
}
for ( const id of mail . attachments ) {
attachmentsTodelete . push ( id )
}
}
}
2022-08-11 16:38:53 +02:00
await this . deleteIn ( MailBodyTypeRef , null , mailbodiesToDelete )
await this . deleteIn ( MailHeadersTypeRef , null , headersToDelete )
2022-04-20 10:39:52 +02:00
for ( let [ listId , elementIds ] of groupByAndMap ( attachmentsTodelete , listIdPart , elementIdPart ) . entries ( ) ) {
2022-08-11 16:38:53 +02:00
await this . deleteIn ( FileTypeRef , listId , elementIds )
await this . deleteRange ( FileTypeRef , listId )
2022-04-20 10:39:52 +02:00
}
2022-08-11 16:38:53 +02:00
await this . deleteIn ( MailTypeRef , listId , mailsToDelete . map ( elementIdPart ) )
}
private async deleteIn ( typeRef : TypeRef < unknown > , listId : Id | null , elementIds : Id [ ] ) : Promise < void > {
const { query , params } = listId == null
? sql ` DELETE FROM element_entities WHERE type = ${ getTypeId ( typeRef ) } AND elementId IN ${ paramList ( elementIds ) } `
: sql ` DELETE FROM list_entities WHERE type = ${ getTypeId ( typeRef ) } AND listId = ${ listId } AND elementId IN ${ paramList ( elementIds ) } `
return this . sqlCipherFacade . run ( query , params )
2022-04-20 10:39:52 +02:00
}
private async updateRangeForList < T extends ListElementEntity > ( typeRef : TypeRef < T > , listId : Id , cutoffId : Id ) : Promise < void > {
2022-05-23 17:09:09 +02:00
const type = getTypeId ( typeRef )
2022-04-20 10:39:52 +02:00
2022-08-11 16:38:53 +02:00
const range = await this . getRange ( type , listId )
2022-04-20 10:39:52 +02:00
if ( range == null ) {
return
}
// If the range for a given list is complete from the beginning (starts at GENERATED_MIN_ID), then we only want to actually modify the
// 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
if ( range . lower === GENERATED_MIN_ID ) {
const entities = await this . provideFromRange ( typeRef , listId , GENERATED_MIN_ID , 1 , false )
const id = mapNullable ( entities [ 0 ] , getElementId )
const rangeWontBeModified = id == null || firstBiggerThanSecond ( id , cutoffId ) || id === cutoffId
if ( rangeWontBeModified ) {
return
}
}
if ( firstBiggerThanSecond ( cutoffId , range . lower ) ) {
// 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
if ( firstBiggerThanSecond ( cutoffId , range . upper ) ) {
2022-08-11 16:38:53 +02:00
await this . deleteRange ( typeRef , listId )
2022-04-20 10:39:52 +02:00
} else {
2022-08-11 16:38:53 +02:00
await this . setLowerRangeForList ( typeRef , listId , cutoffId )
2022-04-20 10:39:52 +02:00
}
}
}
2022-08-11 16:38:53 +02:00
private serialize ( originalEntity : SomeEntity ) : Uint8Array {
return cborg . encode ( originalEntity , { typeEncoders : customTypeEncoders } )
2022-04-20 10:39:52 +02:00
}
2022-08-11 16:38:53 +02:00
private deserialize < T extends SomeEntity > ( typeRef : TypeRef < T > , loaded : Uint8Array ) : T {
const deserialized = cborg . decode ( loaded , { tags : customTypeDecoders } )
// TypeRef cannot be deserialized back automatically. We could write a codec for it but we don't actually
// need to store it so we just "patch" it.
// Some places rely on TypeRef being a class and not a plain object.
deserialized . _type = typeRef
return deserialized
2022-04-20 10:39:52 +02:00
}
2022-05-17 17:40:44 +02:00
2022-08-11 16:38:53 +02:00
private deserializeList < T extends SomeEntity > ( typeRef : TypeRef < T > , loaded : Array < Uint8Array > ) : Array < T > {
return loaded . map ( entity = > this . deserialize ( typeRef , entity ) )
2022-05-17 17:40:44 +02:00
}
2022-08-11 16:38:53 +02:00
}
2022-07-04 14:55:17 +02:00
2022-08-11 16:38:53 +02:00
/ *
2022-08-15 14:22:44 +02:00
* used to automatically create the right amount of query params for selecting ids from a dynamic list .
* must be used within sql ` <query> ` template string to inline the logic into the query
2022-08-11 16:38:53 +02:00
* /
function paramList ( params : SqlValue [ ] ) : SqlFragment {
const qs = params . map ( ( ) = > '?' ) . join ( "," )
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
* must be used within sql ` <query> ` template string to inline the logic 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 = "?"
}
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-15 14:22:44 +02:00
* this tagged template function exists because android doesn ' t allow us to define SQL functions , so we have made a way to inline
* SQL fragments into queries .
* to make it less error - prone , we automate the generation of the params array for the actual sql call .
* In this way , we offload the escaping of actual user content to the SQL engine , which makes this safe from an SQLI point of view .
2022-08-11 16:38:53 +02:00
*
* usage example :
* const type = "sys/User"
* const listId = "someList"
* const startId = "ABC"
2022-08-15 14:22:44 +02:00
* sql ` SELECT entity FROM list_entities WHERE type = ${ type } AND listId = ${ listId } AND ${ firstIdBigger ( startId , "elementId" ) } `
*
* this will result in
* const { query , params } = {
* query : ` SELECT entity FROM list_entities WHERE type = ? AND listId = ? AND (CASE WHEN length(?) > length(elementId) THEN 1 WHEN length(?) < length(elementId) THEN 0 ELSE ? > elementId END) ` ,
* params : [
* { type : SqlType . String , value : "sys/User" } ,
* { type : SqlType . String , value : "someList" } ,
* { type : SqlType . String , value : "ABC" } ,
* { type : SqlType . String , value : "ABC" } ,
* { type : SqlType . String , value : "ABC" }
* ]
* }
2022-08-11 16:38:53 +02:00
*
2022-08-15 14:22:44 +02:00
* which can be consumed by sql . run ( query , params )
2022-08-11 16:38:53 +02:00
* /
export function sql ( queryParts : TemplateStringsArray , . . . paramInstances : ( SqlValue | SqlFragment ) [ ] ) : { query : string , params : TaggedSqlValue [ ] } {
let query = ""
let params : TaggedSqlValue [ ] = [ ]
let i = 0
for ( i = 0 ; i < paramInstances . length ; i ++ ) {
query += queryParts [ i ]
const param = paramInstances [ i ]
if ( param instanceof SqlFragment ) {
query += param . text
params . push ( . . . param . params . map ( tagSqlValue ) )
} else {
query += "?"
params . push ( tagSqlValue ( param ) )
2022-07-04 14:55:17 +02:00
}
}
2022-08-11 16:38:53 +02:00
query += queryParts [ i ]
return { query , params }
2022-04-20 10:39:52 +02:00
}
2022-08-11 16:38:53 +02:00
class SqlFragment {
constructor (
readonly text : string ,
2022-08-15 14:22:44 +02:00
readonly params : SqlValue [ ]
2022-08-11 16:38:53 +02:00
) {
}
}