Remove backbone as a dependency

Co-authored-by: Yash <yash@signal.org>
Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
Co-authored-by: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
Scott Nonnenberg 2025-08-12 06:55:29 +10:00
commit 237e239e05
69 changed files with 963 additions and 2110 deletions

View file

@ -4,15 +4,6 @@
import { debounce, pick, uniq, without } from 'lodash';
import PQueue from 'p-queue';
import { v4 as generateUuid } from 'uuid';
import { batch as batchDispatch } from 'react-redux';
import type {
ConversationModelCollectionType,
ConversationAttributesType,
ConversationAttributesTypeType,
ConversationRenderInfoType,
} from './model-types.d';
import type { ConversationModel } from './models/conversations';
import { DataReader, DataWriter } from './sql/Client';
import { createLogger } from './logging/log';
@ -21,8 +12,12 @@ import { getAuthorId } from './messages/helpers';
import { maybeDeriveGroupV2Id } from './groups';
import { assertDev, strictAssert } from './util/assert';
import { drop } from './util/drop';
import { isGroup, isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import type { ServiceIdString, AciString, PniString } from './types/ServiceId';
import {
isDirectConversation,
isGroup,
isGroupV1,
isGroupV2,
} from './util/whatTypeOfConversation';
import {
isServiceIdString,
normalizePni,
@ -42,6 +37,18 @@ import { isTestOrMockEnvironment } from './environment';
import { isConversationAccepted } from './util/isConversationAccepted';
import { areWePending } from './util/groupMembershipUtils';
import { conversationJobQueue } from './jobs/conversationJobQueue';
import { createBatcher } from './util/batcher';
import { validateConversation } from './util/validateConversation';
import { ConversationModel } from './models/conversations';
import { INITIAL_EXPIRE_TIMER_VERSION } from './util/expirationTimer';
import { missingCaseError } from './util/missingCaseError';
import type {
ConversationAttributesType,
ConversationAttributesTypeType,
ConversationRenderInfoType,
} from './model-types.d';
import type { ServiceIdString, AciString, PniString } from './types/ServiceId';
const log = createLogger('ConversationController');
@ -129,11 +136,7 @@ async function safeCombineConversations(
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
const {
getAllConversations,
getAllGroupsInvolvingServiceId,
getMessagesBySentAt,
} = DataReader;
const { getAllConversations, getMessagesBySentAt } = DataReader;
const {
migrateConversationMessages,
@ -143,57 +146,197 @@ const {
updateConversations,
} = DataWriter;
// We have to run this in background.js, after all backbone models and collections on
// Whisper.* have been created. Once those are in typescript we can use more reasonable
// require statements for referencing these things, giving us more flexibility here.
export function start(): void {
const conversations = new window.Whisper.ConversationCollection();
window.ConversationController = new ConversationController(conversations);
window.getConversations = () => conversations;
}
export class ConversationController {
#_initialFetchComplete = false;
#isReadOnly = false;
private _initialPromise: undefined | Promise<void>;
#_initialPromise: undefined | Promise<void>;
#_conversations: Array<ConversationModel> = [];
#_conversationOpenStart = new Map<string, number>();
#_hasQueueEmptied = false;
#_combineConversationsQueue = new PQueue({ concurrency: 1 });
#_signalConversationId: undefined | string;
constructor(private _conversations: ConversationModelCollectionType) {
const debouncedUpdateUnreadCount = debounce(
this.updateUnreadCount.bind(this),
SECOND,
{
leading: true,
maxWait: SECOND,
trailing: true,
}
);
#delayBeforeUpdatingRedux: (() => number) | undefined;
#isAppStillLoading: (() => boolean) | undefined;
// lookups
#_byE164: Record<string, ConversationModel> = Object.create(null);
#_byServiceId: Record<string, ConversationModel> = Object.create(null);
#_byPni: Record<string, ConversationModel> = Object.create(null);
#_byGroupId: Record<string, ConversationModel> = Object.create(null);
#_byId: Record<string, ConversationModel> = Object.create(null);
#debouncedUpdateUnreadCount = debounce(
this.updateUnreadCount.bind(this),
SECOND,
{
leading: true,
maxWait: SECOND,
trailing: true,
}
);
#convoUpdateBatcher = createBatcher<
| { type: 'change' | 'add'; conversation: ConversationModel }
| { type: 'remove'; id: string }
>({
name: 'changedConvoBatcher',
processBatch: batch => {
let changedOrAddedBatch = new Array<ConversationModel>();
const {
conversationsUpdated,
conversationRemoved,
onConversationClosed,
} = window.reduxActions.conversations;
function flushChangedOrAddedBatch() {
if (!changedOrAddedBatch.length) {
return;
}
conversationsUpdated(
changedOrAddedBatch.map(conversation => conversation.format())
);
changedOrAddedBatch = [];
}
for (const item of batch) {
if (item.type === 'add' || item.type === 'change') {
changedOrAddedBatch.push(item.conversation);
} else {
strictAssert(item.type === 'remove', 'must be remove');
flushChangedOrAddedBatch();
onConversationClosed(item.id, 'removed');
conversationRemoved(item.id);
}
}
flushChangedOrAddedBatch();
},
wait: () => {
return this.#delayBeforeUpdatingRedux?.() ?? 1;
},
maxSize: Infinity,
});
constructor() {
// A few things can cause us to update the app-level unread count
window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount);
this._conversations.on(
'add remove change:active_at change:unreadCount change:markedUnread change:isArchived change:muteExpiresAt',
debouncedUpdateUnreadCount
window.Whisper.events.on(
'updateUnreadCount',
this.#debouncedUpdateUnreadCount
);
}
// If the conversation is muted we set a timeout so when the mute expires
// we can reset the mute state on the model. If the mute has already expired
// then we reset the state right away.
this._conversations.on('add', (model: ConversationModel): void => {
// Don't modify conversations in backup integration testing
if (isTestOrMockEnvironment()) {
return;
registerDelayBeforeUpdatingRedux(
delayBeforeUpdatingRedux: () => number
): void {
this.#delayBeforeUpdatingRedux = delayBeforeUpdatingRedux;
}
registerIsAppStillLoading(isAppStillLoading: () => boolean): void {
this.#isAppStillLoading = isAppStillLoading;
}
conversationUpdated(
conversation: ConversationModel,
previousAttributes: ConversationAttributesType
): void {
// eslint-disable-next-line no-param-reassign
conversation.cachedProps = undefined;
const hasAttributeChanged = (name: keyof ConversationAttributesType) => {
return (
name in conversation.attributes &&
conversation.attributes[name] !== previousAttributes[name]
);
};
this.#convoUpdateBatcher.add({ type: 'change', conversation });
if (isDirectConversation(conversation.attributes)) {
const updateLastMessage =
hasAttributeChanged('name') ||
hasAttributeChanged('profileName') ||
hasAttributeChanged('profileFamilyName') ||
hasAttributeChanged('e164');
const memberVerifiedChange = hasAttributeChanged('verified');
if (updateLastMessage || memberVerifiedChange) {
this.#updateAllGroupsWithMember(conversation, {
updateLastMessage,
memberVerifiedChange,
});
}
}
}
#updateAllGroupsWithMember(
member: ConversationModel,
{
updateLastMessage,
memberVerifiedChange,
}: { updateLastMessage: boolean; memberVerifiedChange: boolean }
): void {
const memberServiceId = member.getServiceId();
if (!memberServiceId) {
return;
}
if (!updateLastMessage && !memberVerifiedChange) {
log.error(
`updateAllGroupsWithMember: Called for ${member.idForLogging()} but neither option set`
);
}
const groups = this.getAllGroupsInvolvingServiceId(memberServiceId);
groups.forEach(conversation => {
if (updateLastMessage) {
conversation.debouncedUpdateLastMessage();
}
if (memberVerifiedChange) {
conversation.onMemberVerifiedChange();
}
model.startMuteTimer();
});
}
#addConversation(conversation: ConversationModel): void {
this.#_conversations.push(conversation);
this.#addToLookup(conversation);
this.#debouncedUpdateUnreadCount();
// Don't modify conversations in backup integration testing
if (!isTestOrMockEnvironment()) {
// If the conversation is muted we set a timeout so when the mute expires
// we can reset the mute state on the model. If the mute has already expired
// then we reset the state right away.
conversation.startMuteTimer();
}
if (this.#isAppStillLoading?.()) {
// The redux update will happen inside the batcher
this.#convoUpdateBatcher.add({ type: 'add', conversation });
} else {
const { conversationsUpdated } = window.reduxActions.conversations;
// During normal app usage, we require conversations to be added synchronously
conversationsUpdated([conversation.format()]);
}
}
#removeConversation(conversation: ConversationModel): void {
this.#_conversations = without(this.#_conversations, conversation);
this.#removeFromLookup(conversation);
this.#debouncedUpdateUnreadCount();
const { id } = conversation || {};
// The redux update call will happen inside the batcher
this.#convoUpdateBatcher.add({ type: 'remove', id });
}
updateUnreadCount(): void {
if (!this.#_hasQueueEmptied) {
return;
@ -203,7 +346,7 @@ export class ConversationController {
window.storage.get('badge-count-muted-conversations') || false;
const unreadStats = countAllConversationsUnreadStats(
this._conversations.map(
this.#_conversations.map(
(conversation): ConversationPropsForUnreadStats => {
// Need to pull this out manually into the Redux shape
// because `conversation.format()` can return cached props by the
@ -251,24 +394,39 @@ export class ConversationController {
'ConversationController.get() needs complete initial fetch'
);
}
if (!id) {
return undefined;
}
// This function takes null just fine. Backbone typings are too restrictive.
return this._conversations.get(id as string);
return (
this.#_byE164[id] ||
this.#_byE164[`+${id}`] ||
this.#_byServiceId[id] ||
this.#_byPni[id] ||
this.#_byGroupId[id] ||
this.#_byId[id]
);
}
getAll(): Array<ConversationModel> {
return this._conversations.models;
return this.#_conversations;
}
dangerouslyCreateAndAdd(
attributes: Partial<ConversationAttributesType>
attributes: ConversationAttributesType
): ConversationModel {
return this._conversations.add(attributes);
const model = new ConversationModel(attributes);
this.#addConversation(model);
return model;
}
dangerouslyRemoveById(id: string): void {
this._conversations.remove(id);
this._conversations.resetLookups();
const model = this.get(id);
if (!model) {
return;
}
this.#removeConversation(model);
}
getOrCreate(
@ -292,7 +450,7 @@ export class ConversationController {
);
}
let conversation = this._conversations.get(identifier);
let conversation = this.get(identifier);
if (conversation) {
return conversation;
}
@ -304,44 +462,64 @@ export class ConversationController {
const id = generateUuid();
if (type === 'group') {
conversation = this._conversations.add({
conversation = new ConversationModel({
id,
serviceId: undefined,
e164: undefined,
groupId: identifier,
type,
version: 2,
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
unreadCount: 0,
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
...additionalInitialProps,
});
this.#addConversation(conversation);
} else if (isServiceIdString(identifier)) {
conversation = this._conversations.add({
conversation = new ConversationModel({
id,
serviceId: identifier,
e164: undefined,
groupId: undefined,
type,
version: 2,
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
unreadCount: 0,
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
...additionalInitialProps,
});
this.#addConversation(conversation);
} else {
conversation = this._conversations.add({
conversation = new ConversationModel({
id,
serviceId: undefined,
e164: identifier,
groupId: undefined,
type,
version: 2,
expireTimerVersion: INITIAL_EXPIRE_TIMER_VERSION,
unreadCount: 0,
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0,
sentMessageCount: 0,
...additionalInitialProps,
});
this.#addConversation(conversation);
}
const create = async () => {
if (!conversation.isValid()) {
const validationError = conversation.validationError || {};
const validationErrorString = validateConversation(
conversation.attributes
);
if (validationErrorString) {
log.error(
'Contact is not valid. Not saving, but adding to collection:',
conversation.idForLogging(),
Errors.toLogFormat(validationError)
validationErrorString
);
return conversation;
@ -755,7 +933,7 @@ export class ConversationController {
(targetOldServiceIds.pni !== pni ||
(aci && targetOldServiceIds.aci !== aci))
) {
targetConversation.unset('needsTitleTransition');
targetConversation.set({ needsTitleTransition: undefined });
mergePromises.push(
targetConversation.addPhoneNumberDiscoveryIfNeeded(
targetOldServiceIds.pni
@ -873,12 +1051,10 @@ export class ConversationController {
// We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map
// here. Instead, we check for duplicates on the derived GV2 ID.
const { models } = this._conversations;
// We iterate from the oldest conversations to the newest. This allows us, in a
// conflict case, to keep the one with activity the most recently.
for (let i = models.length - 1; i >= 0; i -= 1) {
const conversation = models[i];
for (let i = this.#_conversations.length - 1; i >= 0; i -= 1) {
const conversation = this.#_conversations[i];
assertDev(
conversation,
'Expected conversation to be found in array during iteration'
@ -1090,15 +1266,14 @@ export class ConversationController {
} else {
activeAt = obsoleteActiveAt || currentActiveAt;
}
current.set('active_at', activeAt);
current.set({ active_at: activeAt });
current.set(
'expireTimerVersion',
Math.max(
current.set({
expireTimerVersion: Math.max(
obsolete.get('expireTimerVersion') ?? 1,
current.get('expireTimerVersion') ?? 1
)
);
),
});
const obsoleteExpireTimer = obsolete.get('expireTimer');
const currentExpireTimer = current.get('expireTimer');
@ -1106,7 +1281,7 @@ export class ConversationController {
!currentExpireTimer ||
(obsoleteExpireTimer && obsoleteExpireTimer < currentExpireTimer)
) {
current.set('expireTimer', obsoleteExpireTimer);
current.set({ expireTimer: obsoleteExpireTimer });
}
const currentHadMessages = (current.get('messageCount') ?? 0) > 0;
@ -1136,11 +1311,11 @@ export class ConversationController {
>;
keys.forEach(key => {
if (current.get(key) === undefined) {
current.set(key, dataToCopy[key]);
current.set({ [key]: dataToCopy[key] });
// To ensure that any files on disk don't get deleted out from under us
if (key === 'draftAttachments') {
obsolete.set(key, undefined);
obsolete.set({ [key]: undefined });
}
}
});
@ -1244,8 +1419,7 @@ export class ConversationController {
log.warn(
`${logId}: Eliminate old conversation from ConversationController lookups`
);
this._conversations.remove(obsolete);
this._conversations.resetLookups();
this.#removeConversation(obsolete);
current.captureChange('combineConversations');
drop(current.updateLastMessage());
@ -1305,22 +1479,25 @@ export class ConversationController {
return null;
}
async getAllGroupsInvolvingServiceId(
getAllGroupsInvolvingServiceId(
serviceId: ServiceIdString
): Promise<Array<ConversationModel>> {
const groups = await getAllGroupsInvolvingServiceId(serviceId);
return groups.map(group => {
const existing = this.get(group.id);
if (existing) {
return existing;
}
): Array<ConversationModel> {
return this.#_conversations
.map(conversation => {
if (!isGroup(conversation.attributes)) {
return;
}
if (!conversation.hasMember(serviceId)) {
return;
}
return this._conversations.add(group);
});
return conversation;
})
.filter(isNotNil);
}
getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined {
return this._conversations.find(
return this.#_conversations.find(
item => item.get('derivedGroupV2Id') === groupId
);
}
@ -1336,14 +1513,18 @@ export class ConversationController {
}
reset(): void {
delete this._initialPromise;
const { removeAllConversations } = window.reduxActions.conversations;
this.#_initialPromise = undefined;
this.#_initialFetchComplete = false;
this._conversations.reset([]);
this.#_conversations = [];
removeAllConversations();
this.#resetLookups();
}
load(): Promise<void> {
this._initialPromise ||= this.#doLoad();
return this._initialPromise;
this.#_initialPromise ||= this.#doLoad();
return this.#_initialPromise;
}
// A number of things outside conversation.attributes affect conversation re-rendering.
@ -1354,7 +1535,7 @@ export class ConversationController {
let count = 0;
const conversations = identifiers
? identifiers.map(identifier => this.get(identifier)).filter(isNotNil)
: this._conversations.models.slice();
: this.#_conversations.slice();
log.info(
`forceRerender: Starting to loop through ${conversations.length} conversations`
);
@ -1366,7 +1547,7 @@ export class ConversationController {
conversation.oldCachedProps = conversation.cachedProps;
conversation.cachedProps = null;
conversation.trigger('props-change', conversation, false);
this.conversationUpdated(conversation, conversation.attributes);
count += 1;
}
@ -1426,8 +1607,10 @@ export class ConversationController {
);
}
conversation.set('avatar', undefined);
conversation.set('profileAvatar', undefined);
conversation.set({
avatar: undefined,
profileAvatar: undefined,
});
drop(updateConversation(conversation.attributes));
numberOfConversationsMigrated += 1;
}
@ -1449,7 +1632,7 @@ export class ConversationController {
}
log.warn(`Repairing ${convo.idForLogging()}'s isPinned`);
convo.set('isPinned', true);
convo.set({ isPinned: true });
drop(updateConversation(convo.attributes));
}
@ -1469,7 +1652,7 @@ export class ConversationController {
await updateConversations(
sharedWith.map(c => {
c.unset('shareMyPhoneNumber');
c.set({ shareMyPhoneNumber: undefined });
return c.attributes;
})
);
@ -1496,15 +1679,14 @@ export class ConversationController {
// eslint-disable-next-line no-await-in-loop
await removeConversation(convo.id);
this._conversations.remove(convo);
this._conversations.resetLookups();
this.#removeConversation(convo);
}
}
async #doLoad(): Promise<void> {
log.info('starting initial fetch');
if (this._conversations.length) {
if (this.#_conversations.length) {
throw new Error('ConversationController: Already loaded!');
}
@ -1540,14 +1722,16 @@ export class ConversationController {
this.#_initialFetchComplete = true;
// Hydrate the final set of conversations
batchDispatch(() => {
this._conversations.add(
collection.filter(conversation => !conversation.isTemporary)
collection
.filter(conversation => !conversation.isTemporary)
.forEach(conversation =>
this.#_conversations.push(new ConversationModel(conversation))
);
});
this.#generateLookups();
await Promise.all(
this._conversations.map(async conversation => {
this.#_conversations.map(async conversation => {
try {
// Hydrate contactCollection, now that initial fetch is complete
conversation.fetchContacts();
@ -1587,13 +1771,14 @@ export class ConversationController {
);
log.info(
'done with initial fetch, ' +
`got ${this._conversations.length} conversations`
`got ${this.#_conversations.length} conversations`
);
} catch (error) {
log.error('initial fetch failed', Errors.toLogFormat(error));
throw error;
}
}
async archiveSessionsForConversation(
conversationId: string | undefined
): Promise<void> {
@ -1635,4 +1820,203 @@ export class ConversationController {
log.info(`${logId}: Complete!`);
}
idUpdated(
model: ConversationModel,
idProp: 'e164' | 'serviceId' | 'pni' | 'groupId',
oldValue: string | undefined
): void {
const logId = `idUpdated/${model.idForLogging()}/${idProp}`;
if (oldValue) {
if (idProp === 'e164') {
delete this.#_byE164[oldValue];
} else if (idProp === 'serviceId') {
delete this.#_byServiceId[oldValue];
} else if (idProp === 'pni') {
delete this.#_byPni[oldValue];
} else if (idProp === 'groupId') {
delete this.#_byGroupId[oldValue];
} else {
throw missingCaseError(idProp);
}
}
if (idProp === 'e164') {
const e164 = model.get('e164');
if (e164) {
const existing = this.#_byE164[e164];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byE164[e164] = model;
}
} else if (idProp === 'serviceId') {
const serviceId = model.getServiceId();
if (serviceId) {
const existing = this.#_byServiceId[serviceId];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byServiceId[serviceId] = model;
}
} else if (idProp === 'pni') {
const pni = model.get('pni');
if (pni) {
const existing = this.#_byPni[pni];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byPni[pni] = model;
}
} else if (idProp === 'groupId') {
const groupId = model.get('groupId');
if (groupId) {
const existing = this.#_byGroupId[groupId];
if (existing) {
log.warn(`${logId}: Existing match found on lookup`);
}
this.#_byGroupId[groupId] = model;
}
} else {
throw missingCaseError(idProp);
}
}
#resetLookups(): void {
this.#eraseLookups();
this.#generateLookups();
}
#addToLookup(conversation: ConversationModel): void {
const logId = `addToLookup/${conversation.idForLogging()}`;
const id = conversation.get('id');
if (id) {
const existing = this.#_byId[id];
if (existing) {
log.warn(`${logId}: Conflict found by id`);
}
if (!existing || (existing && !existing.getServiceId())) {
this.#_byId[id] = conversation;
}
}
const e164 = conversation.get('e164');
if (e164) {
const existing = this.#_byE164[e164];
if (existing) {
log.warn(`${logId}: Conflict found by e164`);
}
if (!existing || (existing && !existing.getServiceId())) {
this.#_byE164[e164] = conversation;
}
}
const serviceId = conversation.getServiceId();
if (serviceId) {
const existing = this.#_byServiceId[serviceId];
if (existing) {
log.warn(`${logId}: Conflict found by serviceId`);
}
if (!existing || (existing && !existing.get('e164'))) {
this.#_byServiceId[serviceId] = conversation;
}
}
const pni = conversation.getPni();
if (pni) {
const existing = this.#_byPni[pni];
if (existing) {
log.warn(`${logId}: Conflict found by pni`);
}
if (!existing || (existing && !existing.getServiceId())) {
this.#_byPni[pni] = conversation;
}
}
const groupId = conversation.get('groupId');
if (groupId) {
const existing = this.#_byGroupId[groupId];
if (existing) {
log.warn(`${logId}: Conflict found by groupId`);
}
this.#_byGroupId[groupId] = conversation;
}
}
#removeFromLookup(conversation: ConversationModel): void {
const logId = `removeFromLookup/${conversation.idForLogging()}`;
const id = conversation.get('id');
if (id) {
const existing = this.#_byId[id];
if (existing && existing !== conversation) {
log.warn(`${logId}: By id; model in lookup didn't match conversation`);
} else {
delete this.#_byId[id];
}
}
const e164 = conversation.get('e164');
if (e164) {
const existing = this.#_byE164[e164];
if (existing && existing !== conversation) {
log.warn(
`${logId}: By e164; model in lookup didn't match conversation`
);
} else {
delete this.#_byE164[e164];
}
}
const serviceId = conversation.getServiceId();
if (serviceId) {
const existing = this.#_byServiceId[serviceId];
if (existing && existing !== conversation) {
log.warn(
`${logId}: By serviceId; model in lookup didn't match conversation`
);
} else {
delete this.#_byServiceId[serviceId];
}
}
const pni = conversation.getPni();
if (pni) {
const existing = this.#_byPni[pni];
if (existing && existing !== conversation) {
log.warn(`${logId}: By pni; model in lookup didn't match conversation`);
} else {
delete this.#_byPni[pni];
}
}
const groupId = conversation.get('groupId');
if (groupId) {
const existing = this.#_byGroupId[groupId];
if (existing && existing !== conversation) {
log.warn(
`${logId}: By groupId; model in lookup didn't match conversation`
);
} else {
delete this.#_byGroupId[groupId];
}
}
}
#generateLookups(): void {
this.#_conversations.forEach(conversation =>
this.#addToLookup(conversation)
);
}
#eraseLookups(): void {
this.#_byE164 = Object.create(null);
this.#_byServiceId = Object.create(null);
this.#_byPni = Object.create(null);
this.#_byGroupId = Object.create(null);
this.#_byId = Object.create(null);
}
}