mirror of
https://github.com/tutao/tutanota.git
synced 2025-12-08 06:09:50 +00:00
Add the header fields(sender, toRecipients, ccRecipients, bccRecipients, authStatus) to the anti-spam vectors. We also improve some of the preprocessing steps and add offline migrations by deleting old spam tables Co-authored-by: amm@tutao.de Co-authored-by: jhm <17314077+jomapp@users.noreply.github.com>
1145 lines
45 KiB
TypeScript
1145 lines
45 KiB
TypeScript
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"
|
|
|
|
/**
|
|
* 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),
|
|
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<TypeDecoder> = (() => {
|
|
const tags: Array<TypeDecoder> = []
|
|
tags[100] = dateDecoder
|
|
return tags
|
|
})()
|
|
|
|
export interface OfflineDbMeta {
|
|
lastUpdateTime: number
|
|
timeRangeDays: number
|
|
// offline db schema version
|
|
"offline-version": number
|
|
lastTrainedTime: number
|
|
lastTrainedFromScratchTime: 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<string, OfflineStorageTable>
|
|
|
|
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<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,
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* 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<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)
|
|
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<SomeEntity>): Promise<void> {
|
|
const type = getTypeString(typeRef)
|
|
await this.deleteAllRangesForType(type)
|
|
}
|
|
|
|
private async deleteAllRangesForType(type: string): Promise<void> {
|
|
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> {
|
|
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<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<ServerModelParsedInstance>> {
|
|
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)}`)
|
|
}
|
|
})
|
|
|
|
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}`)
|
|
}
|
|
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[]> {
|
|
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<Record<string, TaggedSqlValue>> = 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<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)
|
|
}
|
|
|
|
async put(typeRef: TypeRef<SomeEntity>, instance: ServerModelParsedInstance): Promise<void> {
|
|
const tm = syncMetrics?.beginMeasurement(Category.PutDb)
|
|
await this.putMultiple(typeRef, [instance])
|
|
tm?.endMeasurement()
|
|
}
|
|
|
|
async putMultiple(typeRef: TypeRef<SomeEntity>, instances: ServerModelParsedInstance[]): Promise<void> {
|
|
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<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)
|
|
}
|
|
}
|
|
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)
|
|
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)
|
|
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)
|
|
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> {
|
|
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<void> {
|
|
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")
|
|
return time ? { type: "recorded", time } : { type: "never" }
|
|
}
|
|
|
|
async putLastUpdateTime(ms: number): Promise<void> {
|
|
await this.putMetadata("lastUpdateTime", ms)
|
|
}
|
|
|
|
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> {
|
|
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>> {
|
|
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<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"
|
|
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<void> {
|
|
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<BlobElementEntity>
|
|
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<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)}`,
|
|
)
|
|
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<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
|
|
}
|
|
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> {
|
|
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)
|
|
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<T extends SomeEntity>(typeRef: TypeRef<T>, 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<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> {
|
|
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<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)
|
|
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 {
|
|
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`<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 = "?"
|
|
}
|
|
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
|
|
}
|