2023-01-17 12:21:29 +01:00
|
|
|
import { ElementEntity, ListElementEntity, SomeEntity, TypeModel } from "../../common/EntityTypes.js"
|
2024-08-26 14:05:56 +02:00
|
|
|
import { CUSTOM_MIN_ID, firstBiggerThanSecond, GENERATED_MIN_ID, getElementId } from "../../common/utils/EntityUtils.js"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { CacheStorage, expandId, ExposedCacheStorage, LastUpdateTime } from "../rest/DefaultEntityRestCache.js"
|
2022-01-12 14:43:01 +01:00
|
|
|
import * as cborg from "cborg"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { EncodeOptions, Token, Type } from "cborg"
|
2023-08-16 10:47:09 +02:00
|
|
|
import {
|
|
|
|
|
assert,
|
|
|
|
|
assertNotNull,
|
2024-08-23 13:00:37 +02:00
|
|
|
base64ExtToBase64,
|
|
|
|
|
base64ToBase64Ext,
|
|
|
|
|
base64ToBase64Url,
|
|
|
|
|
base64UrlToBase64,
|
2023-08-16 10:47:09 +02:00
|
|
|
getTypeId,
|
|
|
|
|
groupByAndMapUniquely,
|
|
|
|
|
mapNullable,
|
|
|
|
|
splitInChunks,
|
|
|
|
|
TypeRef,
|
|
|
|
|
} from "@tutao/tutanota-utils"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { isDesktop, isOfflineStorageAvailable, isTest } from "../../common/Env.js"
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
import { modelInfos, resolveTypeReference } from "../../common/EntityFunctions.js"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { DateProvider } from "../../common/DateProvider.js"
|
2024-01-08 17:14:09 +01:00
|
|
|
import { TokenOrNestedTokens } from "cborg/interface"
|
2024-12-17 15:46:05 +01:00
|
|
|
import { CalendarEventTypeRef, MailTypeRef } from "../../entities/tutanota/TypeRefs.js"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { OfflineStorageMigrator } from "./OfflineStorageMigrator.js"
|
2024-12-17 15:46:05 +01:00
|
|
|
import { CustomCacheHandlerMap, CustomCalendarEventCacheHandler, CustomMailEventCacheHandler } from "../rest/CustomCacheHandler.js"
|
2022-12-27 15:37:40 +01:00
|
|
|
import { EntityRestClient } from "../rest/EntityRestClient.js"
|
|
|
|
|
import { InterWindowEventFacadeSendDispatcher } from "../../../native/common/generatedipc/InterWindowEventFacadeSendDispatcher.js"
|
|
|
|
|
import { SqlCipherFacade } from "../../../native/common/generatedipc/SqlCipherFacade.js"
|
2024-03-11 16:25:43 +01:00
|
|
|
import { FormattedQuery, SqlValue, TaggedSqlValue, untagSqlObject } from "./SqlValue.js"
|
2024-08-20 14:31:57 +02:00
|
|
|
import { AssociationType, Cardinality, Type as TypeId, ValueType } from "../../common/EntityConstants.js"
|
2023-01-04 10:54:28 +01:00
|
|
|
import { OutOfSyncError } from "../../common/error/OutOfSyncError.js"
|
2024-03-11 16:25:43 +01:00
|
|
|
import { sql, SqlFragment } from "./Sql.js"
|
2022-01-12 14:43:01 +01:00
|
|
|
|
2023-08-16 10:47:09 +02:00
|
|
|
/**
|
|
|
|
|
* this is the value of SQLITE_MAX_VARIABLE_NUMBER in sqlite3.c
|
|
|
|
|
* it may change if the sqlite version is updated.
|
|
|
|
|
* */
|
|
|
|
|
const MAX_SAFE_SQL_VARS = 32766
|
|
|
|
|
|
2022-01-12 14:43:01 +01:00
|
|
|
function dateEncoder(data: Date, typ: string, options: EncodeOptions): TokenOrNestedTokens | null {
|
2022-10-13 13:29:00 +02:00
|
|
|
const time = data.getTime()
|
2022-01-12 14:43:01 +01:00
|
|
|
return [
|
|
|
|
|
// https://datatracker.ietf.org/doc/rfc8943/
|
|
|
|
|
new Token(Type.tag, 100),
|
2022-12-27 15:37:40 +01:00
|
|
|
new Token(time < 0 ? Type.negint : Type.uint, time),
|
2022-01-12 14:43:01 +01:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dateDecoder(bytes: number): Date {
|
|
|
|
|
return new Date(bytes)
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-27 15:37:40 +01:00
|
|
|
export const customTypeEncoders: { [typeName: string]: typeof dateEncoder } = Object.freeze({
|
|
|
|
|
Date: dateEncoder,
|
2022-01-12 14:43:01 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
type TypeDecoder = (_: any) => any
|
|
|
|
|
export const customTypeDecoders: Array<TypeDecoder> = (() => {
|
|
|
|
|
const tags: Array<TypeDecoder> = []
|
|
|
|
|
tags[100] = dateDecoder
|
|
|
|
|
return tags
|
|
|
|
|
})()
|
|
|
|
|
|
2022-10-21 15:53:39 +02:00
|
|
|
/**
|
|
|
|
|
* For each of these keys we track the current version in the database.
|
|
|
|
|
* The keys are different model versions (because we need to migrate the data with certain model version changes) and "offline" key which is used to track
|
|
|
|
|
* migrations that are needed for other reasons e.g. if DB structure changes or if we need to invalidate some tables.
|
|
|
|
|
*/
|
|
|
|
|
export type VersionMetadataBaseKey = keyof typeof modelInfos | "offline"
|
2022-04-20 10:39:52 +02:00
|
|
|
|
2022-10-21 15:53:39 +02:00
|
|
|
type VersionMetadataEntries = {
|
2022-04-20 10:39:52 +02:00
|
|
|
// Yes this is cursed, give me a break
|
2022-10-21 15:53:39 +02:00
|
|
|
[P in VersionMetadataBaseKey as `${P}-version`]: number
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
|
|
|
|
|
2022-10-21 15:53:39 +02:00
|
|
|
export interface OfflineDbMeta extends VersionMetadataEntries {
|
2022-12-27 15:37:40 +01:00
|
|
|
lastUpdateTime: number
|
2022-04-20 10:39:52 +02:00
|
|
|
timeRangeDays: number
|
|
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
const TableDefinitions = Object.freeze({
|
2022-10-21 15:53:39 +02:00
|
|
|
// plus ownerGroup added in a migration
|
2022-12-27 15:37:40 +01:00
|
|
|
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)",
|
2022-10-21 15:53:39 +02:00
|
|
|
// plus ownerGroup added in a migration
|
|
|
|
|
element_entities: "type TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, elementId)",
|
2022-08-11 16:38:53 +02:00
|
|
|
ranges: "type TEXT NOT NULL, listId TEXT NOT NULL, lower TEXT NOT NULL, upper TEXT NOT NULL, PRIMARY KEY (type, listId)",
|
|
|
|
|
lastUpdateBatchIdPerGroupId: "groupId TEXT NOT NULL, batchId TEXT NOT NULL, PRIMARY KEY (groupId)",
|
|
|
|
|
metadata: "key TEXT NOT NULL, value BLOB, PRIMARY KEY (key)",
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
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)",
|
2022-08-11 16:38:53 +02:00
|
|
|
} as const)
|
|
|
|
|
|
2024-08-23 13:00:37 +02:00
|
|
|
type Range = { lower: Id; upper: Id }
|
2022-08-11 16:38:53 +02:00
|
|
|
|
2022-10-21 15:53:39 +02:00
|
|
|
export interface OfflineStorageInitArgs {
|
2022-12-27 15:37:40 +01:00
|
|
|
userId: Id
|
|
|
|
|
databaseKey: Uint8Array
|
|
|
|
|
timeRangeDays: number | null
|
2022-10-21 15:53:39 +02:00
|
|
|
forceNewDatabase: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-12 17:06:57 +02:00
|
|
|
export class OfflineStorage implements CacheStorage, ExposedCacheStorage {
|
2022-07-04 14:55:17 +02:00
|
|
|
private customCacheHandler: CustomCacheHandlerMap | null = null
|
2022-10-21 15:53:39 +02:00
|
|
|
private userId: Id | null = null
|
2022-11-28 17:38:17 +01:00
|
|
|
private timeRangeDays: number | null = null
|
2022-02-10 16:32:47 +01:00
|
|
|
|
2022-01-12 14:43:01 +01:00
|
|
|
constructor(
|
2022-08-11 16:38:53 +02:00
|
|
|
private readonly sqlCipherFacade: SqlCipherFacade,
|
2022-07-20 15:28:38 +02:00
|
|
|
private readonly interWindowEventSender: InterWindowEventFacadeSendDispatcher,
|
2022-04-20 10:39:52 +02:00
|
|
|
private readonly dateProvider: DateProvider,
|
2022-05-17 17:40:44 +02:00
|
|
|
private readonly migrator: OfflineStorageMigrator,
|
2024-08-15 16:11:44 +02:00
|
|
|
private readonly cleaner: OfflineStorageCleaner,
|
2022-01-12 14:43:01 +01:00
|
|
|
) {
|
2022-02-10 16:32:47 +01:00
|
|
|
assert(isOfflineStorageAvailable() || isTest(), "Offline storage is not available.")
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-17 17:40:44 +02:00
|
|
|
/**
|
|
|
|
|
* @return {boolean} whether the database was newly created or not
|
|
|
|
|
*/
|
2022-12-27 15:37:40 +01:00
|
|
|
async init({ userId, databaseKey, timeRangeDays, forceNewDatabase }: OfflineStorageInitArgs): Promise<boolean> {
|
2022-10-21 15:53:39 +02:00
|
|
|
this.userId = userId
|
2022-11-28 17:38:17 +01:00
|
|
|
this.timeRangeDays = timeRangeDays
|
2022-07-20 15:28:38 +02:00
|
|
|
if (forceNewDatabase) {
|
2022-08-11 16:38:53 +02:00
|
|
|
if (isDesktop()) {
|
|
|
|
|
await this.interWindowEventSender.localUserDataInvalidated(userId)
|
|
|
|
|
}
|
|
|
|
|
await this.sqlCipherFacade.deleteDb(userId)
|
2022-07-20 15:28:38 +02:00
|
|
|
}
|
2022-11-28 17:38:17 +01:00
|
|
|
// We open database here, and it is closed in the native side when the window is closed or the page is reloaded
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.openDb(userId, databaseKey)
|
|
|
|
|
await this.createTables()
|
2024-02-01 13:54:19 +01:00
|
|
|
|
2023-01-04 10:54:28 +01:00
|
|
|
try {
|
|
|
|
|
await this.migrator.migrate(this, this.sqlCipherFacade)
|
|
|
|
|
} catch (e) {
|
2023-01-04 16:54:58 +01:00
|
|
|
if (e instanceof OutOfSyncError) {
|
2023-02-02 14:17:02 +01:00
|
|
|
console.warn("Offline db is out of sync!", e)
|
2023-01-04 16:54:58 +01:00
|
|
|
await this.recreateDbFile(userId, databaseKey)
|
2023-02-02 14:17:02 +01:00
|
|
|
await this.migrator.migrate(this, this.sqlCipherFacade)
|
2023-01-04 16:54:58 +01:00
|
|
|
} else {
|
2023-01-04 10:54:28 +01:00
|
|
|
throw e
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-05-17 17:40:44 +02:00
|
|
|
// if nothing is written here, it means it's a new database
|
2024-01-22 10:42:11 +01:00
|
|
|
return (await this.getLastUpdateTime()).type === "never"
|
2023-01-04 10:54:28 +01:00
|
|
|
}
|
2022-11-28 17:38:17 +01:00
|
|
|
|
2023-01-04 10:54:28 +01:00
|
|
|
private async recreateDbFile(userId: string, databaseKey: Uint8Array): Promise<void> {
|
|
|
|
|
console.log(`recreating DB file for userId ${userId}`)
|
|
|
|
|
await this.sqlCipherFacade.closeDb()
|
|
|
|
|
await this.sqlCipherFacade.deleteDb(userId)
|
|
|
|
|
await this.sqlCipherFacade.openDb(userId, databaseKey)
|
|
|
|
|
await this.createTables()
|
2022-02-10 16:32:47 +01:00
|
|
|
}
|
|
|
|
|
|
2022-08-15 14:22:44 +02:00
|
|
|
/**
|
|
|
|
|
* currently, we close DBs from the native side (mainly on things like reload and on android's onDestroy)
|
|
|
|
|
*/
|
2022-07-20 15:28:38 +02:00
|
|
|
async deinit() {
|
2022-10-21 15:53:39 +02:00
|
|
|
this.userId = null
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.closeDb()
|
2022-07-20 15:28:38 +02:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async deleteIfExists(typeRef: TypeRef<SomeEntity>, listId: Id | null, elementId: Id): Promise<void> {
|
|
|
|
|
const type = getTypeId(typeRef)
|
2025-02-05 13:00:51 +01:00
|
|
|
const typeModel: TypeModel = await resolveTypeReference(typeRef)
|
|
|
|
|
const encodedElementId = ensureBase64Ext(typeModel, elementId)
|
2023-08-15 16:43:50 +02:00
|
|
|
let formattedQuery
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
switch (typeModel.type) {
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.Element:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`DELETE
|
|
|
|
|
FROM element_entities
|
|
|
|
|
WHERE type = ${type}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND elementId = ${encodedElementId}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.ListElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`DELETE
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND elementId = ${encodedElementId}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.BlobElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`DELETE
|
|
|
|
|
FROM blob_element_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND elementId = ${encodedElementId}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
throw new Error("must be a persistent type")
|
2022-02-10 16:32:47 +01:00
|
|
|
}
|
2023-08-15 16:43:50 +02:00
|
|
|
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2023-02-02 17:21:34 +01:00
|
|
|
async deleteAllOfType(typeRef: TypeRef<SomeEntity>): Promise<void> {
|
|
|
|
|
const type = getTypeId(typeRef)
|
|
|
|
|
let typeModel: TypeModel
|
2024-07-01 14:54:13 +02:00
|
|
|
typeModel = await resolveTypeReference(typeRef)
|
2023-08-15 16:43:50 +02:00
|
|
|
let formattedQuery
|
2023-02-02 17:21:34 +01:00
|
|
|
switch (typeModel.type) {
|
|
|
|
|
case TypeId.Element:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`DELETE
|
|
|
|
|
FROM element_entities
|
|
|
|
|
WHERE type = ${type}`
|
2023-02-02 17:21:34 +01:00
|
|
|
break
|
|
|
|
|
case TypeId.ListElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`DELETE
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}`
|
2023-08-15 16:43:50 +02:00
|
|
|
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
|
2023-03-29 14:46:04 +02:00
|
|
|
await this.deleteAllRangesForType(type)
|
|
|
|
|
return
|
2023-02-02 17:21:34 +01:00
|
|
|
case TypeId.BlobElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`DELETE
|
|
|
|
|
FROM blob_element_entities
|
|
|
|
|
WHERE type = ${type}`
|
2023-02-02 17:21:34 +01:00
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
throw new Error("must be a persistent type")
|
|
|
|
|
}
|
2023-08-15 16:43:50 +02:00
|
|
|
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
|
2023-02-02 17:21:34 +01:00
|
|
|
}
|
|
|
|
|
|
2023-03-29 14:46:04 +02:00
|
|
|
private async deleteAllRangesForType(type: string): Promise<void> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`DELETE
|
|
|
|
|
FROM ranges
|
|
|
|
|
WHERE type = ${type}`
|
2023-03-29 14:46:04 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
|
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async get<T extends SomeEntity>(typeRef: TypeRef<T>, listId: Id | null, elementId: Id): Promise<T | null> {
|
|
|
|
|
const type = getTypeId(typeRef)
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedElementId = ensureBase64Ext(typeModel, elementId)
|
2023-08-15 16:43:50 +02:00
|
|
|
let formattedQuery
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
switch (typeModel.type) {
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.Element:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`SELECT entity
|
|
|
|
|
from element_entities
|
|
|
|
|
WHERE type = ${type}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND elementId = ${encodedElementId}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.ListElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`SELECT entity
|
|
|
|
|
from list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND elementId = ${encodedElementId}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.BlobElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`SELECT entity
|
|
|
|
|
from blob_element_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND elementId = ${encodedElementId}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
throw new Error("must be a persistent type")
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
2023-08-15 16:43:50 +02:00
|
|
|
const result = await this.sqlCipherFacade.get(formattedQuery.query, formattedQuery.params)
|
2024-09-04 16:19:59 +02:00
|
|
|
return result?.entity ? await this.deserialize(typeRef, result.entity.value as Uint8Array) : null
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-07 08:38:58 +02:00
|
|
|
async provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<T>> {
|
|
|
|
|
if (elementIds.length === 0) return []
|
2024-08-23 13:00:37 +02:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedElementIds = elementIds.map((elementId) => ensureBase64Ext(typeModel, elementId))
|
2024-08-23 13:00:37 +02:00
|
|
|
|
2024-08-07 08:38:58 +02:00
|
|
|
const type = getTypeId(typeRef)
|
|
|
|
|
const serializedList: ReadonlyArray<Record<string, TaggedSqlValue>> = await this.allChunked(
|
|
|
|
|
MAX_SAFE_SQL_VARS - 2,
|
2025-02-05 13:00:51 +01:00
|
|
|
encodedElementIds,
|
2024-12-17 15:46:05 +01:00
|
|
|
(c) => sql`SELECT entity
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
|
|
|
|
AND elementId IN ${paramList(c)}`,
|
2024-08-07 08:38:58 +02:00
|
|
|
)
|
2024-09-04 16:19:59 +02:00
|
|
|
return await this.deserializeList(
|
2024-08-07 08:38:58 +02:00
|
|
|
typeRef,
|
|
|
|
|
serializedList.map((r) => r.entity.value as Uint8Array),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-12 14:43:01 +01:00
|
|
|
async getIdsInRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<Id>> {
|
2022-08-11 16:38:53 +02:00
|
|
|
const type = getTypeId(typeRef)
|
2024-08-28 17:20:32 +02:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2024-08-23 13:00:37 +02:00
|
|
|
const range = await this.getRange(typeRef, listId)
|
2022-08-11 16:38:53 +02:00
|
|
|
if (range == null) {
|
|
|
|
|
throw new Error(`no range exists for ${type} and list ${listId}`)
|
|
|
|
|
}
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT elementId
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
|
|
|
|
AND (elementId = ${range.lower}
|
|
|
|
|
OR ${firstIdBigger("elementId", range.lower)})
|
|
|
|
|
AND NOT (${firstIdBigger("elementId", range.upper)})`
|
2022-08-11 16:38:53 +02:00
|
|
|
const rows = await this.sqlCipherFacade.all(query, params)
|
2024-08-28 17:20:32 +02:00
|
|
|
return rows.map((row) => customIdToBase64Url(typeModel, row.elementId.value as string))
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-23 13:00:37 +02:00
|
|
|
/** 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.
|
|
|
|
|
*/
|
2022-08-11 16:38:53 +02:00
|
|
|
async getRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Range | null> {
|
2024-08-23 13:00:37 +02:00
|
|
|
let range = await this.getRange(typeRef, listId)
|
|
|
|
|
if (range == null) return range
|
2024-08-28 17:47:42 +02:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2024-08-23 13:00:37 +02:00
|
|
|
return {
|
|
|
|
|
lower: customIdToBase64Url(typeModel, range.lower),
|
|
|
|
|
upper: customIdToBase64Url(typeModel, range.upper),
|
|
|
|
|
}
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-23 13:00:37 +02:00
|
|
|
async isElementIdInCacheRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementId: Id): Promise<boolean> {
|
|
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedElementId = ensureBase64Ext(typeModel, elementId)
|
2024-08-23 13:00:37 +02:00
|
|
|
|
|
|
|
|
const range = await this.getRange(typeRef, listId)
|
2025-02-05 13:00:51 +01:00
|
|
|
return range != null && !firstBiggerThanSecond(encodedElementId, range.upper) && !firstBiggerThanSecond(range.lower, encodedElementId)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async provideFromRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, start: Id, count: number, reverse: boolean): Promise<T[]> {
|
2024-08-23 13:00:37 +02:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedStartId = ensureBase64Ext(typeModel, start)
|
2022-08-11 16:38:53 +02:00
|
|
|
const type = getTypeId(typeRef)
|
2023-08-15 16:43:50 +02:00
|
|
|
let formattedQuery
|
2022-08-11 16:38:53 +02:00
|
|
|
if (reverse) {
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`SELECT entity
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND ${firstIdBigger(encodedStartId, "elementId")}
|
2024-12-17 15:46:05 +01:00
|
|
|
ORDER BY LENGTH(elementId) DESC, elementId DESC LIMIT ${count}`
|
2022-08-11 16:38:53 +02:00
|
|
|
} else {
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`SELECT entity
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}
|
2025-02-05 13:00:51 +01:00
|
|
|
AND ${firstIdBigger("elementId", encodedStartId)}
|
2024-12-17 15:46:05 +01:00
|
|
|
ORDER BY LENGTH(elementId) ASC, elementId ASC LIMIT ${count}`
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
2023-08-15 16:43:50 +02:00
|
|
|
const { query, params } = formattedQuery
|
2022-08-11 16:38:53 +02:00
|
|
|
const serializedList: ReadonlyArray<Record<string, TaggedSqlValue>> = await this.sqlCipherFacade.all(query, params)
|
2024-09-04 16:19:59 +02:00
|
|
|
return await this.deserializeList(
|
2022-12-27 15:37:40 +01:00
|
|
|
typeRef,
|
|
|
|
|
serializedList.map((r) => r.entity.value as Uint8Array),
|
|
|
|
|
)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async put(originalEntity: SomeEntity): Promise<void> {
|
|
|
|
|
const serializedEntity = this.serialize(originalEntity)
|
2025-02-05 13:00:51 +01:00
|
|
|
const { listId, elementId } = expandId(originalEntity._id)
|
2022-08-11 16:38:53 +02:00
|
|
|
const type = getTypeId(originalEntity._type)
|
2022-10-21 15:53:39 +02:00
|
|
|
const ownerGroup = originalEntity._ownerGroup
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
const typeModel = await resolveTypeReference(originalEntity._type)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedElementId = ensureBase64Ext(typeModel, elementId)
|
2023-08-15 16:43:50 +02:00
|
|
|
let formattedQuery: FormattedQuery
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
switch (typeModel.type) {
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.Element:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`INSERT
|
|
|
|
|
OR REPLACE INTO element_entities (type, elementId, ownerGroup, entity) VALUES (
|
|
|
|
|
${type},
|
2025-02-05 13:00:51 +01:00
|
|
|
${encodedElementId},
|
2024-12-17 15:46:05 +01:00
|
|
|
${ownerGroup},
|
|
|
|
|
${serializedEntity}
|
|
|
|
|
)`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.ListElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`INSERT
|
|
|
|
|
OR REPLACE INTO list_entities (type, listId, elementId, ownerGroup, entity) VALUES (
|
|
|
|
|
${type},
|
|
|
|
|
${listId},
|
2025-02-05 13:00:51 +01:00
|
|
|
${encodedElementId},
|
2024-12-17 15:46:05 +01:00
|
|
|
${ownerGroup},
|
|
|
|
|
${serializedEntity}
|
|
|
|
|
)`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.BlobElement:
|
2024-12-17 15:46:05 +01:00
|
|
|
formattedQuery = sql`INSERT
|
|
|
|
|
OR REPLACE INTO blob_element_entities (type, listId, elementId, ownerGroup, entity) VALUES (
|
|
|
|
|
${type},
|
|
|
|
|
${listId},
|
2025-02-05 13:00:51 +01:00
|
|
|
${encodedElementId},
|
2024-12-17 15:46:05 +01:00
|
|
|
${ownerGroup},
|
|
|
|
|
${serializedEntity}
|
|
|
|
|
)`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
throw new Error("must be a persistent type")
|
|
|
|
|
}
|
2023-08-15 16:43:50 +02:00
|
|
|
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-23 13:00:37 +02:00
|
|
|
async setLowerRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lowerId: Id): Promise<void> {
|
|
|
|
|
lowerId = ensureBase64Ext(await resolveTypeReference(typeRef), lowerId)
|
2022-08-11 16:38:53 +02:00
|
|
|
const type = getTypeId(typeRef)
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`UPDATE ranges
|
|
|
|
|
SET lower = ${lowerId}
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}`
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-23 13:00:37 +02:00
|
|
|
async setUpperRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, upperId: Id): Promise<void> {
|
|
|
|
|
upperId = ensureBase64Ext(await resolveTypeReference(typeRef), upperId)
|
2022-08-11 16:38:53 +02:00
|
|
|
const type = getTypeId(typeRef)
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`UPDATE ranges
|
|
|
|
|
SET upper = ${upperId}
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId = ${listId}`
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setNewRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lower: Id, upper: Id): Promise<void> {
|
2024-08-23 13:00:37 +02:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
|
|
|
|
lower = ensureBase64Ext(typeModel, lower)
|
|
|
|
|
upper = ensureBase64Ext(typeModel, upper)
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
const type = getTypeId(typeRef)
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`INSERT
|
|
|
|
|
OR REPLACE INTO ranges VALUES (
|
|
|
|
|
${type},
|
|
|
|
|
${listId},
|
|
|
|
|
${lower},
|
|
|
|
|
${upper}
|
|
|
|
|
)`
|
2022-08-11 16:38:53 +02:00
|
|
|
return this.sqlCipherFacade.run(query, params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async getLastBatchIdForGroup(groupId: Id): Promise<Id | null> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT batchId
|
|
|
|
|
from lastUpdateBatchIdPerGroupId
|
|
|
|
|
WHERE groupId = ${groupId}`
|
2022-12-27 15:37:40 +01:00
|
|
|
const row = (await this.sqlCipherFacade.get(query, params)) as { batchId: TaggedSqlValue } | null
|
2022-08-11 16:38:53 +02:00
|
|
|
return (row?.batchId?.value ?? null) as Id | null
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async putLastBatchIdForGroup(groupId: Id, batchId: Id): Promise<void> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`INSERT
|
|
|
|
|
OR REPLACE INTO lastUpdateBatchIdPerGroupId VALUES (
|
|
|
|
|
${groupId},
|
|
|
|
|
${batchId}
|
|
|
|
|
)`
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2022-09-07 17:09:25 +02:00
|
|
|
async getLastUpdateTime(): Promise<LastUpdateTime> {
|
|
|
|
|
const time = await this.getMetadata("lastUpdateTime")
|
2022-12-27 15:37:40 +01:00
|
|
|
return time ? { type: "recorded", time } : { type: "never" }
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async putLastUpdateTime(ms: number): Promise<void> {
|
|
|
|
|
await this.putMetadata("lastUpdateTime", ms)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async purgeStorage(): Promise<void> {
|
|
|
|
|
for (let name of Object.keys(TableDefinitions)) {
|
2024-12-17 15:46:05 +01:00
|
|
|
await this.sqlCipherFacade.run(
|
|
|
|
|
`DELETE
|
2025-01-03 10:16:07 +01:00
|
|
|
FROM ${name}`,
|
2024-12-17 15:46:05 +01:00
|
|
|
[],
|
|
|
|
|
)
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async deleteRange(typeRef: TypeRef<unknown>, listId: string): Promise<void> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`DELETE
|
|
|
|
|
FROM ranges
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}
|
|
|
|
|
AND listId = ${listId}`
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2024-09-04 16:51:12 +02:00
|
|
|
async getRawListElementsOfType(typeRef: TypeRef<ListElementEntity>): Promise<Array<ListElementEntity>> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT entity
|
|
|
|
|
from list_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}`
|
2022-12-27 15:37:40 +01:00
|
|
|
const items = (await this.sqlCipherFacade.all(query, params)) ?? []
|
2024-09-04 16:51:12 +02:00
|
|
|
return items.map((item) => this.decodeCborEntity(item.entity.value as Uint8Array) as Record<string, unknown> & ListElementEntity)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getRawElementsOfType(typeRef: TypeRef<ElementEntity>): Promise<Array<ElementEntity>> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT entity
|
|
|
|
|
from element_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}`
|
2024-09-04 16:51:12 +02:00
|
|
|
const items = (await this.sqlCipherFacade.all(query, params)) ?? []
|
|
|
|
|
return items.map((item) => this.decodeCborEntity(item.entity.value as Uint8Array) as Record<string, unknown> & ElementEntity)
|
2022-02-10 16:32:47 +01:00
|
|
|
}
|
|
|
|
|
|
2024-08-15 16:11:44 +02:00
|
|
|
async getElementsOfType<T extends ElementEntity>(typeRef: TypeRef<T>): Promise<Array<T>> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT entity
|
|
|
|
|
from element_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}`
|
2022-12-27 15:37:40 +01:00
|
|
|
const items = (await this.sqlCipherFacade.all(query, params)) ?? []
|
2024-09-04 16:19:59 +02:00
|
|
|
return await this.deserializeList(
|
2022-12-27 15:37:40 +01:00
|
|
|
typeRef,
|
|
|
|
|
items.map((row) => row.entity.value as Uint8Array),
|
|
|
|
|
)
|
2022-06-16 17:23:48 +02:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async getWholeList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<T>> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT entity
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}
|
|
|
|
|
AND listId = ${listId}`
|
2022-12-27 15:37:40 +01:00
|
|
|
const items = (await this.sqlCipherFacade.all(query, params)) ?? []
|
2024-09-04 16:19:59 +02:00
|
|
|
return await this.deserializeList(
|
2022-12-27 15:37:40 +01:00
|
|
|
typeRef,
|
|
|
|
|
items.map((row) => row.entity.value as Uint8Array),
|
|
|
|
|
)
|
2022-02-10 16:32:47 +01:00
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
async dumpMetadata(): Promise<Partial<OfflineDbMeta>> {
|
|
|
|
|
const query = "SELECT * from metadata"
|
2022-12-27 15:37:40 +01:00
|
|
|
const stored = (await this.sqlCipherFacade.all(query, [])).map((row) => [row.key.value as string, row.value.value as Uint8Array] as const)
|
2022-08-11 16:38:53 +02:00
|
|
|
return Object.fromEntries(stored.map(([key, value]) => [key, cborg.decode(value)])) as OfflineDbMeta
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
|
|
|
|
|
2022-10-21 15:53:39 +02:00
|
|
|
async setStoredModelVersion(model: VersionMetadataBaseKey, version: number) {
|
2022-08-11 16:38:53 +02:00
|
|
|
return this.putMetadata(`${model}-version`, version)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getCustomCacheHandlerMap(entityRestClient: EntityRestClient): CustomCacheHandlerMap {
|
|
|
|
|
if (this.customCacheHandler == null) {
|
2024-12-17 15:46:05 +01:00
|
|
|
this.customCacheHandler = new CustomCacheHandlerMap(
|
|
|
|
|
{
|
|
|
|
|
ref: CalendarEventTypeRef,
|
|
|
|
|
handler: new CustomCalendarEventCacheHandler(entityRestClient),
|
|
|
|
|
},
|
|
|
|
|
{ ref: MailTypeRef, handler: new CustomMailEventCacheHandler() },
|
|
|
|
|
)
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
|
|
|
|
return this.customCacheHandler
|
|
|
|
|
}
|
|
|
|
|
|
2022-10-21 15:53:39 +02:00
|
|
|
getUserId(): Id {
|
|
|
|
|
return assertNotNull(this.userId, "No user id, not initialized?")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteAllOwnedBy(owner: Id): Promise<void> {
|
|
|
|
|
{
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`DELETE
|
|
|
|
|
FROM element_entities
|
|
|
|
|
WHERE ownerGroup = ${owner}`
|
2022-10-21 15:53:39 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
|
|
|
|
}
|
|
|
|
|
{
|
2022-11-02 11:37:37 +01:00
|
|
|
// first, check which list Ids contain entities owned by the lost group
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT listId, type
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE ownerGroup = ${owner}`
|
2022-11-02 11:37:37 +01:00
|
|
|
const rangeRows = await this.sqlCipherFacade.all(query, params)
|
2022-12-27 15:37:40 +01:00
|
|
|
const rows = rangeRows.map((row) => untagSqlObject(row) as { listId: string; type: string })
|
|
|
|
|
const listIdsByType: Map<string, Set<Id>> = groupByAndMapUniquely(
|
|
|
|
|
rows,
|
|
|
|
|
(row) => row.type,
|
|
|
|
|
(row) => row.listId,
|
|
|
|
|
)
|
2023-08-16 10:47:09 +02:00
|
|
|
// delete the ranges for those listIds
|
2022-11-02 11:37:37 +01:00
|
|
|
for (const [type, listIds] of listIdsByType.entries()) {
|
2023-08-16 10:47:09 +02:00
|
|
|
// this particular query uses one other SQL var for the type.
|
|
|
|
|
const safeChunkSize = MAX_SAFE_SQL_VARS - 1
|
|
|
|
|
const listIdArr = Array.from(listIds)
|
2024-12-17 15:46:05 +01:00
|
|
|
await this.runChunked(
|
|
|
|
|
safeChunkSize,
|
|
|
|
|
listIdArr,
|
|
|
|
|
(c) => sql`DELETE
|
2025-01-03 10:16:07 +01:00
|
|
|
FROM ranges
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId IN ${paramList(c)}`,
|
2024-12-17 15:46:05 +01:00
|
|
|
)
|
|
|
|
|
await this.runChunked(
|
|
|
|
|
safeChunkSize,
|
|
|
|
|
listIdArr,
|
|
|
|
|
(c) => sql`DELETE
|
2025-01-03 10:16:07 +01:00
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${type}
|
|
|
|
|
AND listId IN ${paramList(c)}`,
|
2024-12-17 15:46:05 +01:00
|
|
|
)
|
2022-11-02 11:37:37 +01:00
|
|
|
}
|
2022-11-03 11:04:26 +01:00
|
|
|
}
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
{
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`DELETE
|
|
|
|
|
FROM blob_element_entities
|
|
|
|
|
WHERE ownerGroup = ${owner}`
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
|
|
|
|
}
|
2022-11-03 11:04:26 +01:00
|
|
|
{
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`DELETE
|
|
|
|
|
FROM lastUpdateBatchIdPerGroupId
|
|
|
|
|
WHERE groupId = ${owner}`
|
2022-11-03 11:04:26 +01:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
2022-10-21 15:53:39 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-09 11:57:46 +02:00
|
|
|
async deleteWholeList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<void> {
|
|
|
|
|
await this.lockRangesDbAccess(listId)
|
|
|
|
|
await this.deleteRange(typeRef, listId)
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`DELETE
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE listId = ${listId}`
|
2024-09-09 11:57:46 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
|
|
|
|
await this.unlockRangesDbAccess(listId)
|
|
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
private async putMetadata<K extends keyof OfflineDbMeta>(key: K, value: OfflineDbMeta[K]): Promise<void> {
|
2023-02-07 10:18:22 +01:00
|
|
|
let encodedValue
|
|
|
|
|
try {
|
|
|
|
|
encodedValue = cborg.encode(value)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("[OfflineStorage] failed to encode metadata for key", key, "with value", value)
|
|
|
|
|
throw e
|
|
|
|
|
}
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`INSERT
|
|
|
|
|
OR REPLACE INTO metadata VALUES (
|
|
|
|
|
${key},
|
|
|
|
|
${encodedValue}
|
|
|
|
|
)`
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.sqlCipherFacade.run(query, params)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getMetadata<K extends keyof OfflineDbMeta>(key: K): Promise<OfflineDbMeta[K] | null> {
|
2024-12-17 15:46:05 +01:00
|
|
|
const { query, params } = sql`SELECT value
|
|
|
|
|
from metadata
|
|
|
|
|
WHERE key = ${key}`
|
2022-08-11 16:38:53 +02:00
|
|
|
const encoded = await this.sqlCipherFacade.get(query, params)
|
|
|
|
|
return encoded && cborg.decode(encoded.value.value as Uint8Array)
|
2022-01-12 14:43:01 +01:00
|
|
|
}
|
2022-02-10 16:32:47 +01:00
|
|
|
|
2022-05-06 14:39:31 +02:00
|
|
|
/**
|
2022-11-28 17:38:17 +01:00
|
|
|
* Clear out unneeded data from the offline database (i.e. trash and spam lists, old data).
|
|
|
|
|
* This will be called after login (CachePostLoginActions.ts) to ensure fast login time.
|
|
|
|
|
* @param timeRangeDays: the maximum age of days that mails should be to be kept in the database. if null, will use a default value
|
|
|
|
|
* @param userId id of the current user. default, last stored userId
|
2022-05-06 14:39:31 +02:00
|
|
|
*/
|
2022-11-28 17:38:17 +01:00
|
|
|
async clearExcludedData(timeRangeDays: number | null = this.timeRangeDays, userId: Id = this.getUserId()): Promise<void> {
|
2024-08-15 16:11:44 +02:00
|
|
|
await this.cleaner.cleanOfflineDb(this, timeRangeDays, userId, this.dateProvider.now())
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async createTables() {
|
|
|
|
|
for (let [name, definition] of Object.entries(TableDefinitions)) {
|
2024-12-17 15:46:05 +01:00
|
|
|
await this.sqlCipherFacade.run(
|
|
|
|
|
`CREATE TABLE IF NOT EXISTS ${name}
|
2025-01-03 10:16:07 +01:00
|
|
|
(
|
|
|
|
|
${definition}
|
|
|
|
|
)`,
|
2024-12-17 15:46:05 +01:00
|
|
|
[],
|
|
|
|
|
)
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-05 13:00:51 +01:00
|
|
|
private async getRange(typeRef: TypeRef<ElementEntity | ListElementEntity>, listId: Id): Promise<Range | null> {
|
2024-08-23 13:00:37 +02:00
|
|
|
const type = getTypeId(typeRef)
|
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
|
2024-08-23 13:00:37 +02:00
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
return mapNullable(row, untagSqlObject) as Range | null
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
|
|
|
|
|
2024-08-15 16:11:44 +02:00
|
|
|
async deleteIn(typeRef: TypeRef<unknown>, listId: Id | null, elementIds: Id[]): Promise<void> {
|
2023-09-18 12:07:46 +02:00
|
|
|
if (elementIds.length === 0) return
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedElementIds = elementIds.map((elementIds) => ensureBase64Ext(typeModel, elementIds))
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
switch (typeModel.type) {
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.Element:
|
2023-08-16 10:47:09 +02:00
|
|
|
return await this.runChunked(
|
|
|
|
|
MAX_SAFE_SQL_VARS - 1,
|
2025-02-05 13:00:51 +01:00
|
|
|
encodedElementIds,
|
2024-12-17 15:46:05 +01:00
|
|
|
(c) => sql`DELETE
|
|
|
|
|
FROM element_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}
|
|
|
|
|
AND elementId IN ${paramList(c)}`,
|
2023-08-16 10:47:09 +02:00
|
|
|
)
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.ListElement:
|
2023-08-16 10:47:09 +02:00
|
|
|
return await this.runChunked(
|
|
|
|
|
MAX_SAFE_SQL_VARS - 2,
|
2025-02-05 13:00:51 +01:00
|
|
|
encodedElementIds,
|
2024-12-17 15:46:05 +01:00
|
|
|
(c) => sql`DELETE
|
|
|
|
|
FROM list_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}
|
|
|
|
|
AND listId = ${listId}
|
|
|
|
|
AND elementId IN ${paramList(c)}`,
|
2023-08-16 10:47:09 +02:00
|
|
|
)
|
2023-01-12 16:48:28 +01:00
|
|
|
case TypeId.BlobElement:
|
2023-08-16 10:47:09 +02:00
|
|
|
return await this.runChunked(
|
|
|
|
|
MAX_SAFE_SQL_VARS - 2,
|
2025-02-05 13:00:51 +01:00
|
|
|
encodedElementIds,
|
2024-12-17 15:46:05 +01:00
|
|
|
(c) => sql`DELETE
|
|
|
|
|
FROM blob_element_entities
|
|
|
|
|
WHERE type = ${getTypeId(typeRef)}
|
|
|
|
|
AND listId = ${listId}
|
|
|
|
|
AND elementId IN ${paramList(c)}`,
|
2023-08-16 10:47:09 +02:00
|
|
|
)
|
add MailDetails feature, #4719
server issues: 1276, 1271, 1279, 1272, 1270, 1258, 1254, 1253, 1242, 1241
2022-11-03 19:03:54 +01:00
|
|
|
default:
|
|
|
|
|
throw new Error("must be a persistent type")
|
|
|
|
|
}
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
|
|
|
|
|
2022-11-30 17:15:08 +01:00
|
|
|
/**
|
|
|
|
|
* We want to lock the access to the "ranges" db when updating / reading the
|
2024-08-26 15:07:37 +02:00
|
|
|
* offline available mail list / mailset ranges for each mail list (referenced using the listId).
|
|
|
|
|
* @param listId the mail list or mail set entry list that we want to lock
|
2022-11-30 17:15:08 +01:00
|
|
|
*/
|
|
|
|
|
async lockRangesDbAccess(listId: Id) {
|
|
|
|
|
await this.sqlCipherFacade.lockRangesDbAccess(listId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* This is the counterpart to the function "lockRangesDbAccess(listId)".
|
|
|
|
|
* @param listId the mail list that we want to unlock
|
|
|
|
|
*/
|
|
|
|
|
async unlockRangesDbAccess(listId: Id) {
|
|
|
|
|
await this.sqlCipherFacade.unlockRangesDbAccess(listId)
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-15 16:11:44 +02:00
|
|
|
async updateRangeForListAndDeleteObsoleteData<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, rawCutoffId: Id): Promise<void> {
|
2024-08-23 13:00:37 +02:00
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
|
|
|
|
const isCustomId = isCustomIdType(typeModel)
|
2025-02-05 13:00:51 +01:00
|
|
|
const encodedCutoffId = ensureBase64Ext(typeModel, rawCutoffId)
|
2022-04-20 10:39:52 +02:00
|
|
|
|
2024-08-23 13:00:37 +02:00
|
|
|
const range = await this.getRange(typeRef, listId)
|
2022-04-20 10:39:52 +02:00
|
|
|
if (range == null) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the range for a given list is complete from the beginning (starts at GENERATED_MIN_ID), then we only want to actually modify the
|
|
|
|
|
// saved range if we would be removing elements from the list, in order to not lose the information that the range is complete in storage.
|
|
|
|
|
// So we have to check how old the oldest element in said range is. If it is newer than cutoffId, then we will not modify the range,
|
|
|
|
|
// otherwise we will just modify it normally
|
2024-08-23 13:00:37 +02:00
|
|
|
const expectedMinId = isCustomId ? CUSTOM_MIN_ID : GENERATED_MIN_ID
|
|
|
|
|
if (range.lower === expectedMinId) {
|
|
|
|
|
const entities = await this.provideFromRange(typeRef, listId, expectedMinId, 1, false)
|
2022-04-20 10:39:52 +02:00
|
|
|
const id = mapNullable(entities[0], getElementId)
|
2025-02-05 13:00:51 +01:00
|
|
|
const rangeWontBeModified = id == null || firstBiggerThanSecond(id, encodedCutoffId) || id === encodedCutoffId
|
2022-04-20 10:39:52 +02:00
|
|
|
if (rangeWontBeModified) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-05 13:00:51 +01:00
|
|
|
if (firstBiggerThanSecond(encodedCutoffId, range.lower)) {
|
2022-04-20 10:39:52 +02:00
|
|
|
// If the upper id of the range is below the cutoff, then the entire range will be deleted from the storage
|
|
|
|
|
// so we just delete the range as well
|
|
|
|
|
// Otherwise, we only want to modify
|
2025-02-05 13:00:51 +01:00
|
|
|
if (firstBiggerThanSecond(encodedCutoffId, range.upper)) {
|
2022-08-11 16:38:53 +02:00
|
|
|
await this.deleteRange(typeRef, listId)
|
2022-04-20 10:39:52 +02:00
|
|
|
} else {
|
2024-08-26 15:07:37 +02:00
|
|
|
await this.setLowerRangeForList(typeRef, listId, rawCutoffId)
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
private serialize(originalEntity: SomeEntity): Uint8Array {
|
2023-02-07 10:18:22 +01:00
|
|
|
try {
|
|
|
|
|
return cborg.encode(originalEntity, { typeEncoders: customTypeEncoders })
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log("[OfflineStorage] failed to encode entity of type", originalEntity._type, "with id", originalEntity._id)
|
|
|
|
|
throw e
|
|
|
|
|
}
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
|
|
|
|
|
2024-08-20 14:31:57 +02:00
|
|
|
/**
|
|
|
|
|
* Convert the type from CBOR representation to the runtime type
|
|
|
|
|
*/
|
2024-09-20 16:10:37 +02:00
|
|
|
private async deserialize<T extends SomeEntity>(typeRef: TypeRef<T>, loaded: Uint8Array): Promise<T | null> {
|
|
|
|
|
let deserialized
|
|
|
|
|
try {
|
|
|
|
|
deserialized = this.decodeCborEntity(loaded)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log(e)
|
|
|
|
|
console.log(`Error with CBOR decode. Trying to decode (of type: ${typeof loaded}): ${loaded}`)
|
|
|
|
|
return null
|
|
|
|
|
}
|
2024-08-20 14:31:57 +02:00
|
|
|
|
|
|
|
|
const typeModel = await resolveTypeReference(typeRef)
|
|
|
|
|
return (await this.fixupTypeRefs(typeModel, deserialized)) as T
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-04 16:51:12 +02:00
|
|
|
private decodeCborEntity(loaded: Uint8Array): Record<string, unknown> {
|
|
|
|
|
return cborg.decode(loaded, { tags: customTypeDecoders })
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-20 14:31:57 +02:00
|
|
|
private async fixupTypeRefs(typeModel: TypeModel, deserialized: any): Promise<unknown> {
|
|
|
|
|
// TypeRef cannot be deserialized back automatically. We could write a codec for it but we don't actually need to store it so we just "patch" it.
|
2022-08-11 16:38:53 +02:00
|
|
|
// Some places rely on TypeRef being a class and not a plain object.
|
2024-08-20 14:31:57 +02:00
|
|
|
// We also have to update all aggregates, recursively.
|
|
|
|
|
deserialized._type = new TypeRef(typeModel.app, typeModel.name)
|
|
|
|
|
for (const [associationName, associationModel] of Object.entries(typeModel.associations)) {
|
|
|
|
|
if (associationModel.type === AssociationType.Aggregation) {
|
|
|
|
|
const aggregateTypeRef = new TypeRef(associationModel.dependency ?? typeModel.app, associationModel.refType)
|
|
|
|
|
const aggregateTypeModel = await resolveTypeReference(aggregateTypeRef)
|
|
|
|
|
switch (associationModel.cardinality) {
|
|
|
|
|
case Cardinality.One:
|
2025-01-03 10:16:07 +01:00
|
|
|
case Cardinality.ZeroOrOne: {
|
2024-08-20 14:31:57 +02:00
|
|
|
const aggregate = deserialized[associationName]
|
|
|
|
|
if (aggregate) {
|
|
|
|
|
await this.fixupTypeRefs(aggregateTypeModel, aggregate)
|
|
|
|
|
}
|
|
|
|
|
break
|
2025-01-03 10:16:07 +01:00
|
|
|
}
|
|
|
|
|
case Cardinality.Any: {
|
2024-08-20 14:31:57 +02:00
|
|
|
const aggregateList = deserialized[associationName]
|
|
|
|
|
for (const aggregate of aggregateList) {
|
|
|
|
|
await this.fixupTypeRefs(aggregateTypeModel, aggregate)
|
|
|
|
|
}
|
|
|
|
|
break
|
2025-01-03 10:16:07 +01:00
|
|
|
}
|
2024-08-20 14:31:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-08-11 16:38:53 +02:00
|
|
|
return deserialized
|
2022-04-20 10:39:52 +02:00
|
|
|
}
|
2022-05-17 17:40:44 +02:00
|
|
|
|
2024-09-04 16:19:59 +02:00
|
|
|
private async deserializeList<T extends SomeEntity>(typeRef: TypeRef<T>, loaded: Array<Uint8Array>): Promise<Array<T>> {
|
|
|
|
|
// manually reimplementing promiseMap to make sure we don't hit the scheduler since there's nothing actually async happening
|
2024-09-04 16:51:12 +02:00
|
|
|
const result: Array<T> = []
|
2024-09-04 16:19:59 +02:00
|
|
|
for (const entity of loaded) {
|
2024-09-20 16:10:37 +02:00
|
|
|
const deserialized = await this.deserialize(typeRef, entity)
|
|
|
|
|
if (deserialized != null) {
|
|
|
|
|
result.push(deserialized)
|
|
|
|
|
}
|
2024-09-04 16:19:59 +02:00
|
|
|
}
|
|
|
|
|
return result
|
2022-05-17 17:40:44 +02:00
|
|
|
}
|
2023-08-16 10:47:09 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* convenience method to run a potentially too large query over several chunks.
|
|
|
|
|
* chunkSize must be chosen such that the total number of SQL variables in the final query does not exceed MAX_SAFE_SQL_VARS
|
|
|
|
|
* */
|
|
|
|
|
private async runChunked(chunkSize: number, originalList: SqlValue[], formatter: (chunk: SqlValue[]) => FormattedQuery): Promise<void> {
|
|
|
|
|
for (const chunk of splitInChunks(chunkSize, originalList)) {
|
|
|
|
|
const formattedQuery = formatter(chunk)
|
|
|
|
|
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-08-07 08:38:58 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* convenience method to execute a potentially too large query over several chunks.
|
|
|
|
|
* chunkSize must be chosen such that the total number of SQL variables in the final query does not exceed MAX_SAFE_SQL_VARS
|
|
|
|
|
* */
|
|
|
|
|
private async allChunked(
|
|
|
|
|
chunkSize: number,
|
|
|
|
|
originalList: SqlValue[],
|
|
|
|
|
formatter: (chunk: SqlValue[]) => FormattedQuery,
|
|
|
|
|
): Promise<Array<Record<string, TaggedSqlValue>>> {
|
|
|
|
|
const result: Array<Record<string, TaggedSqlValue>> = []
|
|
|
|
|
for (const chunk of splitInChunks(chunkSize, originalList)) {
|
|
|
|
|
const formattedQuery = formatter(chunk)
|
|
|
|
|
result.push(...(await this.sqlCipherFacade.all(formattedQuery.query, formattedQuery.params)))
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
2022-07-04 14:55:17 +02:00
|
|
|
|
2022-08-11 16:38:53 +02:00
|
|
|
/*
|
2023-08-16 10:47:09 +02:00
|
|
|
* used to automatically create the right amount of SQL variables for selecting ids from a dynamic list.
|
|
|
|
|
* must be used within sql`<query>` template string to inline the logic into the query.
|
|
|
|
|
*
|
|
|
|
|
* It is very important that params is kept to a size such that the total amount of SQL variables is
|
|
|
|
|
* less than MAX_SAFE_SQL_VARS.
|
2022-08-11 16:38:53 +02:00
|
|
|
*/
|
|
|
|
|
function paramList(params: SqlValue[]): SqlFragment {
|
2022-12-27 15:37:40 +01:00
|
|
|
const qs = params.map(() => "?").join(",")
|
2022-08-11 16:38:53 +02:00
|
|
|
return new SqlFragment(`(${qs})`, params)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2022-08-15 14:22:44 +02:00
|
|
|
* comparison to select ids that are bigger or smaller than a parameter id
|
2023-08-16 10:47:09 +02:00
|
|
|
* must be used within sql`<query>` template string to inline the logic into the query.
|
|
|
|
|
*
|
|
|
|
|
* will always insert 3 constants and 3 SQL variables into the query.
|
2022-08-11 16:38:53 +02:00
|
|
|
*/
|
|
|
|
|
function firstIdBigger(...args: [string, "elementId"] | ["elementId", string]): SqlFragment {
|
|
|
|
|
let [l, r]: [string, string] = args
|
|
|
|
|
let v
|
|
|
|
|
if (l === "elementId") {
|
|
|
|
|
v = r
|
|
|
|
|
r = "?"
|
|
|
|
|
} else {
|
|
|
|
|
v = l
|
|
|
|
|
l = "?"
|
|
|
|
|
}
|
2022-12-27 15:37:40 +01:00
|
|
|
return new SqlFragment(`(CASE WHEN length(${l}) > length(${r}) THEN 1 WHEN length(${l}) < length(${r}) THEN 0 ELSE ${l} > ${r} END)`, [v, v, v])
|
2022-08-11 16:38:53 +02:00
|
|
|
}
|
2024-08-23 13:00:37 +02:00
|
|
|
|
|
|
|
|
export function isCustomIdType(typeModel: TypeModel): boolean {
|
|
|
|
|
return typeModel.values._id.type === ValueType.CustomId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* We store customIds as base64ext in the db to make them sortable, but we get them as base64url from the server.
|
|
|
|
|
*/
|
|
|
|
|
export function ensureBase64Ext(typeModel: TypeModel, elementId: Id): Id {
|
|
|
|
|
if (isCustomIdType(typeModel)) {
|
|
|
|
|
return base64ToBase64Ext(base64UrlToBase64(elementId))
|
|
|
|
|
}
|
|
|
|
|
return elementId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function customIdToBase64Url(typeModel: TypeModel, elementId: Id): Id {
|
|
|
|
|
if (isCustomIdType(typeModel)) {
|
|
|
|
|
return base64ToBase64Url(base64ExtToBase64(elementId))
|
|
|
|
|
}
|
|
|
|
|
return elementId
|
|
|
|
|
}
|
2024-08-15 16:11:44 +02:00
|
|
|
|
|
|
|
|
export interface OfflineStorageCleaner {
|
|
|
|
|
cleanOfflineDb(offlineStorage: OfflineStorage, timeRangeDays: number | null, userId: Id, now: number): Promise<void>
|
|
|
|
|
}
|