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" import { EncodeOptions, Token, Type } from "cborg" import { assert, assertNotNull, Base64Ext, getFirstOrThrow, getTypeString, groupBy, groupByAndMap, isEmpty, mapNullable, Nullable, parseTypeString, splitInChunks, typedEntries, typedValues, TypeRef, } from "@tutao/tutanota-utils" import { isDesktop, isOfflineStorageAvailable, isTest } from "../../common/Env.js" import { DateProvider } from "../../common/DateProvider.js" import { TokenOrNestedTokens } from "cborg/interface" import { OfflineStorageMigrator } from "./OfflineStorageMigrator.js" import { CustomCacheHandlerMap } from "../rest/cacheHandler/CustomCacheHandler.js" 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" import { Category, syncMetrics } from "../utils/SyncMetrics" import { hasError } from "../../common/utils/ErrorUtils" /** * 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 listId: Nullable elementId: Id encodedElementId: Base64Ext ownerGroup: Id serializedInstance: Uint8Array instance: ServerModelParsedInstance } const tableNameByTypeId: Map = 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), new Token(time < 0 ? Type.negint : Type.uint, time), ] } function dateDecoder(bytes: number): Date { return new Date(bytes) } export const customTypeEncoders: { [typeName: string]: typeof dateEncoder } = Object.freeze({ Date: dateEncoder, }) type TypeDecoder = (_: any) => any export const customTypeDecoders: Array = (() => { const tags: Array = [] tags[100] = dateDecoder return tags })() export interface OfflineDbMeta { lastUpdateTime: number timeRangeDays: number // offline db schema version "offline-version": number } 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 type Range = { lower: Id; upper: Id } export interface OfflineStorageInitArgs { 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 } export class OfflineStorage implements CacheStorage { private userId: Id | null = null private databaseKey: Uint8Array | null = null private timeRangeDate: Date | null = null private readonly allTables: Record 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, private readonly customCacheHandler: CustomCacheHandlerMap, additionalTables: Record, ) { assert(isOfflineStorageAvailable() || isTest(), "Offline storage is not available.") this.allTables = Object.freeze(Object.assign({}, additionalTables, TableDefinitions)) } async getWholeListParsed(typeRef: TypeRef, listId: string): Promise { 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(typeRef: TypeRef, listId: string | null, id: string): Promise { const parsedInstance = await this.getParsed(typeRef, listId, id) if (parsedInstance == null) { return null } return await this.modelMapper.mapToInstance(typeRef, parsedInstance) } async provideMultiple(typeRef: TypeRef, listId: string, elementIds: string[]): Promise { 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 { 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 { 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() } /** * 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() } async deleteIfExists( typeRef: TypeRef, listId: T extends ListElementEntity | BlobElementEntity ? Id : null, elementId: Id, ): Promise { const fullId: T["_id"] = listId == null ? elementId : [listId, elementId] await this.deleteByIds(typeRef, [fullId]) } async deleteAllOfType(typeRef: TypeRef): Promise { const type = getTypeString(typeRef) const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef) let formattedQuery switch (typeModel.type) { case TypeId.Element: formattedQuery = sql`SELECT elementId FROM element_entities WHERE type = ${type}` break case TypeId.ListElement: formattedQuery = sql`SELECT listId, elementId FROM list_entities WHERE type = ${type}` await this.deleteAllRangesForType(type) break case TypeId.BlobElement: formattedQuery = sql`SELECT listId, elementId FROM blob_element_entities WHERE type = ${type}` break default: throw new Error("must be a persistent type") } 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): Promise { const type = getTypeString(typeRef) await this.deleteAllRangesForType(type) } private async deleteAllRangesForType(type: string): Promise { const { query, params } = sql`DELETE FROM ranges WHERE type = ${type}` await this.sqlCipherFacade.run(query, params) } async getParsed(typeRef: TypeRef, listId: Id | null, id: Id): Promise { 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) { case TypeId.Element: formattedQuery = sql`SELECT entity from element_entities WHERE type = ${type} AND elementId = ${encodedElementId}` break case TypeId.ListElement: formattedQuery = sql`SELECT entity from list_entities WHERE type = ${type} AND listId = ${listId} AND elementId = ${encodedElementId}` break case TypeId.BlobElement: 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") } 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(typeRef: TypeRef, listId: Id, elementIds: Id[]): Promise> { 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> = 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)}`) } }) const result = await this.deserializeList(serializedList.map((r) => r.entity.value as Uint8Array)) tm?.endMeasurement() return result } async getIdsInRange(typeRef: TypeRef, listId: Id): Promise> { 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}`) } 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(typeRef: TypeRef, listId: Id): Promise { 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(typeRef: TypeRef, listId: Id, elementId: Id): Promise { 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( typeRef: TypeRef, listId: Id, start: Id, count: number, reverse: boolean, ): Promise { 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) { 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 { 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> = await this.sqlCipherFacade.all(query, params) const result = await this.deserializeList(serializedList.map((r) => r.entity.value as Uint8Array)) tm?.endMeasurement() return result } async provideFromRange(typeRef: TypeRef, listId: Id, start: Id, count: number, reverse: boolean): Promise> { const parsed = await this.provideFromRangeParsed(typeRef, listId, start, count, reverse) return await this.modelMapper.mapToInstances(typeRef, parsed) } async put(typeRef: TypeRef, instance: ServerModelParsedInstance): Promise { const tm = syncMetrics?.beginMeasurement(Category.PutDb) await this.putMultiple(typeRef, [instance]) tm?.endMeasurement() } async putMultiple(typeRef: TypeRef, instances: ServerModelParsedInstance[]): Promise { const tm = instances.length > 1 ? syncMetrics?.beginMeasurement(Category.PutMultipleDb) : null 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") } 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) } 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> = chunk.map((storable) => { const { rowId, typeString, encodedElementId, ownerGroup, serializedInstance } = storable return [rowId, typeString, encodedElementId, ownerGroup, serializedInstance] satisfies Array }) 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> = chunk.map((storable) => { const { rowId, typeString, encodedElementId, ownerGroup, serializedInstance } = storable return [rowId, typeString, listId, encodedElementId, ownerGroup, serializedInstance] as Array }) 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> = chunk.map((storable) => { const { rowId, typeString, encodedElementId, ownerGroup, serializedInstance } = storable return [rowId, typeString, listId, encodedElementId, ownerGroup, serializedInstance] as Array }) 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) } } tm?.endMeasurement() } private insertMultipleFormattedQuery(query: string, nestedlistOfParams: Array>): 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, typeModel: TypeModel, typeString: string, table: string, ): Promise> { const storables = await Promise.all( instances.map(async (instance): Promise> => { const { listId, elementId } = expandId(AttributeModel.getAttribute(instance, "_id", typeModel)) const ownerGroup = AttributeModel.getAttribute(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, storableInstances: StorableInstance[], ): Promise { 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(typeRef: TypeRef, listId: Id, lowerId: Id): Promise { let typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef) lowerId = ensureBase64Ext(typeModel, lowerId) const type = getTypeString(typeRef) const { query, params } = sql`UPDATE ranges SET lower = ${lowerId} WHERE type = ${type} AND listId = ${listId}` await this.sqlCipherFacade.run(query, params) } async setUpperRangeForList(typeRef: TypeRef, listId: Id, upperId: Id): Promise { upperId = ensureBase64Ext(await this.typeModelResolver.resolveClientTypeReference(typeRef), upperId) const type = getTypeString(typeRef) const { query, params } = sql`UPDATE ranges SET upper = ${upperId} WHERE type = ${type} AND listId = ${listId}` await this.sqlCipherFacade.run(query, params) } async setNewRangeForList(typeRef: TypeRef, listId: Id, lower: Id, upper: Id): Promise { const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef) lower = ensureBase64Ext(typeModel, lower) upper = ensureBase64Ext(typeModel, upper) const type = getTypeString(typeRef) 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 { const { query, params } = sql`SELECT batchId from lastUpdateBatchIdPerGroupId WHERE groupId = ${groupId}` const row = (await this.sqlCipherFacade.get(query, params)) as { batchId: TaggedSqlValue } | null return (row?.batchId?.value ?? null) as Id | null } async putLastBatchIdForGroup(groupId: Id, batchId: Id): Promise { const { query, params } = sql`INSERT OR REPLACE INTO lastUpdateBatchIdPerGroupId VALUES ( ${groupId}, ${batchId} )` await this.sqlCipherFacade.run(query, params) } async getLastUpdateTime(): Promise { const time = await this.getMetadata("lastUpdateTime") return time ? { type: "recorded", time } : { type: "never" } } async putLastUpdateTime(ms: number): Promise { await this.putMetadata("lastUpdateTime", ms) } async purgeStorage(): Promise { 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, listId: string): Promise { const { query, params } = sql`DELETE FROM ranges WHERE type = ${getTypeString(typeRef)} AND listId = ${listId}` await this.sqlCipherFacade.run(query, params) } async getElementsOfType(typeRef: TypeRef): Promise> { const { query, params } = sql`SELECT entity from element_entities WHERE type = ${getTypeString(typeRef)}` 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) } async getWholeList(typeRef: TypeRef, listId: Id): Promise> { const parsedInstances = await this.getWholeListParsed(typeRef, listId) return await this.modelMapper.mapToInstances(typeRef, parsedInstances) } async dumpMetadata(): Promise> { const query = "SELECT * from metadata" const stored = (await this.sqlCipherFacade.all(query, [])).map((row) => [row.key.value as string, row.value.value as Uint8Array] as const) return Object.fromEntries(stored.map(([key, value]) => [key, cborg.decode(value)])) as OfflineDbMeta } async setCurrentOfflineSchemaVersion(version: number) { return this.putMetadata("offline-version", version) } getCustomCacheHandlerMap(): CustomCacheHandlerMap { return this.customCacheHandler } getUserId(): Id { return assertNotNull(this.userId, "No user id, not initialized?") } async deleteAllOwnedBy(owner: Id): Promise { await this.deleteAllElementTypesOwnedBy(owner) await this.deleteAllListElementTypesOwnedBy(owner) await this.deleteAllBlobElementTypesOwnedBy(owner) { const { query, params } = sql`DELETE FROM lastUpdateBatchIdPerGroupId WHERE groupId = ${owner}` await this.sqlCipherFacade.run(query, params) } } private async deleteAllBlobElementTypesOwnedBy(owner: Id) { const { query, params } = sql`SELECT listId, elementId, type FROM blob_element_entities WHERE ownerGroup = ${owner}` 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 await this.deleteByIds( typeRef, rows.map((row) => [row.listId, row.elementId]), ) } } 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}` 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> = 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 // 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)}`, ) await this.deleteByIds( typeRef, rows.map((row) => [row.listId, row.elementId]), ) } } private async deleteAllElementTypesOwnedBy(owner: Id) { const { query, params } = sql`SELECT elementId, type FROM element_entities WHERE ownerGroup = ${owner}` 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 await this.deleteByIds(typeRef, ids) } } private async putMetadata(key: K, value: OfflineDbMeta[K]): Promise { let encodedValue try { encodedValue = cborg.encode(value) } catch (e) { console.log("[OfflineStorage] failed to encode metadata for key", key, "with value", value) throw e } const { query, params } = sql`INSERT OR REPLACE INTO metadata VALUES ( ${key}, ${encodedValue} )` await this.sqlCipherFacade.run(query, params) } private async getMetadata(key: K): Promise { 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 { 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, listId: Id): Promise { const type = getTypeString(typeRef) const { query, params } = sql`SELECT upper, lower FROM ranges WHERE type = ${type} AND listId = ${listId}` const row = (await this.sqlCipherFacade.get(query, params)) ?? null return mapNullable(row, untagSqlObject) as Range | null } /** * A neat helper which can delete types in any lists as long as they belong to the same type. * Will invoke {@link CustomCacheHandler#onBeforeCacheDeletion}. */ private async deleteByIds(typeRef: TypeRef, ids: T["_id"][]) { if (isEmpty(ids)) { return } const type = getTypeString(typeRef) const typeModel = await this.typeModelResolver.resolveClientTypeReference(typeRef) const handler = this.getCustomCacheHandlerMap().get(typeRef) if (handler && handler.onBeforeCacheDeletion) { for (const id of ids) { await handler.onBeforeCacheDeletion(id) } } switch (typeModel.type) { case TypeId.Element: await this.runChunked( MAX_SAFE_SQL_VARS - 1, (ids as Id[]).map((id) => ensureBase64Ext(typeModel, id)), (c) => sql`DELETE FROM element_entities WHERE type = ${type} AND elementId IN ${paramList(c)}`, ) break case TypeId.ListElement: { 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)}`, ) } } break case TypeId.BlobElement: { 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)}`, ) } } break default: throw new Error("must be a persistent type") } } async deleteIn( typeRef: TypeRef, listId: T extends ListElementEntity | BlobElementEntity ? Id : null, elementIds: Id[], ): Promise { if (isEmpty(elementIds)) return const fullIds: T["_id"][] = listId == null ? elementIds : elementIds.map((id) => [listId, id]) await this.deleteByIds(typeRef, fullIds) } async updateRangeForList(typeRef: TypeRef, listId: Id, rawCutoffId: Id): Promise { 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 { 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 { try { return cborg.decode(loaded, { tags: customTypeDecoders }) } catch (e) { console.log(`Error with CBOR decode. Trying to decode (of type: ${typeof loaded}): ${loaded}`) return null } } private async deserializeList(loaded: Array): Promise> { // manually reimplementing promiseMap to make sure we don't hit the scheduler since there's nothing actually async happening const result: Array = [] for (const entity of loaded) { const deserialized = await this.deserialize(entity) 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 { 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>> { const result: Array> = [] 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`` 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 { const qs = params.map(() => "?").join(",") return new SqlFragment(`(${qs})`, params) } /** * comparison to select ids that are bigger or smaller than a parameter id * must be used within sql`` 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 = "?" } 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 } export async function tableExists(sqlCipherFacade: SqlCipherFacade, table: string): Promise { // 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 }