tutanota/src/common/api/worker/offline/OfflineStorage.ts

1146 lines
45 KiB
TypeScript
Raw Normal View History

import { BlobElementEntity, ElementEntity, Entity, ListElementEntity, ServerModelParsedInstance, SomeEntity, TypeModel } from "../../common/EntityTypes.js"
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"
import * as cborg from "cborg"
2022-12-27 15:37:40 +01:00
import { EncodeOptions, Token, Type } from "cborg"
import {
assert,
assertNotNull,
Base64Ext,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
getFirstOrThrow,
getTypeString,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
groupBy,
groupByAndMap,
isEmpty,
mapNullable,
Nullable,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
parseTypeString,
splitInChunks,
typedEntries,
typedValues,
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"
import { TokenOrNestedTokens } from "cborg/interface"
2022-12-27 15:37:40 +01:00
import { OfflineStorageMigrator } from "./OfflineStorageMigrator.js"
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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"
import { FormattedQuery, SqlValue, TaggedSqlValue, tagSqlValue, untagSqlObject, untagSqlValue } from "./SqlValue.js"
import { Type as TypeId } from "../../common/EntityConstants.js"
import { OutOfSyncError } from "../../common/error/OutOfSyncError.js"
import { sql, SqlFragment } from "./Sql.js"
import { ModelMapper } from "../crypto/ModelMapper"
import { AttributeModel } from "../../common/AttributeModel"
import { TypeModelResolver } from "../../common/EntityFunctions"
import { collapseId, expandId } from "../rest/RestClientIdUtils"
2025-07-02 10:07:53 +02:00
import { Category, syncMetrics } from "../utils/SyncMetrics"
/**
* 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
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"],
])
function dateEncoder(data: Date, typ: string, options: EncodeOptions): TokenOrNestedTokens | null {
const time = data.getTime()
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),
]
}
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,
})
type TypeDecoder = (_: any) => any
export const customTypeDecoders: Array<TypeDecoder> = (() => {
const tags: Array<TypeDecoder> = []
tags[100] = dateDecoder
return tags
})()
export interface OfflineDbMeta {
2022-12-27 15:37:40 +01:00
lastUpdateTime: number
timeRangeDays: number
// offline db schema version
"offline-version": number
[antispam] Add client-side local spam filtering Implement a local machine learning model for client-side spam filtering. The local model is implemented using tensorflow "LayersModel" to train separate models in all available mailboxes, resulting in one model per ownerGroup (i.e. mailbox). Initially, the training data is aggregated from the last 30 days of received mails, and the data is stored in a separate offline database table named spam_classification_training_data. The trained model is stored in the table spam_classification_model. The initial training starts after indexing, with periodic training happening every 30 minutes and on each subsequent login. The model will predict on incoming mails once we have received the entity event for said mail, moving it to either inbox or spam folder. When users move mails, we update the training data labels accordingly, by adjusting the isSpam classification and isSpamConfidence values in the offline database. The MoveMailService now contains a moveReason, which indicates that the mail has been moved by our spam filter. Client-side spam filtering can be activated using the SpamClientClassification feature flag, and is for now only available on the desktop client. Co-authored-by: sug <sug@tutao.de> Co-authored-by: kib <104761667+kibibytium@users.noreply.github.com> Co-authored-by: abp <abp@tutao.de> Co-authored-by: map <mpfau@users.noreply.github.com> Co-authored-by: jhm <17314077+jomapp@users.noreply.github.com> Co-authored-by: frm <frm@tutao.de> Co-authored-by: das <das@tutao.de> Co-authored-by: nif <nif@tutao.de> Co-authored-by: amm <amm@tutao.de>
2025-10-14 12:32:17 +02:00
lastTrainedTime: number
lastTrainedFromScratchTime: number
}
2025-02-10 13:15:28 +01:00
export const TableDefinitions = Object.freeze({
// plus ownerGroup added in a migration
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,
},
// plus ownerGroup added in a migration
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>
type Range = { lower: Id; upper: Id }
export interface OfflineStorageInitArgs {
2022-12-27 15:37:40 +01:00
userId: Id
databaseKey: Uint8Array
timeRangeDate: Date | null
forceNewDatabase: boolean
}
/**
* 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
/**
* 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>
}
export class OfflineStorage implements CacheStorage {
private userId: Id | null = null
private databaseKey: Uint8Array | null = null
private timeRangeDate: Date | null = null
private readonly allTables: Record<string, OfflineStorageTable>
constructor(
private readonly sqlCipherFacade: SqlCipherFacade,
private readonly interWindowEventSender: InterWindowEventFacadeSendDispatcher,
private readonly dateProvider: DateProvider,
private readonly migrator: OfflineStorageMigrator,
private readonly cleaner: OfflineStorageCleaner,
private readonly modelMapper: ModelMapper,
private readonly typeModelResolver: TypeModelResolver,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
private readonly customCacheHandler: CustomCacheHandlerMap,
additionalTables: Record<string, OfflineStorageTable>,
) {
assert(isOfflineStorageAvailable() || isTest(), "Offline storage is not available.")
this.allTables = Object.freeze(Object.assign({}, additionalTables, TableDefinitions))
}
async getWholeListParsed(typeRef: TypeRef<unknown>, listId: string): Promise<ServerModelParsedInstance[]> {
const { query, params } = sql`SELECT entity
FROM list_entities
WHERE type = ${getTypeString(typeRef)}
AND listId = ${listId}`
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)
}
/**
* @return {boolean} whether the database was newly created or not
*/
async init({ userId, databaseKey, timeRangeDate, forceNewDatabase }: OfflineStorageInitArgs): Promise<boolean> {
this.userId = userId
this.databaseKey = databaseKey
this.timeRangeDate = timeRangeDate
if (forceNewDatabase) {
if (isDesktop()) {
await this.interWindowEventSender.localUserDataInvalidated(userId)
}
await this.sqlCipherFacade.deleteDb(userId)
}
// We open database here, and it is closed in the native side when the window is closed or the page is reloaded
await this.sqlCipherFacade.openDb(userId, databaseKey)
await this.createTables()
try {
await this.migrator.migrate(this, this.sqlCipherFacade)
} catch (e) {
if (e instanceof OutOfSyncError) {
console.warn("Offline db is out of sync!", e)
await this.recreateDbFile(userId, databaseKey)
await this.migrator.migrate(this, this.sqlCipherFacade)
} else {
throw e
}
}
// We createTables again in case they were purged in a migration
await this.createTables()
// if nothing is written here, it means it's a new database
return (await this.getLastUpdateTime()).type === "never"
}
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-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)
*/
async deinit() {
this.userId = null
this.databaseKey = null
await this.sqlCipherFacade.closeDb()
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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])
}
async deleteAllOfType(typeRef: TypeRef<SomeEntity>): Promise<void> {
const type = getTypeString(typeRef)
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
let formattedQuery
switch (typeModel.type) {
case TypeId.Element:
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
formattedQuery = sql`SELECT elementId
FROM element_entities
WHERE type = ${type}`
break
case TypeId.ListElement:
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
formattedQuery = sql`SELECT listId, elementId
FROM list_entities
WHERE type = ${type}`
await this.deleteAllRangesForType(type)
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
break
case TypeId.BlobElement:
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
formattedQuery = sql`SELECT listId, elementId
FROM blob_element_entities
WHERE type = ${type}`
break
default:
throw new Error("must be a persistent type")
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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)
}
/**
* Remove all ranges (and only ranges, without associated data) for the specified {@param typeRef}.
*/
async deleteAllRangesOfType(typeRef: TypeRef<SomeEntity>): Promise<void> {
const type = getTypeString(typeRef)
await this.deleteAllRangesForType(type)
}
private async deleteAllRangesForType(type: string): Promise<void> {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`DELETE
FROM ranges
WHERE type = ${type}`
await this.sqlCipherFacade.run(query, params)
}
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)
const type = getTypeString(typeRef)
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
const encodedElementId = ensureBase64Ext(typeModel, id)
let formattedQuery
switch (typeModel.type) {
2023-01-12 16:48:28 +01:00
case TypeId.Element:
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
formattedQuery = sql`SELECT entity
from element_entities
WHERE type = ${type}
AND elementId = ${encodedElementId}`
break
2023-01-12 16:48:28 +01:00
case TypeId.ListElement:
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
formattedQuery = sql`SELECT entity
from list_entities
WHERE type = ${type}
AND listId = ${listId}
AND elementId = ${encodedElementId}`
break
2023-01-12 16:48:28 +01:00
case TypeId.BlobElement:
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
formattedQuery = sql`SELECT entity
from blob_element_entities
WHERE type = ${type}
AND listId = ${listId}
AND elementId = ${encodedElementId}`
break
default:
throw new Error("must be a persistent type")
}
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
}
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)
if (elementIds.length === 0) return []
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
const encodedElementIds = elementIds.map((elementId) => ensureBase64Ext(typeModel, elementId))
const type = getTypeString(typeRef)
const serializedList: ReadonlyArray<Record<string, TaggedSqlValue>> = await this.allChunked(1000, encodedElementIds, (c) => {
if (typeModel.type === TypeId.Element) {
return sql`SELECT entity
FROM element_entities
WHERE type = ${type}
AND elementId IN ${paramList(c)}`
} else if (typeModel.type === TypeId.ListElement) {
return sql`SELECT entity
FROM list_entities
WHERE type = ${type}
AND listId = ${listId}
AND elementId IN ${paramList(c)}`
} else if (typeModel.type === TypeId.BlobElement) {
return sql`SELECT entity
FROM blob_element_entities
WHERE type = ${type}
AND listId = ${listId}
AND elementId IN ${paramList(c)}`
} 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
}
async getIdsInRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<Id>> {
const type = getTypeString(typeRef)
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
const range = await this.getRange(typeRef, listId)
if (range == null) {
throw new Error(`no range exists for ${type} and list ${listId}`)
}
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`SELECT elementId
FROM list_entities
WHERE type = ${type}
AND listId = ${listId}
AND (elementId = ${range.lower}
OR ${firstIdBigger("elementId", range.lower)})
AND NOT (${firstIdBigger("elementId", range.upper)})`
const rows = await this.sqlCipherFacade.all(query, params)
return rows.map((row) => customIdToBase64Url(typeModel, row.elementId.value as string))
}
/** don't use this internally in this class, use OfflineStorage::getRange instead. OfflineStorage is
* using converted custom IDs internally which is undone when using this to access the range.
*/
async getRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Range | null> {
let range = await this.getRange(typeRef, listId)
if (range == null) return range
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
return {
lower: customIdToBase64Url(typeModel, range.lower),
upper: customIdToBase64Url(typeModel, range.upper),
}
}
async isElementIdInCacheRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementId: Id): Promise<boolean> {
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
const encodedElementId = ensureBase64Ext(typeModel, elementId)
const range = await this.getRange(typeRef, listId)
return range != null && !firstBiggerThanSecond(encodedElementId, range.upper) && !firstBiggerThanSecond(range.lower, encodedElementId)
}
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)
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
const encodedStartId = ensureBase64Ext(typeModel, start)
const type = getTypeString(typeRef)
let formattedQuery
if (reverse) {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
formattedQuery = sql`SELECT entity
FROM list_entities
WHERE type = ${type}
AND listId = ${listId}
AND ${firstIdBigger(encodedStartId, "elementId")}
ORDER BY LENGTH(elementId) DESC, elementId DESC LIMIT ${count}`
} else {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
formattedQuery = sql`SELECT entity
FROM list_entities
WHERE type = ${type}
AND listId = ${listId}
AND ${firstIdBigger("elementId", encodedStartId)}
ORDER BY LENGTH(elementId) ASC, elementId ASC LIMIT ${count}`
}
const { query, params } = formattedQuery
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
}
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)
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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()
}
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
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
const handler = this.getCustomCacheHandlerMap().get(typeRef)
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")
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
}
const type = typeModel.type
const table = assertNotNull(tableNameByTypeId.get(type))
const storables = await this.toStorables(instances, typeModel, typeString, table)
const groupedByListId = groupBy(storables, (dbRef) => dbRef.listId)
for (const [listId, storableInstances] of groupedByListId) {
await this.fetchRowIds(typeModel, table, typeString, listId, storableInstances)
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01: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
return [rowId, typeString, encodedElementId, ownerGroup, serializedInstance] satisfies Array<SqlValue>
})
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()
}
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(
instances.map(async (instance): Promise<Nullable<StorableInstance>> => {
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,
}
}),
)
return storables.filter((storable) => storable !== null)
}
private async fetchRowIds(
typeModel: TypeModel,
table: string,
typeString: string,
listId: Nullable<Id>,
storableInstances: StorableInstance[],
): Promise<void> {
const ids = storableInstances.map((dbRefs) => dbRefs.encodedElementId)
let formattedQuery: FormattedQuery
if (typeModel.type === TypeId.Element) {
formattedQuery = sql`SELECT elementId, rowid
FROM element_entities
WHERE type = ${typeString}
and elementId IN ${paramList(ids)}`
} else if (typeModel.type === TypeId.ListElement) {
formattedQuery = sql`SELECT elementId, listId, rowid
FROM list_entities
WHERE type = ${typeString}
and listId = ${listId}
and elementId IN ${paramList(ids)}`
} else if (typeModel.type === TypeId.BlobElement) {
formattedQuery = sql`SELECT elementId, listId, rowid
FROM blob_element_entities
WHERE type = ${typeString}
and listId = ${listId}
and elementId IN ${paramList(ids)}`
} else {
throw new Error("Can't fetch row ids for invalid type")
}
const resultRows = await this.sqlCipherFacade.all(formattedQuery.query, formattedQuery.params)
// 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 }
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,
)
assertNotNull(storable).rowId = assertNotNull(row.rowid)
}
}
async setLowerRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lowerId: Id): Promise<void> {
let typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
lowerId = ensureBase64Ext(typeModel, lowerId)
const type = getTypeString(typeRef)
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`UPDATE ranges
SET lower = ${lowerId}
WHERE type = ${type}
AND listId = ${listId}`
await this.sqlCipherFacade.run(query, params)
}
async setUpperRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, upperId: Id): Promise<void> {
upperId = ensureBase64Ext(await this.typeModelResolver.resolveClientTypeReference(typeRef), upperId)
const type = getTypeString(typeRef)
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`UPDATE ranges
SET upper = ${upperId}
WHERE type = ${type}
AND listId = ${listId}`
await this.sqlCipherFacade.run(query, params)
}
async setNewRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lower: Id, upper: Id): Promise<void> {
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
lower = ensureBase64Ext(typeModel, lower)
upper = ensureBase64Ext(typeModel, upper)
const type = getTypeString(typeRef)
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`INSERT
OR REPLACE INTO ranges VALUES (
${type},
${listId},
${lower},
${upper}
)`
return this.sqlCipherFacade.run(query, params)
}
async getLastBatchIdForGroup(groupId: Id): Promise<Id | null> {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`SELECT batchId
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
return (row?.batchId?.value ?? null) as Id | null
}
async putLastBatchIdForGroup(groupId: Id, batchId: Id): Promise<void> {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`INSERT
OR REPLACE INTO lastUpdateBatchIdPerGroupId VALUES (
${groupId},
${batchId}
)`
await this.sqlCipherFacade.run(query, params)
}
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" }
}
async putLastUpdateTime(ms: number): Promise<void> {
await this.putMetadata("lastUpdateTime", ms)
}
[antispam] Add client-side local spam filtering Implement a local machine learning model for client-side spam filtering. The local model is implemented using tensorflow "LayersModel" to train separate models in all available mailboxes, resulting in one model per ownerGroup (i.e. mailbox). Initially, the training data is aggregated from the last 30 days of received mails, and the data is stored in a separate offline database table named spam_classification_training_data. The trained model is stored in the table spam_classification_model. The initial training starts after indexing, with periodic training happening every 30 minutes and on each subsequent login. The model will predict on incoming mails once we have received the entity event for said mail, moving it to either inbox or spam folder. When users move mails, we update the training data labels accordingly, by adjusting the isSpam classification and isSpamConfidence values in the offline database. The MoveMailService now contains a moveReason, which indicates that the mail has been moved by our spam filter. Client-side spam filtering can be activated using the SpamClientClassification feature flag, and is for now only available on the desktop client. Co-authored-by: sug <sug@tutao.de> Co-authored-by: kib <104761667+kibibytium@users.noreply.github.com> Co-authored-by: abp <abp@tutao.de> Co-authored-by: map <mpfau@users.noreply.github.com> Co-authored-by: jhm <17314077+jomapp@users.noreply.github.com> Co-authored-by: frm <frm@tutao.de> Co-authored-by: das <das@tutao.de> Co-authored-by: nif <nif@tutao.de> Co-authored-by: amm <amm@tutao.de>
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)
}
async getLastTrainedFromScratchTime(): Promise<number> {
return (await this.getMetadata("lastTrainedFromScratchTime")) ?? Date.now()
}
async setLastTrainedFromScratchTime(ms: number): Promise<void> {
await this.putMetadata("lastTrainedFromScratchTime", ms)
}
async purgeStorage(): Promise<void> {
if (this.userId == null || this.databaseKey == null) {
console.warn("not purging storage since we don't have an open db")
return
}
for (const [tableName, { purgedWithCache, onBeforePurged }] of typedEntries(this.allTables)) {
if (onBeforePurged != null) {
await onBeforePurged(this.sqlCipherFacade)
}
if (purgedWithCache) {
await this.sqlCipherFacade.run(`DROP TABLE IF EXISTS ${tableName}`, [])
}
}
}
async deleteRange(typeRef: TypeRef<unknown>, listId: string): Promise<void> {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`DELETE
FROM ranges
WHERE type = ${getTypeString(typeRef)}
AND listId = ${listId}`
await this.sqlCipherFacade.run(query, params)
}
async getElementsOfType<T extends ElementEntity>(typeRef: TypeRef<T>): Promise<Array<T>> {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`SELECT entity
from element_entities
WHERE type = ${getTypeString(typeRef)}`
2022-12-27 15:37:40 +01:00
const items = (await this.sqlCipherFacade.all(query, params)) ?? []
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
}
async getWholeList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<T>> {
const parsedInstances = await this.getWholeListParsed(typeRef, listId)
return await this.modelMapper.mapToInstances(typeRef, parsedInstances)
}
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)
return Object.fromEntries(stored.map(([key, value]) => [key, cborg.decode(value)])) as OfflineDbMeta
}
async setCurrentOfflineSchemaVersion(version: number) {
return this.putMetadata("offline-version", version)
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
getCustomCacheHandlerMap(): CustomCacheHandlerMap {
return this.customCacheHandler
}
getUserId(): Id {
return assertNotNull(this.userId, "No user id, not initialized?")
}
async deleteAllOwnedBy(owner: Id): Promise<void> {
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
await this.deleteAllElementTypesOwnedBy(owner)
await this.deleteAllListElementTypesOwnedBy(owner)
await this.deleteAllBlobElementTypesOwnedBy(owner)
{
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`DELETE
FROM lastUpdateBatchIdPerGroupId
WHERE groupId = ${owner}`
await this.sqlCipherFacade.run(query, params)
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
}
private async deleteAllBlobElementTypesOwnedBy(owner: Id) {
const { query, params } = sql`SELECT listId, elementId, type
FROM blob_element_entities
WHERE ownerGroup = ${owner}`
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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 SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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
FROM list_entities
WHERE ownerGroup = ${owner}`
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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
FROM ranges
WHERE type = ${type}
AND listId IN ${paramList(c)}`,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
)
await this.deleteByIds(
typeRef,
rows.map((row) => [row.listId, row.elementId]),
)
}
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
private async deleteAllElementTypesOwnedBy(owner: Id) {
const { query, params } = sql`SELECT elementId, type
FROM element_entities
WHERE ownerGroup = ${owner}`
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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)
}
}
private async putMetadata<K extends keyof OfflineDbMeta>(key: K, value: OfflineDbMeta[K]): Promise<void> {
let encodedValue
try {
encodedValue = cborg.encode(value)
} catch (e) {
console.log("[OfflineStorage] failed to encode metadata for key", key, "with value", value)
throw e
}
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`INSERT
OR REPLACE INTO metadata VALUES (
${key},
${encodedValue}
)`
await this.sqlCipherFacade.run(query, params)
}
private async getMetadata<K extends keyof OfflineDbMeta>(key: K): Promise<OfflineDbMeta[K] | null> {
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
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)
}
/**
* Clear out unneeded data from the offline database (i.e. old data).
* This will be called after login (CachePostLoginActions.ts) to ensure fast login time.
* @param timeRangeDate the maximum age that mails should be to be kept in the database
* @param userId id of the current user. default, last stored userId
*/
async clearExcludedData(timeRangeDate: Date | null = this.timeRangeDate, userId: Id = this.getUserId()): Promise<void> {
await this.cleaner.cleanOfflineDb(this, timeRangeDate, userId, this.dateProvider.now())
}
private async createTables() {
for (const { definition } of typedValues(this.allTables)) {
await this.sqlCipherFacade.run(definition, [])
}
}
private async getRange(typeRef: TypeRef<ElementEntity | ListElementEntity>, listId: Id): Promise<Range | null> {
const type = getTypeString(typeRef)
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
const { query, params } = sql`SELECT upper, lower
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
return mapNullable(row, untagSqlObject) as Range | null
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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.
* Will invoke {@link CustomCacheHandler#onBeforeCacheDeletion}.
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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)
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
const handler = this.getCustomCacheHandlerMap().get(typeRef)
if (handler && handler.onBeforeCacheDeletion) {
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
for (const id of ids) {
await handler.onBeforeCacheDeletion(id)
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
}
}
switch (typeModel.type) {
2023-01-12 16:48:28 +01:00
case TypeId.Element:
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
await this.runChunked(
MAX_SAFE_SQL_VARS - 1,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
(ids as Id[]).map((id) => ensureBase64Ext(typeModel, id)),
Fix outdated mails from notification Notification process on mobile does insert Mail instances into offline db in order to show the mail preview as quickly as possible when the user clicks on notification. In most cases it happens before login, and it happens independently of entity update processing. EntityRestCache does only download new list element instances when they are within the cached range. In most cases new mails are within the cached range as mail indexer caches mail bag ranges during initial indexing. However, if the mail bag size has been reached there will be a new mail bag with mail list that is not cached by the client (there is no range stored for this new list). EventQueue optimizes event updates. If CREATE event is followed by an UPDATE event they will be folded into a single CREATE event. In a situation where the email has been updated by another client after it has been inserted into offline db by notification process but before the login, and that email belongs to uncached mail bag, CREATE event (and optimized away UPDATE events) for that mail would be skipped and the user would see an outdated Mail instance. We changed the behavior for Mail instances to be always downloaded on CREATE event when we use Offline cache so that we do not miss those update operations. This was the previously expected behavior because of mail indexing and is also a common behavior now, until new mail bag is created so it does not lead to additional requests in most cases. Alternatively we could check if the (new) instance is already cached as individual instance. We decided against that as it adds the delay to event processing and also does not fit the logic of processing (why should cache check if new instance is already cache?). In the future we should try to optimize loading of new instances so that the sync time does not increase from downloading single emails. Close #8041 Co-authored-by: ivk <ivk@tutao.de>
2024-12-17 15:46:05 +01:00
(c) => sql`DELETE
FROM element_entities
WHERE type = ${type}
AND elementId IN ${paramList(c)}`,
)
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
break
2023-01-12 16:48:28 +01:00
case TypeId.ListElement:
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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
FROM list_entities
WHERE type = ${type}
AND listId = ${listId}
AND elementId IN ${paramList(c)}`,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
)
}
}
break
2023-01-12 16:48:28 +01:00
case TypeId.BlobElement:
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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
FROM blob_element_entities
WHERE type = ${type}
AND listId = ${listId}
AND elementId IN ${paramList(c)}`,
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
2025-03-13 16:37:55 +01:00
)
}
}
break
default:
throw new Error("must be a persistent type")
}
}
Add SQLite search on clients where offline storage is available - Introduce a separate Indexer for SQLite using FTS5 - Split search backends and use the right one based on client (IndexedDB for Browser, and OfflineStorage everywhere else) - Split SearchFacade into two implementations - Adds a table for storing unindexed metadata for mails - Escape special character for SQLite search To escape special characters from fts5 syntax. However, simply surrounding each token in quotes is sufficient to do this. See section 3.1 "FTS5 Strings" here: https://www.sqlite.org/fts5.html which states that a string may be specified by surrounding it in quotes, and that special string requirements only exist for strings that are not in quotes. - Add EncryptedDbWrapper - Simplify out of sync logic in IndexedDbIndexer - Fix deadlock when initializing IndexedDbIndexer - Cleanup indexedDb index when migrating to offline storage index - Pass contactSuggestionFacade to IndexedDbSearchFacade The only suggestion facade used by IndexedDbSearchFacade was the contact suggestion facade. So we made it clearer. - Remove IndexerCore stats - Split custom cache handlers into separate files We were already doing this with user, so we should do this with the other entity types. - Rewrite IndexedDb tests - Add OfflineStorage indexer tests - Add custom cache handlers tests to OfflineStorageTest - Add tests for custom cache handlers with ephemeral storage - Use dbStub instead of dbMock in IndexedDbIndexerTest - Replace spy with testdouble in IndexedDbIndexerTest Close #8550 Co-authored-by: ivk <ivk@tutao.de> Co-authored-by: paw <paw-hub@users.noreply.github.com> Co-authored-by: wrd <wrd@tutao.de> Co-authored-by: bir <bir@tutao.de> Co-authored-by: hrb-hub <hrb-hub@users.noreply.github.com>
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)
}
async updateRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, rawCutoffId: Id): Promise<void> {
const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef)
const isCustomId = isCustomIdType(typeModel)
const encodedCutoffId = ensureBase64Ext(typeModel, rawCutoffId)
const range = await this.getRange(typeRef, listId)
if (range == null) {
return
}
// 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
// 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
const expectedMinId = isCustomId ? CUSTOM_MIN_ID : GENERATED_MIN_ID
if (range.lower === expectedMinId) {
const entities = await this.provideFromRange(typeRef, listId, expectedMinId, 1, false)
const id = mapNullable(entities[0], getElementId)
// !!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)
if (rangeWontBeModified) {
return
}
}
if (firstBiggerThanSecond(encodedCutoffId, 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(encodedCutoffId, range.upper)) {
await this.deleteRange(typeRef, listId)
} else {
await this.setLowerRangeForList(typeRef, listId, rawCutoffId)
}
}
}
private async serialize(parsedInstance: ServerModelParsedInstance): Promise<Uint8Array> {
try {
return cborg.encode(parsedInstance, { typeEncoders: customTypeEncoders })
} catch (e) {
console.log("[OfflineStorage] failed to encode entity with attribute ids: " + Object.keys(parsedInstance))
throw e
}
}
/**
* Convert the type from CBOR representation to the runtime type
*/
private async deserialize(loaded: Uint8Array): Promise<ServerModelParsedInstance | null> {
2024-09-20 16:10:37 +02:00
try {
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
}
}
private async deserializeList(loaded: Array<Uint8Array>): Promise<Array<ServerModelParsedInstance>> {
// manually reimplementing promiseMap to make sure we don't hit the scheduler since there's nothing actually async happening
const result: Array<ServerModelParsedInstance> = []
for (const entity of loaded) {
const deserialized = await this.deserialize(entity)
2024-09-20 16:10:37 +02:00
if (deserialized != null) {
result.push(deserialized)
}
}
return result
}
/**
* 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)
}
}
/**
* 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
}
}
/*
* 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.
*/
function paramList(params: SqlValue[]): SqlFragment {
2022-12-27 15:37:40 +01:00
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.
*
* will always insert 3 constants and 3 SQL variables into the query.
*/
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])
}
export interface OfflineStorageCleaner {
/**
* Delete instances from db that are older than timeRangeDays.
*/
cleanOfflineDb(offlineStorage: OfflineStorage, timeRangeDate: Date | null, userId: Id, now: number): Promise<void>
}
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
FROM sqlite_schema
WHERE name = ${table}`
const result = assertNotNull(await sqlCipherFacade.get(query, params))
return untagSqlValue(result["metadata_exists"]) === 1
}