make OfflineStorage use base64Ext for storing customIds #7429

we now store custom id entities in the offline storage, which means we need to
make sure storing ranges and comparing ids works for them. in order to achieve
that, we decided to store the normally base64Url-encoded, not lexicographically
sortable ids in the sortable base64Ext format.

the Offline Storage needs to use the "converted" base64Ext ids internally everywhere
for custom id types, but give out ranges and entities in the "raw" base64Url format and
take raw ids as parameters.

to make this easier, we implement the conversion in the public CacheStorage::getRangeForList
implementation and use the private OfflineStorage::getRange method internally.
This commit is contained in:
nig 2024-08-23 13:00:37 +02:00 committed by FajindraII
parent 06a9476e78
commit 9fc3669a61
19 changed files with 254 additions and 95 deletions

View file

@ -73,7 +73,7 @@ export function base64ToBase64Ext(base64: Base64): Base64Ext {
let base64ext = "" let base64ext = ""
for (let i = 0; i < base64.length; i++) { for (let i = 0; i < base64.length; i++) {
let index = base64Lookup[base64.charAt(i)] const index = base64Lookup[base64.charAt(i)]
base64ext += base64extAlphabet[index] base64ext += base64extAlphabet[index]
} }

View file

@ -396,7 +396,6 @@ export class CalendarSearchViewModel {
// note in case of refactor: the fact that the list updates the URL every time it changes // note in case of refactor: the fact that the list updates the URL every time it changes
// its state is a major source of complexity and makes everything very order-dependent // its state is a major source of complexity and makes everything very order-dependent
return new ListModel<SearchResultListEntry>({ return new ListModel<SearchResultListEntry>({
topId: GENERATED_MAX_ID,
fetch: async (startId: Id, count: number) => { fetch: async (startId: Id, count: number) => {
const lastResult = this._searchResult const lastResult = this._searchResult
if (lastResult !== this._searchResult) { if (lastResult !== this._searchResult) {

View file

@ -1,4 +1,3 @@
import type { Hex } from "@tutao/tutanota-utils"
import { import {
base64ExtToBase64, base64ExtToBase64,
base64ToBase64Ext, base64ToBase64Ext,
@ -7,9 +6,11 @@ import {
base64UrlToBase64, base64UrlToBase64,
clone, clone,
compare, compare,
Hex,
hexToBase64, hexToBase64,
isSameTypeRef, isSameTypeRef,
pad, pad,
repeat,
stringToUtf8Uint8Array, stringToUtf8Uint8Array,
TypeRef, TypeRef,
uint8ArrayToBase64, uint8ArrayToBase64,
@ -37,19 +38,23 @@ export const GENERATED_MIN_ID = "------------"
*/ */
export const GENERATED_ID_BYTES_LENGTH = 9 export const GENERATED_ID_BYTES_LENGTH = 9
/**
* The byte length of a custom Id used by mail set entries
* 4 bytes timestamp (1024ms resolution)
* 9 bytes mail element Id
*/
export const MAIL_SET_ENTRY_ID_BYTE_LENGTH = 13
/** /**
* The minimum ID for elements with custom id stored on the server * The minimum ID for elements with custom id stored on the server
*/ */
export const CUSTOM_MIN_ID = "" export const CUSTOM_MIN_ID = ""
/** /**
* the maximum custom element id is enforced to be less than 256 bytes on the server. decoding this as b64url gives 255 bytes. * the maximum custom element id is enforced to be less than 256 bytes on the server. decoding this as Base64Url gives 255 bytes.
* *
* NOTE: this is currently only used as a marker value when caching calendar events. * NOTE: this is currently only used as a marker value when caching CalenderEvent and MailSetEntry.
*/ */
export const CUSTOM_MAX_ID = export const CUSTOM_MAX_ID = repeat("_", 340)
"_______________________________________________________________________________________________________________________________________________________" +
"_______________________________________________________________________________________________________________________________________________________" +
"______________________________________"
export const RANGE_ITEM_LIMIT = 1000 export const RANGE_ITEM_LIMIT = 1000
export const LOAD_MULTIPLE_LIMIT = 100 export const LOAD_MULTIPLE_LIMIT = 100
export const POST_MULTIPLE_LIMIT = 100 export const POST_MULTIPLE_LIMIT = 100
@ -126,7 +131,7 @@ export function firstBiggerThanSecondCustomId(firstId: Id, secondId: Id): boolea
return compare(customIdToUint8array(firstId), customIdToUint8array(secondId)) === 1 return compare(customIdToUint8array(firstId), customIdToUint8array(secondId)) === 1
} }
function customIdToUint8array(id: Id): Uint8Array { export function customIdToUint8array(id: Id): Uint8Array {
if (id === "") { if (id === "") {
return new Uint8Array() return new Uint8Array()
} }
@ -443,6 +448,27 @@ function removeIdentityFields<E extends Partial<SomeEntity>>(entity: E) {
_removeIdentityFields(entity) _removeIdentityFields(entity)
} }
/** construct a mail set entry Id for a given mail. see MailFolderHelper.java */
export function constructMailSetEntryId(receiveDate: Date, mailId: Id): string {
const buffer = new DataView(new ArrayBuffer(MAIL_SET_ENTRY_ID_BYTE_LENGTH))
const mailIdBytes = base64ToUint8Array(base64ExtToBase64(mailId))
// shifting the received timestamp by 10 bit reduces the resolution from 1ms to 1024ms.
// truncating to 4 bytes leaves us with enough space for epoch + 4_294_967_295 not-quite-seconds
// (until around 2109-05-15 15:00)
const timestamp: bigint = BigInt(Math.trunc(receiveDate.getTime()))
const truncatedReceiveDate = (timestamp >> 10n) & 0xffffffffn
// we don't need the leading zeroes
buffer.setBigUint64(0, truncatedReceiveDate << 32n)
for (let i = 0; i < mailIdBytes.length; i++) {
buffer.setUint8(i + 4, mailIdBytes[i])
}
return uint8arrayToCustomId(new Uint8Array(buffer.buffer))
}
export const LEGACY_TO_RECIPIENTS_ID = 112 export const LEGACY_TO_RECIPIENTS_ID = 112
export const LEGACY_CC_RECIPIENTS_ID = 113 export const LEGACY_CC_RECIPIENTS_ID = 113
export const LEGACY_BCC_RECIPIENTS_ID = 114 export const LEGACY_BCC_RECIPIENTS_ID = 114

View file

@ -4,7 +4,7 @@ import { ConnectionError, ServiceUnavailableError } from "../common/error/RestEr
import type { EntityUpdate } from "../entities/sys/TypeRefs.js" import type { EntityUpdate } from "../entities/sys/TypeRefs.js"
import { CustomerInfoTypeRef } from "../entities/sys/TypeRefs.js" import { CustomerInfoTypeRef } from "../entities/sys/TypeRefs.js"
import { ProgrammingError } from "../common/error/ProgrammingError.js" import { ProgrammingError } from "../common/error/ProgrammingError.js"
import { MailTypeRef } from "../entities/tutanota/TypeRefs.js" import { MailSetEntryTypeRef, MailTypeRef } from "../entities/tutanota/TypeRefs.js"
import { isSameId } from "../common/utils/EntityUtils.js" import { isSameId } from "../common/utils/EntityUtils.js"
import { containsEventOfType, EntityUpdateData, getEventOfType } from "../common/utils/EntityUpdateUtils.js" import { containsEventOfType, EntityUpdateData, getEventOfType } from "../common/utils/EntityUpdateUtils.js"
import { ProgressMonitorDelegate } from "./ProgressMonitorDelegate.js" import { ProgressMonitorDelegate } from "./ProgressMonitorDelegate.js"
@ -26,11 +26,12 @@ type QueueAction = (nextElement: QueuedBatch) => Promise<void>
const MOVABLE_EVENT_TYPE_REFS = [ const MOVABLE_EVENT_TYPE_REFS = [
// moved in MoveMailService // moved in MoveMailService
MailTypeRef, // moved in SwitchAccountTypeService MailTypeRef, // moved in SwitchAccountTypeService
MailSetEntryTypeRef,
CustomerInfoTypeRef, CustomerInfoTypeRef,
] ]
/** /**
* Whether the entity of the event supports MOVE operation. MOVE is supposed to be immutable so we cannot apply it to all instances. * Whether the entity of the event supports MOVE operation. MOVE is supposed to be immutable, so we cannot apply it to all instances.
*/ */
function isMovableEventType(event: EntityUpdate): boolean { function isMovableEventType(event: EntityUpdate): boolean {
return MOVABLE_EVENT_TYPE_REFS.some((typeRef) => isSameTypeRefByAttr(typeRef, event.application, event.type)) return MOVABLE_EVENT_TYPE_REFS.some((typeRef) => isSameTypeRefByAttr(typeRef, event.application, event.type))

View file

@ -1,5 +1,6 @@
import { ElementEntity, ListElementEntity, SomeEntity, TypeModel } from "../../common/EntityTypes.js" import { ElementEntity, ListElementEntity, SomeEntity, TypeModel } from "../../common/EntityTypes.js"
import { import {
CUSTOM_MIN_ID,
elementIdPart, elementIdPart,
firstBiggerThanSecond, firstBiggerThanSecond,
GENERATED_MAX_ID, GENERATED_MAX_ID,
@ -14,6 +15,10 @@ import { EncodeOptions, Token, Type } from "cborg"
import { import {
assert, assert,
assertNotNull, assertNotNull,
base64ExtToBase64,
base64ToBase64Ext,
base64ToBase64Url,
base64UrlToBase64,
DAY_IN_MILLIS, DAY_IN_MILLIS,
getTypeId, getTypeId,
groupByAndMap, groupByAndMap,
@ -44,7 +49,7 @@ import { InterWindowEventFacadeSendDispatcher } from "../../../native/common/gen
import { SqlCipherFacade } from "../../../native/common/generatedipc/SqlCipherFacade.js" import { SqlCipherFacade } from "../../../native/common/generatedipc/SqlCipherFacade.js"
import { FormattedQuery, SqlValue, TaggedSqlValue, untagSqlObject } from "./SqlValue.js" import { FormattedQuery, SqlValue, TaggedSqlValue, untagSqlObject } from "./SqlValue.js"
import { FolderSystem } from "../../common/mail/FolderSystem.js" import { FolderSystem } from "../../common/mail/FolderSystem.js"
import { Type as TypeId } from "../../common/EntityConstants.js" import { Type as TypeId, ValueType } from "../../common/EntityConstants.js"
import { OutOfSyncError } from "../../common/error/OutOfSyncError.js" import { OutOfSyncError } from "../../common/error/OutOfSyncError.js"
import { sql, SqlFragment } from "./Sql.js" import { sql, SqlFragment } from "./Sql.js"
import { isDraft, isSpamOrTrashFolder } from "../../common/CommonMailUtils.js" import { isDraft, isSpamOrTrashFolder } from "../../common/CommonMailUtils.js"
@ -109,7 +114,7 @@ const TableDefinitions = Object.freeze({
"type TEXT NOT NULL, listId TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, listId, elementId)", "type TEXT NOT NULL, listId TEXT NOT NULL, elementId TEXT NOT NULL, ownerGroup TEXT, entity BLOB NOT NULL, PRIMARY KEY (type, listId, elementId)",
} as const) } as const)
type Range = { lower: string; upper: string } type Range = { lower: Id; upper: Id }
export interface OfflineStorageInitArgs { export interface OfflineStorageInitArgs {
userId: Id userId: Id
@ -183,6 +188,7 @@ export class OfflineStorage implements CacheStorage, ExposedCacheStorage {
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
let typeModel: TypeModel let typeModel: TypeModel
typeModel = await resolveTypeReference(typeRef) typeModel = await resolveTypeReference(typeRef)
elementId = ensureBase64Ext(typeModel, elementId)
let formattedQuery let formattedQuery
switch (typeModel.type) { switch (typeModel.type) {
case TypeId.Element: case TypeId.Element:
@ -231,6 +237,7 @@ export class OfflineStorage implements CacheStorage, ExposedCacheStorage {
async get<T extends SomeEntity>(typeRef: TypeRef<T>, listId: Id | null, elementId: Id): Promise<T | null> { async get<T extends SomeEntity>(typeRef: TypeRef<T>, listId: Id | null, elementId: Id): Promise<T | null> {
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const typeModel = await resolveTypeReference(typeRef) const typeModel = await resolveTypeReference(typeRef)
elementId = ensureBase64Ext(typeModel, elementId)
let formattedQuery let formattedQuery
switch (typeModel.type) { switch (typeModel.type) {
case TypeId.Element: case TypeId.Element:
@ -251,6 +258,9 @@ export class OfflineStorage implements CacheStorage, ExposedCacheStorage {
async provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<T>> { async provideMultiple<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementIds: Id[]): Promise<Array<T>> {
if (elementIds.length === 0) return [] if (elementIds.length === 0) return []
const typeModel = await resolveTypeReference(typeRef)
elementIds = elementIds.map((el) => ensureBase64Ext(typeModel, el))
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const serializedList: ReadonlyArray<Record<string, TaggedSqlValue>> = await this.allChunked( const serializedList: ReadonlyArray<Record<string, TaggedSqlValue>> = await this.allChunked(
MAX_SAFE_SQL_VARS - 2, MAX_SAFE_SQL_VARS - 2,
@ -265,31 +275,44 @@ export class OfflineStorage implements CacheStorage, ExposedCacheStorage {
async getIdsInRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<Id>> { async getIdsInRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Array<Id>> {
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const range = await this.getRange(type, listId) const range = await this.getRange(typeRef, listId)
if (range == null) { if (range == null) {
throw new Error(`no range exists for ${type} and list ${listId}`) throw new Error(`no range exists for ${type} and list ${listId}`)
} }
const { lower, upper } = range
const { query, params } = sql`SELECT elementId FROM list_entities const { query, params } = sql`SELECT elementId FROM list_entities
WHERE type = ${type} WHERE type = ${type}
AND listId = ${listId} AND listId = ${listId}
AND (elementId = ${lower} AND (elementId = ${range.lower}
OR ${firstIdBigger("elementId", lower)}) OR ${firstIdBigger("elementId", range.lower)})
AND NOT(${firstIdBigger("elementId", upper)})` AND NOT(${firstIdBigger("elementId", range.upper)})`
const rows = await this.sqlCipherFacade.all(query, params) const rows = await this.sqlCipherFacade.all(query, params)
return rows.map((row) => row.elementId.value as string) return rows.map((row) => 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> { async getRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id): Promise<Range | null> {
return this.getRange(getTypeId(typeRef), listId) let range = await this.getRange(typeRef, listId)
const typeModel = await resolveTypeReference(typeRef)
if (range == null) return range
return {
lower: customIdToBase64Url(typeModel, range.lower),
upper: customIdToBase64Url(typeModel, range.upper),
}
} }
async isElementIdInCacheRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, id: Id): Promise<boolean> { async isElementIdInCacheRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, elementId: Id): Promise<boolean> {
const range = await this.getRangeForList(typeRef, listId) const typeModel = await resolveTypeReference(typeRef)
return range != null && !firstBiggerThanSecond(id, range.upper) && !firstBiggerThanSecond(range.lower, id) elementId = ensureBase64Ext(typeModel, elementId)
const range = await this.getRange(typeRef, listId)
return range != null && !firstBiggerThanSecond(elementId, range.upper) && !firstBiggerThanSecond(range.lower, elementId)
} }
async provideFromRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, start: Id, count: number, reverse: boolean): Promise<T[]> { async provideFromRange<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, start: Id, count: number, reverse: boolean): Promise<T[]> {
const typeModel = await resolveTypeReference(typeRef)
start = ensureBase64Ext(typeModel, start)
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
let formattedQuery let formattedQuery
if (reverse) { if (reverse) {
@ -313,10 +336,11 @@ AND NOT(${firstIdBigger("elementId", upper)})`
async put(originalEntity: SomeEntity): Promise<void> { async put(originalEntity: SomeEntity): Promise<void> {
const serializedEntity = this.serialize(originalEntity) const serializedEntity = this.serialize(originalEntity)
const { listId, elementId } = expandId(originalEntity._id) let { listId, elementId } = expandId(originalEntity._id)
const type = getTypeId(originalEntity._type) const type = getTypeId(originalEntity._type)
const ownerGroup = originalEntity._ownerGroup const ownerGroup = originalEntity._ownerGroup
const typeModel = await resolveTypeReference(originalEntity._type) const typeModel = await resolveTypeReference(originalEntity._type)
elementId = ensureBase64Ext(typeModel, elementId)
let formattedQuery: FormattedQuery let formattedQuery: FormattedQuery
switch (typeModel.type) { switch (typeModel.type) {
case TypeId.Element: case TypeId.Element:
@ -334,19 +358,25 @@ AND NOT(${firstIdBigger("elementId", upper)})`
await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params) await this.sqlCipherFacade.run(formattedQuery.query, formattedQuery.params)
} }
async setLowerRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, id: Id): Promise<void> { async setLowerRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lowerId: Id): Promise<void> {
lowerId = ensureBase64Ext(await resolveTypeReference(typeRef), lowerId)
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const { query, params } = sql`UPDATE ranges SET lower = ${id} WHERE type = ${type} AND listId = ${listId}` const { query, params } = sql`UPDATE ranges SET lower = ${lowerId} WHERE type = ${type} AND listId = ${listId}`
await this.sqlCipherFacade.run(query, params) await this.sqlCipherFacade.run(query, params)
} }
async setUpperRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, id: Id): Promise<void> { async setUpperRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, upperId: Id): Promise<void> {
upperId = ensureBase64Ext(await resolveTypeReference(typeRef), upperId)
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const { query, params } = sql`UPDATE ranges SET upper = ${id} WHERE type = ${type} AND listId = ${listId}` const { query, params } = sql`UPDATE ranges SET upper = ${upperId} WHERE type = ${type} AND listId = ${listId}`
await this.sqlCipherFacade.run(query, params) await this.sqlCipherFacade.run(query, params)
} }
async setNewRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lower: Id, upper: Id): Promise<void> { async setNewRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, lower: Id, upper: Id): Promise<void> {
const typeModel = await resolveTypeReference(typeRef)
lower = ensureBase64Ext(typeModel, lower)
upper = ensureBase64Ext(typeModel, upper)
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const { query, params } = sql`INSERT OR REPLACE INTO ranges VALUES (${type}, ${listId}, ${lower}, ${upper})` const { query, params } = sql`INSERT OR REPLACE INTO ranges VALUES (${type}, ${listId}, ${lower}, ${upper})`
return this.sqlCipherFacade.run(query, params) return this.sqlCipherFacade.run(query, params)
@ -506,7 +536,7 @@ AND NOT(${firstIdBigger("elementId", upper)})`
for (const mailBox of mailBoxes) { for (const mailBox of mailBoxes) {
const isMailsetMigrated = mailBox.currentMailBag != null const isMailsetMigrated = mailBox.currentMailBag != null
if (isMailsetMigrated) { if (isMailsetMigrated) {
var mailListIds = [mailBox.currentMailBag!, ...mailBox.archivedMailBags].map((mailbag) => mailbag.mails) const mailListIds = [mailBox.currentMailBag!, ...mailBox.archivedMailBags].map((mailbag) => mailbag.mails)
for (const mailListId of mailListIds) { for (const mailListId of mailListIds) {
await this.deleteMailList(mailListId, cutoffId) await this.deleteMailList(mailListId, cutoffId)
} }
@ -531,9 +561,12 @@ AND NOT(${firstIdBigger("elementId", upper)})`
} }
} }
private async getRange(type: string, listId: Id): Promise<Range | null> { private async getRange(typeRef: TypeRef<ElementEntity | ListElementEntity>, listId: Id): Promise<Range | null> {
const type = getTypeId(typeRef)
const { query, params } = sql`SELECT upper, lower FROM ranges WHERE type = ${type} AND listId = ${listId}` const { query, params } = sql`SELECT upper, lower FROM ranges WHERE type = ${type} AND listId = ${listId}`
const row = (await this.sqlCipherFacade.get(query, params)) ?? null const row = (await this.sqlCipherFacade.get(query, params)) ?? null
return mapNullable(row, untagSqlObject) as Range | null return mapNullable(row, untagSqlObject) as Range | null
} }
@ -552,6 +585,8 @@ AND NOT(${firstIdBigger("elementId", upper)})`
* 2. We might need them in the future for showing the whole thread * 2. We might need them in the future for showing the whole thread
*/ */
private async deleteMailList(listId: Id, cutoffId: Id): Promise<void> { private async deleteMailList(listId: Id, cutoffId: Id): Promise<void> {
// fixme: do we want to remove mailsetentries as well here?
// we don't have the mailsetentry list id
// We lock access to the "ranges" db here in order to prevent race conditions when accessing the "ranges" database. // We lock access to the "ranges" db here in order to prevent race conditions when accessing the "ranges" database.
await this.lockRangesDbAccess(listId) await this.lockRangesDbAccess(listId)
try { try {
@ -647,8 +682,11 @@ AND NOT(${firstIdBigger("elementId", upper)})`
private async updateRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, cutoffId: Id): Promise<void> { private async updateRangeForList<T extends ListElementEntity>(typeRef: TypeRef<T>, listId: Id, cutoffId: Id): Promise<void> {
const type = getTypeId(typeRef) const type = getTypeId(typeRef)
const typeModel = await resolveTypeReference(typeRef)
const isCustomId = isCustomIdType(typeModel)
cutoffId = ensureBase64Ext(typeModel, cutoffId)
const range = await this.getRange(type, listId) const range = await this.getRange(typeRef, listId)
if (range == null) { if (range == null) {
return return
} }
@ -657,8 +695,9 @@ AND NOT(${firstIdBigger("elementId", upper)})`
// 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. // 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, // 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 // otherwise we will just modify it normally
if (range.lower === GENERATED_MIN_ID) { const expectedMinId = isCustomId ? CUSTOM_MIN_ID : GENERATED_MIN_ID
const entities = await this.provideFromRange(typeRef, listId, GENERATED_MIN_ID, 1, false) if (range.lower === expectedMinId) {
const entities = await this.provideFromRange(typeRef, listId, expectedMinId, 1, false)
const id = mapNullable(entities[0], getElementId) const id = mapNullable(entities[0], getElementId)
const rangeWontBeModified = id == null || firstBiggerThanSecond(id, cutoffId) || id === cutoffId const rangeWontBeModified = id == null || firstBiggerThanSecond(id, cutoffId) || id === cutoffId
if (rangeWontBeModified) { if (rangeWontBeModified) {
@ -759,3 +798,24 @@ function firstIdBigger(...args: [string, "elementId"] | ["elementId", string]):
} }
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]) 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 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
}

View file

@ -2,7 +2,7 @@ import type { EntityRestInterface, OwnerEncSessionKeyProvider, OwnerKeyProvider
import { EntityRestClient, EntityRestClientSetupOptions } from "./EntityRestClient" import { EntityRestClient, EntityRestClientSetupOptions } from "./EntityRestClient"
import { resolveTypeReference } from "../../common/EntityFunctions" import { resolveTypeReference } from "../../common/EntityFunctions"
import { OperationType } from "../../common/TutanotaConstants" import { OperationType } from "../../common/TutanotaConstants"
import { assertNotNull, difference, getFirstOrThrow, groupBy, isSameTypeRef, lastThrow, TypeRef } from "@tutao/tutanota-utils" import { assertNotNull, difference, getFirstOrThrow, getTypeId, groupBy, isEmpty, isSameTypeRef, lastThrow, TypeRef } from "@tutao/tutanota-utils"
import { import {
BucketPermissionTypeRef, BucketPermissionTypeRef,
EntityEventBatchTypeRef, EntityEventBatchTypeRef,
@ -20,8 +20,8 @@ import {
} from "../../entities/sys/TypeRefs.js" } from "../../entities/sys/TypeRefs.js"
import { ValueType } from "../../common/EntityConstants.js" import { ValueType } from "../../common/EntityConstants.js"
import { NotAuthorizedError, NotFoundError } from "../../common/error/RestError" import { NotAuthorizedError, NotFoundError } from "../../common/error/RestError"
import { CalendarEventUidIndexTypeRef, Mail, MailDetailsBlobTypeRef, MailTypeRef } from "../../entities/tutanota/TypeRefs.js" import { CalendarEventUidIndexTypeRef, Mail, MailDetailsBlobTypeRef, MailSetEntryTypeRef, MailTypeRef } from "../../entities/tutanota/TypeRefs.js"
import { firstBiggerThanSecond, GENERATED_MAX_ID, GENERATED_MIN_ID, getElementId, isSameId } from "../../common/utils/EntityUtils" import { CUSTOM_MAX_ID, CUSTOM_MIN_ID, firstBiggerThanSecond, GENERATED_MAX_ID, GENERATED_MIN_ID, getElementId, isSameId } from "../../common/utils/EntityUtils"
import { ProgrammingError } from "../../common/error/ProgrammingError" import { ProgrammingError } from "../../common/error/ProgrammingError"
import { assertWorkerOrNode } from "../../common/Env" import { assertWorkerOrNode } from "../../common/Env"
import type { ListElementEntity, SomeEntity, TypeModel } from "../../common/EntityTypes" import type { ListElementEntity, SomeEntity, TypeModel } from "../../common/EntityTypes"
@ -30,6 +30,7 @@ import { QueuedBatch } from "../EventQueue.js"
import { ENTITY_EVENT_BATCH_EXPIRE_MS } from "../EventBusClient" import { ENTITY_EVENT_BATCH_EXPIRE_MS } from "../EventBusClient"
import { CustomCacheHandlerMap } from "./CustomCacheHandler.js" import { CustomCacheHandlerMap } from "./CustomCacheHandler.js"
import { containsEventOfType, EntityUpdateData, getEventOfType } from "../../common/utils/EntityUpdateUtils.js" import { containsEventOfType, EntityUpdateData, getEventOfType } from "../../common/utils/EntityUpdateUtils.js"
import { isCustomIdType } from "../offline/OfflineStorage.js"
assertWorkerOrNode() assertWorkerOrNode()
@ -377,12 +378,15 @@ export class DefaultEntityRestCache implements EntityRestCache {
try { try {
const range = await this.storage.getRangeForList(typeRef, listId) const range = await this.storage.getRangeForList(typeRef, listId)
if (getTypeId(typeRef) == "tutanota/MailSetEntry") {
console.log(getTypeId(typeRef), listId, start, count, reverse)
console.log("range", range)
}
if (range == null) { if (range == null) {
await this.populateNewListWithRange(typeRef, listId, start, count, reverse) await this.populateNewListWithRange(typeRef, listId, start, count, reverse)
} else if (isStartIdWithinRange(range, start)) { } else if (isStartIdWithinRange(range, start, typeModel)) {
await this.extendFromWithinRange(typeRef, listId, start, count, reverse) await this.extendFromWithinRange(typeRef, listId, start, count, reverse)
} else if (isRangeRequestAwayFromExistingRange(range, reverse, start)) { } else if (isRangeRequestAwayFromExistingRange(range, reverse, start, typeModel)) {
await this.extendAwayFromRange(typeRef, listId, start, count, reverse) await this.extendAwayFromRange(typeRef, listId, start, count, reverse)
} else { } else {
await this.extendTowardsRange(typeRef, listId, start, count, reverse) await this.extendTowardsRange(typeRef, listId, start, count, reverse)
@ -512,12 +516,14 @@ export class DefaultEntityRestCache implements EntityRestCache {
wasReverseRequest: boolean, wasReverseRequest: boolean,
receivedEntities: T[], receivedEntities: T[],
) { ) {
const isCustomId = isCustomIdType(await resolveTypeReference(typeRef))
let elementsToAdd = receivedEntities let elementsToAdd = receivedEntities
if (wasReverseRequest) { if (wasReverseRequest) {
// Ensure that elements are cached in ascending (not reverse) order // Ensure that elements are cached in ascending (not reverse) order
elementsToAdd = receivedEntities.reverse() elementsToAdd = receivedEntities.reverse()
if (receivedEntities.length < countRequested) { if (receivedEntities.length < countRequested) {
await this.storage.setLowerRangeForList(typeRef, listId, GENERATED_MIN_ID) console.log("finished loading, setting min id")
await this.storage.setLowerRangeForList(typeRef, listId, isCustomId ? CUSTOM_MIN_ID : GENERATED_MIN_ID)
} else { } else {
// After reversing the list the first element in the list is the lower range limit // After reversing the list the first element in the list is the lower range limit
await this.storage.setLowerRangeForList(typeRef, listId, getElementId(getFirstOrThrow(receivedEntities))) await this.storage.setLowerRangeForList(typeRef, listId, getElementId(getFirstOrThrow(receivedEntities)))
@ -526,7 +532,8 @@ export class DefaultEntityRestCache implements EntityRestCache {
// Last element in the list is the upper range limit // Last element in the list is the upper range limit
if (receivedEntities.length < countRequested) { if (receivedEntities.length < countRequested) {
// all elements have been loaded, so the upper range must be set to MAX_ID // all elements have been loaded, so the upper range must be set to MAX_ID
await this.storage.setUpperRangeForList(typeRef, listId, GENERATED_MAX_ID) console.log("finished loading, setting max id")
await this.storage.setUpperRangeForList(typeRef, listId, isCustomId ? CUSTOM_MAX_ID : GENERATED_MAX_ID)
} else { } else {
await this.storage.setUpperRangeForList(typeRef, listId, getElementId(lastThrow(receivedEntities))) await this.storage.setUpperRangeForList(typeRef, listId, getElementId(lastThrow(receivedEntities)))
} }
@ -556,7 +563,13 @@ export class DefaultEntityRestCache implements EntityRestCache {
} }
const { lower, upper } = range const { lower, upper } = range
let indexOfStart = allRangeList.indexOf(start) let indexOfStart = allRangeList.indexOf(start)
if ((!reverse && upper === GENERATED_MAX_ID) || (reverse && lower === GENERATED_MIN_ID)) {
const typeModel = await resolveTypeReference(typeRef)
const isCustomId = isCustomIdType(typeModel)
if (
(!reverse && (isCustomId ? upper == CUSTOM_MAX_ID : upper === GENERATED_MAX_ID)) ||
(reverse && (isCustomId ? lower == CUSTOM_MIN_ID : lower === GENERATED_MIN_ID))
) {
// we have already loaded the complete range in the desired direction, so we do not have to load from server // we have already loaded the complete range in the desired direction, so we do not have to load from server
elementsToRead = 0 elementsToRead = 0
} else if (allRangeList.length === 0) { } else if (allRangeList.length === 0) {
@ -571,7 +584,7 @@ export class DefaultEntityRestCache implements EntityRestCache {
elementsToRead = count - (allRangeList.length - 1 - indexOfStart) elementsToRead = count - (allRangeList.length - 1 - indexOfStart)
startElementId = allRangeList[allRangeList.length - 1] // use the highest id in allRange as start element startElementId = allRangeList[allRangeList.length - 1] // use the highest id in allRange as start element
} }
} else if (lower === start || (firstBiggerThanSecond(start, lower) && firstBiggerThanSecond(allRangeList[0], start))) { } else if (lower === start || (firstBiggerThanSecond(start, lower, typeModel) && firstBiggerThanSecond(allRangeList[0], start, typeModel))) {
// Start element is not in allRange but has been used has start element for a range request, eg. EntityRestInterface.GENERATED_MIN_ID, or start is between lower range id and lowest element in range // Start element is not in allRange but has been used has start element for a range request, eg. EntityRestInterface.GENERATED_MIN_ID, or start is between lower range id and lowest element in range
if (!reverse) { if (!reverse) {
// if not reverse read only elements that are not in allRange // if not reverse read only elements that are not in allRange
@ -579,7 +592,10 @@ export class DefaultEntityRestCache implements EntityRestCache {
elementsToRead = count - allRangeList.length elementsToRead = count - allRangeList.length
} }
// if reverse read all elements // if reverse read all elements
} else if (upper === start || (firstBiggerThanSecond(start, allRangeList[allRangeList.length - 1]) && firstBiggerThanSecond(upper, start))) { } else if (
upper === start ||
(firstBiggerThanSecond(start, allRangeList[allRangeList.length - 1], typeModel) && firstBiggerThanSecond(upper, start, typeModel))
) {
// Start element is not in allRange but has been used has start element for a range request, eg. EntityRestInterface.GENERATED_MAX_ID, or start is between upper range id and highest element in range // Start element is not in allRange but has been used has start element for a range request, eg. EntityRestInterface.GENERATED_MAX_ID, or start is between upper range id and highest element in range
if (reverse) { if (reverse) {
// if not reverse read only elements that are not in allRange // if not reverse read only elements that are not in allRange
@ -606,18 +622,17 @@ export class DefaultEntityRestCache implements EntityRestCache {
const regularUpdates: EntityUpdate[] = [] // all updates not resulting from post multiple requests const regularUpdates: EntityUpdate[] = [] // all updates not resulting from post multiple requests
const updatesArray = batch.events const updatesArray = batch.events
for (const update of updatesArray) { for (const update of updatesArray) {
if (update.application !== "monitor") { // monitor application is ignored
// monitor application is ignored if (update.application === "monitor") continue
// mails are ignored because move operations are handled as a special event (and no post multiple is possible) // mails are ignored because move operations are handled as a special event (and no post multiple is possible)
if ( if (
update.operation === OperationType.CREATE && update.operation === OperationType.CREATE &&
getUpdateInstanceId(update).instanceListId != null && getUpdateInstanceId(update).instanceListId != null &&
!isSameTypeRef(new TypeRef(update.application, update.type), MailTypeRef) !isSameTypeRef(new TypeRef(update.application, update.type), MailTypeRef)
) { ) {
createUpdatesForLETs.push(update) createUpdatesForLETs.push(update)
} else { } else {
regularUpdates.push(update) regularUpdates.push(update)
}
} }
} }
@ -722,15 +737,18 @@ export class DefaultEntityRestCache implements EntityRestCache {
if (instanceListId != null) { if (instanceListId != null) {
const deleteEvent = getEventOfType(batch, OperationType.DELETE, instanceId) const deleteEvent = getEventOfType(batch, OperationType.DELETE, instanceId)
const element = deleteEvent && isSameTypeRef(MailTypeRef, typeRef) ? await this.storage.get(typeRef, deleteEvent.instanceListId, instanceId) : null const mail = deleteEvent && isSameTypeRef(MailTypeRef, typeRef) ? await this.storage.get(MailTypeRef, deleteEvent.instanceListId, instanceId) : null
if (deleteEvent != null && element != null) { // avoid downloading new mail element for non-mailSet user.
// can be removed once all mailbox have been migrated to mailSet (once lastNonOutdatedClientVersion is >= v242)
if (deleteEvent != null && mail != null && isEmpty(mail.sets)) {
// It is a move event for cached mail // It is a move event for cached mail
await this.storage.deleteIfExists(typeRef, deleteEvent.instanceListId, instanceId) await this.storage.deleteIfExists(typeRef, deleteEvent.instanceListId, instanceId)
await this.updateListIdOfMailAndUpdateCache(element as Mail, instanceListId, instanceId) await this.updateListIdOfMailAndUpdateCache(mail, instanceListId, instanceId)
return update return update
} else if (await this.storage.isElementIdInCacheRange(typeRef, instanceListId, instanceId)) { } else if (await this.storage.isElementIdInCacheRange(typeRef, instanceListId, instanceId)) {
// No need to try to download something that's not there anymore // No need to try to download something that's not there anymore
// We do not consult custom handlers here because they are only needed for list elements. // We do not consult custom handlers here because they are only needed for list elements.
console.log("downloading create event for", getTypeId(typeRef), instanceListId, instanceId)
return this.entityRestClient return this.entityRestClient
.load(typeRef, [instanceListId, instanceId]) .load(typeRef, [instanceListId, instanceId])
.then((entity) => this.storage.put(entity)) .then((entity) => this.storage.put(entity))
@ -880,16 +898,16 @@ export function getUpdateInstanceId(update: EntityUpdate): { instanceListId: Id
/** /**
* Check if a range request begins inside an existing range * Check if a range request begins inside an existing range
*/ */
function isStartIdWithinRange(range: Range, startId: Id): boolean { function isStartIdWithinRange(range: Range, startId: Id, typeModel: TypeModel): boolean {
return !firstBiggerThanSecond(startId, range.upper) && !firstBiggerThanSecond(range.lower, startId) return !firstBiggerThanSecond(startId, range.upper, typeModel) && !firstBiggerThanSecond(range.lower, startId, typeModel)
} }
/** /**
* Check if a range request is going away from an existing range * Check if a range request is going away from an existing range
* Assumes that the range request doesn't start inside the range * Assumes that the range request doesn't start inside the range
*/ */
function isRangeRequestAwayFromExistingRange(range: Range, reverse: boolean, start: string) { function isRangeRequestAwayFromExistingRange(range: Range, reverse: boolean, start: string, typeModel: TypeModel) {
return reverse ? firstBiggerThanSecond(range.lower, start) : firstBiggerThanSecond(start, range.upper) return reverse ? firstBiggerThanSecond(range.lower, start, typeModel) : firstBiggerThanSecond(start, range.upper, typeModel)
} }
/** /**
@ -909,5 +927,9 @@ function isIgnoredType(typeRef: TypeRef<unknown>): boolean {
* isIgnoredType(ref) -/-> !isCachedType(ref) * isIgnoredType(ref) -/-> !isCachedType(ref)
*/ */
function isCachedType(typeModel: TypeModel, typeRef: TypeRef<unknown>): boolean { function isCachedType(typeModel: TypeModel, typeRef: TypeRef<unknown>): boolean {
return !isIgnoredType(typeRef) && typeModel.values._id.type === ValueType.GeneratedId return (!isIgnoredType(typeRef) && isGeneratedIdType(typeModel)) || isSameTypeRef(typeRef, MailSetEntryTypeRef)
}
function isGeneratedIdType(typeModel: TypeModel): boolean {
return typeModel.values._id.type === ValueType.GeneratedId
} }

View file

@ -111,6 +111,7 @@ export class EphemeralCacheStorage implements CacheStorage {
const entity = clone(originalEntity) const entity = clone(originalEntity)
const typeRef = entity._type const typeRef = entity._type
const typeModel = await resolveTypeReference(typeRef) const typeModel = await resolveTypeReference(typeRef)
// fixme: maybe we can fix the sortability of the custom id types here, directly before inserting?
switch (typeModel.type) { switch (typeModel.type) {
case TypeId.Element: case TypeId.Element:
const elementEntity = entity as ElementEntity const elementEntity = entity as ElementEntity

View file

@ -1,4 +1,4 @@
import { elementIdPart, getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils.js" import { elementIdPart, GENERATED_MAX_ID, getElementId, isSameId, ListElement } from "../api/common/utils/EntityUtils.js"
import { ListLoadingState, ListState } from "../gui/base/List.js" import { ListLoadingState, ListState } from "../gui/base/List.js"
import { OperationType } from "../api/common/TutanotaConstants.js" import { OperationType } from "../api/common/TutanotaConstants.js"
@ -25,14 +25,19 @@ import { ListFetchResult, PageSize } from "../gui/base/ListUtils.js"
import { isOfflineError } from "../api/common/utils/ErrorUtils.js" import { isOfflineError } from "../api/common/utils/ErrorUtils.js"
import { ListAutoSelectBehavior } from "./DeviceConfig.js" import { ListAutoSelectBehavior } from "./DeviceConfig.js"
export interface ListModelConfig<ListElementType> { export type ListModelConfig<ListElementType> = {
topId: Id
/** /**
* Get the given number of entities starting after the given id. May return more elements than requested, e.g. if all elements are available on first fetch. * Get the given number of entities starting after the given id. May return more elements than requested, e.g. if all elements are available on first fetch.
*/ */
fetch(startId: Id, count: number): Promise<ListFetchResult<ListElementType>> fetch(startId: Id, count: number): Promise<ListFetchResult<ListElementType>>
/**
* some lists load elements via an index indirection,
* so they use a different list to load than is actually displayed.
* in fact, the displayed entities might not even be stored in the same list
*/
getLoadIdForElement?: (element: ListElementType | null | undefined) => Id
/** /**
* Returns null if the given element could not be loaded * Returns null if the given element could not be loaded
*/ */
@ -53,7 +58,14 @@ type PrivateListState<ElementType> = Omit<ListState<ElementType>, "items" | "act
/** ListModel that does the state upkeep for the List, including loading state, loaded items, selection and filters*/ /** ListModel that does the state upkeep for the List, including loading state, loaded items, selection and filters*/
export class ListModel<ElementType extends ListElement> { export class ListModel<ElementType extends ListElement> {
constructor(private readonly config: ListModelConfig<ElementType>) {} private readonly config: Required<ListModelConfig<ElementType>>
constructor(config: ListModelConfig<ElementType>) {
if (config.getLoadIdForElement == null) {
config.getLoadIdForElement = (element: ElementType) => (element != null ? getElementId(element) : GENERATED_MAX_ID)
}
this.config = config as Required<ListModelConfig<ElementType>>
}
private loadState: "created" | "initialized" = "created" private loadState: "created" | "initialized" = "created"
private loading: Promise<unknown> = Promise.resolve() private loading: Promise<unknown> = Promise.resolve()
@ -153,7 +165,8 @@ export class ListModel<ElementType extends ListElement> {
this.loading = Promise.resolve().then(async () => { this.loading = Promise.resolve().then(async () => {
const lastItem = last(this.rawState.unfilteredItems) const lastItem = last(this.rawState.unfilteredItems)
try { try {
const { items: newItems, complete } = await this.config.fetch(lastItem ? getElementId(lastItem) : this.config.topId, PageSize) const idToLoadFrom = this.config.getLoadIdForElement(lastItem)
const { items: newItems, complete } = await this.config.fetch(idToLoadFrom, PageSize)
// if the loading was cancelled in the meantime, don't insert anything so that it's not confusing // if the loading was cancelled in the meantime, don't insert anything so that it's not confusing
if (this.state.loadingStatus === ListLoadingState.ConnectionLost) { if (this.state.loadingStatus === ListLoadingState.ConnectionLost) {
return return

View file

@ -216,7 +216,6 @@ export class UserListView implements UpdatableSettingsViewer {
private makeListModel(): ListModel<GroupInfo> { private makeListModel(): ListModel<GroupInfo> {
const listModel = new ListModel<GroupInfo>({ const listModel = new ListModel<GroupInfo>({
topId: GENERATED_MAX_ID,
sortCompare: compareGroupInfos, sortCompare: compareGroupInfos,
fetch: async (startId) => { fetch: async (startId) => {
if (startId !== GENERATED_MAX_ID) { if (startId !== GENERATED_MAX_ID) {

View file

@ -8,7 +8,7 @@ import {
ContactTypeRef, ContactTypeRef,
createContactListEntry, createContactListEntry,
} from "../../../common/api/entities/tutanota/TypeRefs.js" } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { GENERATED_MAX_ID, getEtId, isSameId } from "../../../common/api/common/utils/EntityUtils.js" import { getEtId, isSameId } from "../../../common/api/common/utils/EntityUtils.js"
import { EntityClient } from "../../../common/api/common/EntityClient.js" import { EntityClient } from "../../../common/api/common/EntityClient.js"
import { GroupManagementFacade } from "../../../common/api/worker/facades/lazy/GroupManagementFacade.js" import { GroupManagementFacade } from "../../../common/api/worker/facades/lazy/GroupManagementFacade.js"
import { LoginController } from "../../../common/api/main/LoginController.js" import { LoginController } from "../../../common/api/main/LoginController.js"
@ -83,7 +83,6 @@ export class ContactListViewModel {
private readonly _listModel = memoized((listId: Id) => { private readonly _listModel = memoized((listId: Id) => {
const newListModel = new ListModel<ContactListEntry>({ const newListModel = new ListModel<ContactListEntry>({
topId: GENERATED_MAX_ID,
fetch: async () => { fetch: async () => {
const items = await this.getRecipientsForList(listId) const items = await this.getRecipientsForList(listId)
return { items, complete: true } return { items, complete: true }

View file

@ -6,7 +6,7 @@ import { Contact, ContactTypeRef } from "../../../common/api/entities/tutanota/T
import { compareContacts } from "./ContactGuiUtils.js" import { compareContacts } from "./ContactGuiUtils.js"
import { ListState } from "../../../common/gui/base/List.js" import { ListState } from "../../../common/gui/base/List.js"
import { assertNotNull, lazyMemoized } from "@tutao/tutanota-utils" import { assertNotNull, lazyMemoized } from "@tutao/tutanota-utils"
import { GENERATED_MAX_ID, getElementId } from "../../../common/api/common/utils/EntityUtils.js" import { getElementId } from "../../../common/api/common/utils/EntityUtils.js"
import Stream from "mithril/stream" import Stream from "mithril/stream"
import { Router } from "../../../common/gui/ScopedRouter.js" import { Router } from "../../../common/gui/ScopedRouter.js"
import { isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils.js" import { isUpdateForTypeRef } from "../../../common/api/common/utils/EntityUpdateUtils.js"
@ -29,7 +29,6 @@ export class ContactViewModel {
) {} ) {}
readonly listModel: ListModel<Contact> = new ListModel<Contact>({ readonly listModel: ListModel<Contact> = new ListModel<Contact>({
topId: GENERATED_MAX_ID,
fetch: async () => { fetch: async () => {
const items = await this.entityClient.loadAll(ContactTypeRef, this.contactListId) const items = await this.entityClient.loadAll(ContactTypeRef, this.contactListId)
return { items, complete: true } return { items, complete: true }

View file

@ -1,11 +1,13 @@
import { ListModel } from "../../../common/misc/ListModel.js" import { ListModel, ListModelConfig } from "../../../common/misc/ListModel.js"
import { MailboxDetail, MailModel } from "../../../common/mailFunctionality/MailModel.js" import { MailboxDetail, MailModel } from "../../../common/mailFunctionality/MailModel.js"
import { EntityClient } from "../../../common/api/common/EntityClient.js" import { EntityClient } from "../../../common/api/common/EntityClient.js"
import { Mail, MailFolder, MailSetEntry, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js" import { Mail, MailFolder, MailSetEntry, MailSetEntryTypeRef, MailTypeRef } from "../../../common/api/entities/tutanota/TypeRefs.js"
import { import {
constructMailSetEntryId,
CUSTOM_MAX_ID,
customIdToUint8array,
elementIdPart, elementIdPart,
firstBiggerThanSecond, firstBiggerThanSecond,
GENERATED_MAX_ID,
getElementId, getElementId,
isSameId, isSameId,
listIdPart, listIdPart,
@ -13,6 +15,7 @@ import {
} from "../../../common/api/common/utils/EntityUtils.js" } from "../../../common/api/common/utils/EntityUtils.js"
import { import {
assertNotNull, assertNotNull,
compare,
count, count,
debounce, debounce,
groupByAndMap, groupByAndMap,
@ -48,6 +51,13 @@ export interface MailOpenedListener {
onEmailOpened(mail: Mail): unknown onEmailOpened(mail: Mail): unknown
} }
/** sort mail set mails in descending order according to their receivedDate, not their element id */
function sortCompareMailSetMails(firstMail: Mail, secondMail: Mail): number {
const firstMailEntryId = constructMailSetEntryId(firstMail.receivedDate, getElementId(firstMail))
const secondMailEntryId = constructMailSetEntryId(secondMail.receivedDate, getElementId(secondMail))
return compare(customIdToUint8array(secondMailEntryId), customIdToUint8array(firstMailEntryId))
}
/** ViewModel for the overall mail view. */ /** ViewModel for the overall mail view. */
export class MailViewModel { export class MailViewModel {
private _folder: MailFolder | null = null private _folder: MailFolder | null = null
@ -215,8 +225,11 @@ export class MailViewModel {
} }
private listModelForFolder = memoized((folderId: Id) => { private listModelForFolder = memoized((folderId: Id) => {
const customGetLoadIdForElement: ListModelConfig<Mail>["getLoadIdForElement"] = (mail) =>
mail == null ? CUSTOM_MAX_ID : constructMailSetEntryId(mail.receivedDate, getElementId(mail))
return new ListModel<Mail>({ return new ListModel<Mail>({
topId: GENERATED_MAX_ID, getLoadIdForElement: this._folder?.isMailSet ? customGetLoadIdForElement : undefined,
fetch: async (startId, count) => { fetch: async (startId, count) => {
const folder = assertNotNull(this._folder) const folder = assertNotNull(this._folder)
const { complete, items } = await this.loadMailRange(folder, startId, count) const { complete, items } = await this.loadMailRange(folder, startId, count)
@ -225,10 +238,12 @@ export class MailViewModel {
} }
return { complete, items } return { complete, items }
}, },
loadSingle: (listId: Id, elementId: Id): Promise<Mail | null> => { loadSingle: async (listId: Id, elementId: Id): Promise<Mail | null> => {
// await this.entityClient.load(MailSetEntryTypeRef, )
return this.entityClient.load(MailTypeRef, [listId, elementId]) return this.entityClient.load(MailTypeRef, [listId, elementId])
}, },
sortCompare: sortCompareByReverseId, sortCompare: (firstMail, secondMail): number =>
assertNotNull(this._folder).isMailSet ? sortCompareMailSetMails(firstMail, secondMail) : sortCompareByReverseId(firstMail, secondMail),
autoSelectBehavior: () => this.conversationPrefProvider.getMailAutoSelectBehavior(), autoSelectBehavior: () => this.conversationPrefProvider.getMailAutoSelectBehavior(),
}) })
}) })
@ -354,9 +369,10 @@ export class MailViewModel {
} }
} }
private async loadMailSetMailRange(folder: MailFolder, start: string, count: number) { private async loadMailSetMailRange(folder: MailFolder, startId: string, count: number): Promise<ListFetchResult<Mail>> {
console.log("range request for ", folder.entries, startId, count)
try { try {
const loadMailSetEntries = () => this.entityClient.loadRange(MailSetEntryTypeRef, folder.entries, start, count, true) const loadMailSetEntries = () => this.entityClient.loadRange(MailSetEntryTypeRef, folder.entries, startId, count, true)
const loadMails = (listId: Id, mailIds: Array<Id>) => this.entityClient.loadMultiple(MailTypeRef, listId, mailIds) const loadMails = (listId: Id, mailIds: Array<Id>) => this.entityClient.loadMultiple(MailTypeRef, listId, mailIds)
const mails = await this.acquireMails(loadMailSetEntries, loadMails) const mails = await this.acquireMails(loadMailSetEntries, loadMails)
@ -382,7 +398,7 @@ export class MailViewModel {
// give it to the list and let list make another request (and almost certainly fail that request) to show a retry button. This way we both show // give it to the list and let list make another request (and almost certainly fail that request) to show a retry button. This way we both show
// the items we have and also show that we couldn't load everything. // the items we have and also show that we couldn't load everything.
if (isOfflineError(e)) { if (isOfflineError(e)) {
const loadMailSetEntries = () => this.cacheStorage.provideFromRange(MailSetEntryTypeRef, folder.entries, start, count, true) const loadMailSetEntries = () => this.cacheStorage.provideFromRange(MailSetEntryTypeRef, folder.entries, startId, count, true)
const loadMails = (listId: Id, mailIds: Array<Id>) => this.cacheStorage.provideMultiple(MailTypeRef, listId, mailIds) const loadMails = (listId: Id, mailIds: Array<Id>) => this.cacheStorage.provideMultiple(MailTypeRef, listId, mailIds)
const items = await this.acquireMails(loadMailSetEntries, loadMails) const items = await this.acquireMails(loadMailSetEntries, loadMails)
if (items.length === 0) throw e if (items.length === 0) throw e
@ -396,8 +412,12 @@ export class MailViewModel {
/** /**
* Load mails either from remote or from offline storage. Loader functions must be implemented for each use case. * Load mails either from remote or from offline storage. Loader functions must be implemented for each use case.
*/ */
private async acquireMails(loadMailSetEntries: () => Promise<MailSetEntry[]>, loadMails: (listId: Id, mailIds: Array<Id>) => Promise<Mail[]>) { private async acquireMails(
loadMailSetEntries: () => Promise<MailSetEntry[]>,
loadMails: (listId: Id, mailIds: Array<Id>) => Promise<Mail[]>,
): Promise<Array<Mail>> {
const mailSetEntries = await loadMailSetEntries() const mailSetEntries = await loadMailSetEntries()
console.log("entries", mailSetEntries)
const mailListIdToMailIds = groupByAndMap( const mailListIdToMailIds = groupByAndMap(
mailSetEntries, mailSetEntries,
(mse) => listIdPart(mse.mail), (mse) => listIdPart(mse.mail),
@ -407,11 +427,11 @@ export class MailViewModel {
for (const [listId, mailIds] of mailListIdToMailIds) { for (const [listId, mailIds] of mailListIdToMailIds) {
mails.push(...(await loadMails(listId, mailIds))) mails.push(...(await loadMails(listId, mailIds)))
} }
mails.sort((a, b) => b.receivedDate.getTime() - a.receivedDate.getTime()) console.log("mails", mails)
return mails return mails
} }
private async loadLegacyMailRange(folder: MailFolder, start: string, count: number) { private async loadLegacyMailRange(folder: MailFolder, start: string, count: number): Promise<ListFetchResult<Mail>> {
const listId = folder.mails const listId = folder.mails
try { try {
const items = await this.entityClient.loadRange(MailTypeRef, listId, start, count, true) const items = await this.entityClient.loadRange(MailTypeRef, listId, start, count, true)

View file

@ -685,7 +685,6 @@ export class SearchViewModel {
// note in case of refactor: the fact that the list updates the URL every time it changes // note in case of refactor: the fact that the list updates the URL every time it changes
// its state is a major source of complexity and makes everything very order-dependent // its state is a major source of complexity and makes everything very order-dependent
return new ListModel<SearchResultListEntry>({ return new ListModel<SearchResultListEntry>({
topId: GENERATED_MAX_ID,
fetch: async (startId: Id, count: number) => { fetch: async (startId: Id, count: number) => {
const lastResult = this._searchResult const lastResult = this._searchResult
if (lastResult !== this._searchResult) { if (lastResult !== this._searchResult) {

View file

@ -82,7 +82,6 @@ export class KnowledgeBaseListView implements UpdatableSettingsViewer {
private makeListModel() { private makeListModel() {
const listModel = new ListModel<KnowledgeBaseEntry>({ const listModel = new ListModel<KnowledgeBaseEntry>({
topId: GENERATED_MAX_ID,
sortCompare: (a: KnowledgeBaseEntry, b: KnowledgeBaseEntry) => { sortCompare: (a: KnowledgeBaseEntry, b: KnowledgeBaseEntry) => {
var titleA = a.title.toUpperCase() var titleA = a.title.toUpperCase()
var titleB = b.title.toUpperCase() var titleB = b.title.toUpperCase()

View file

@ -81,7 +81,6 @@ export class TemplateListView implements UpdatableSettingsViewer {
private makeListModel() { private makeListModel() {
const listModel = new ListModel<EmailTemplate>({ const listModel = new ListModel<EmailTemplate>({
topId: GENERATED_MAX_ID,
sortCompare: (a: EmailTemplate, b: EmailTemplate) => { sortCompare: (a: EmailTemplate, b: EmailTemplate) => {
const titleA = a.title.toUpperCase() const titleA = a.title.toUpperCase()
const titleB = b.title.toUpperCase() const titleB = b.title.toUpperCase()

View file

@ -159,7 +159,6 @@ export class GroupListView implements UpdatableSettingsViewer {
private makeListModel(): ListModel<GroupInfo> { private makeListModel(): ListModel<GroupInfo> {
const listModel = new ListModel<GroupInfo>({ const listModel = new ListModel<GroupInfo>({
topId: GENERATED_MAX_ID,
sortCompare: compareGroupInfos, sortCompare: compareGroupInfos,
fetch: async (startId) => { fetch: async (startId) => {
if (startId === GENERATED_MAX_ID) { if (startId === GENERATED_MAX_ID) {

View file

@ -1,5 +1,6 @@
import o from "@tutao/otest" import o from "@tutao/otest"
import { import {
constructMailSetEntryId,
create, create,
GENERATED_MIN_ID, GENERATED_MIN_ID,
generatedIdToTimestamp, generatedIdToTimestamp,
@ -31,6 +32,16 @@ o.spec("EntityUtils", function () {
o(generatedIdToTimestamp("IwQvgF------")).equals(1370563200000) o(generatedIdToTimestamp("IwQvgF------")).equals(1370563200000)
}) })
o("test constructcustomId for mailSetEntry", function () {
const mailId: Id = "-----------0"
const expected = "V7iDsQAAAAAAAAAAAQ"
const receiveDate = new Date("2017-10-03 13:46:13")
const calculatedId = constructMailSetEntryId(receiveDate, mailId)
o(expected).equals(calculatedId)
})
o("create new entity without error object ", function () { o("create new entity without error object ", function () {
const mailEntity = create(typeModels.Mail, MailTypeRef) const mailEntity = create(typeModels.Mail, MailTypeRef)
o(mailEntity._errors).equals(undefined) o(mailEntity._errors).equals(undefined)

View file

@ -1,13 +1,14 @@
import o from "@tutao/otest" import o from "@tutao/otest"
import { OfflineMigration, OfflineStorageMigrator } from "../../../../../src/common/api/worker/offline/OfflineStorageMigrator.js" import { OfflineMigration, OfflineStorageMigrator } from "../../../../../src/common/api/worker/offline/OfflineStorageMigrator.js"
import { OfflineStorage } from "../../../../../src/common/api/worker/offline/OfflineStorage.js" import { ensureBase64Ext, OfflineStorage } from "../../../../../src/common/api/worker/offline/OfflineStorage.js"
import { func, instance, matchers, object, when } from "testdouble" import { func, instance, matchers, object, when } from "testdouble"
import { assertThrows, verify } from "@tutao/tutanota-test-utils" import { assertThrows, verify } from "@tutao/tutanota-test-utils"
import { ModelInfos } from "../../../../../src/common/api/common/EntityFunctions.js" import { ModelInfos, resolveTypeReference } from "../../../../../src/common/api/common/EntityFunctions.js"
import { typedEntries } from "@tutao/tutanota-utils" import { repeat, typedEntries } from "@tutao/tutanota-utils"
import { ProgrammingError } from "../../../../../src/common/api/common/error/ProgrammingError.js" import { ProgrammingError } from "../../../../../src/common/api/common/error/ProgrammingError.js"
import { SqlCipherFacade } from "../../../../../src/common/native/common/generatedipc/SqlCipherFacade.js" import { SqlCipherFacade } from "../../../../../src/common/native/common/generatedipc/SqlCipherFacade.js"
import { OutOfSyncError } from "../../../../../src/common/api/common/error/OutOfSyncError.js" import { OutOfSyncError } from "../../../../../src/common/api/common/error/OutOfSyncError.js"
import { CalendarEventTypeRef, MailSetEntryTypeRef } from "../../../../../src/common/api/entities/tutanota/TypeRefs.js"
o.spec("OfflineStorageMigrator", function () { o.spec("OfflineStorageMigrator", function () {
const modelInfos: ModelInfos = { const modelInfos: ModelInfos = {
@ -114,4 +115,17 @@ o.spec("OfflineStorageMigrator", function () {
verify(migration.migrate(storage, sqlCipherFacade), { times: 0 }) verify(migration.migrate(storage, sqlCipherFacade), { times: 0 })
verify(storage.setStoredModelVersion("tutanota", matchers.anything()), { times: 0 }) verify(storage.setStoredModelVersion("tutanota", matchers.anything()), { times: 0 })
}) })
o("sanity check for customids", async function () {
const mailSetEntryCustomId = "ZFvk7GRb5OwzgABABQ"
const typeModel = await resolveTypeReference(MailSetEntryTypeRef)
const convertedId = ensureBase64Ext(typeModel, mailSetEntryCustomId)
console.log(convertedId)
})
o("sanity check for max custom id", async function () {
const typeModel = await resolveTypeReference(CalendarEventTypeRef)
const convertedId = ensureBase64Ext(typeModel, repeat("z", 340))
console.log(convertedId)
})
}) })

View file

@ -1,6 +1,6 @@
import o from "@tutao/otest" import o from "@tutao/otest"
import { ListModel, ListModelConfig } from "../../../src/common/misc/ListModel.js" import { ListModel, ListModelConfig } from "../../../src/common/misc/ListModel.js"
import { GENERATED_MAX_ID, getElementId, getListId, sortCompareById, timestampToGeneratedId } from "../../../src/common/api/common/utils/EntityUtils.js" import { getElementId, getListId, sortCompareById, timestampToGeneratedId } from "../../../src/common/api/common/utils/EntityUtils.js"
import { defer, DeferredObject } from "@tutao/tutanota-utils" import { defer, DeferredObject } from "@tutao/tutanota-utils"
import { KnowledgeBaseEntry, KnowledgeBaseEntryTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs.js" import { KnowledgeBaseEntry, KnowledgeBaseEntryTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs.js"
import { ListFetchResult } from "../../../src/common/gui/base/ListUtils.js" import { ListFetchResult } from "../../../src/common/gui/base/ListUtils.js"
@ -15,7 +15,6 @@ o.spec("ListModel", function () {
let fetchDefer: DeferredObject<ListFetchResult<KnowledgeBaseEntry>> let fetchDefer: DeferredObject<ListFetchResult<KnowledgeBaseEntry>>
let listModel: ListModel<KnowledgeBaseEntry> let listModel: ListModel<KnowledgeBaseEntry>
const defaultListConfig = { const defaultListConfig = {
topId: GENERATED_MAX_ID,
fetch: () => fetchDefer.promise, fetch: () => fetchDefer.promise,
sortCompare: sortCompareById, sortCompare: sortCompareById,
loadSingle: () => { loadSingle: () => {