Types, better-sqlite3, and worker_threads for our sqlite

This commit is contained in:
Fedor Indutny 2021-04-05 15:18:19 -07:00 committed by Josh Perez
parent fc3004a183
commit 37c8c1727f
24 changed files with 2823 additions and 3121 deletions

View file

@ -6,6 +6,13 @@
import { fromEncodedBinaryToArrayBuffer, constantTimeEqual } from './Crypto';
import { isNotNil } from './util/isNotNil';
import { isMoreRecentThan } from './util/timestamp';
import {
IdentityKeyType,
SignedPreKeyType,
PreKeyType,
UnprocessedType,
SessionType,
} from './sql/Interface';
const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds
const Direction = {
@ -126,30 +133,6 @@ type KeyPairType = {
pubKey: ArrayBuffer;
};
type IdentityKeyType = {
firstUse: boolean;
id: string;
nonblockingApproval: boolean;
publicKey: ArrayBuffer;
timestamp: number;
verified: number;
};
type SessionType = {
conversationId: string;
deviceId: number;
id: string;
record: string;
};
type SignedPreKeyType = {
confirmed: boolean;
// eslint-disable-next-line camelcase
created_at: number;
id: number;
privateKey: ArrayBuffer;
publicKey: ArrayBuffer;
};
type OuterSignedPrekeyType = {
confirmed: boolean;
// eslint-disable-next-line camelcase
@ -158,23 +141,6 @@ type OuterSignedPrekeyType = {
privKey: ArrayBuffer;
pubKey: ArrayBuffer;
};
type PreKeyType = {
id: number;
privateKey: ArrayBuffer;
publicKey: ArrayBuffer;
};
type UnprocessedType = {
id: string;
timestamp: number;
version: number;
attempts: number;
envelope: string;
decrypted?: string;
source?: string;
sourceDevice: string;
serverTimestamp: number;
};
// We add a this parameter to avoid an 'implicit any' error on the next line
const EventsMixin = (function EventsMixin(this: unknown) {
@ -1175,7 +1141,7 @@ export class SignalProtocolStore extends EventsMixin {
return window.Signal.Data.getUnprocessedById(id);
}
addUnprocessed(data: UnprocessedType): Promise<number> {
addUnprocessed(data: UnprocessedType): Promise<string> {
// We need to pass forceSave because the data has an id already, which will cause
// an update instead of an insert.
return window.Signal.Data.saveUnprocessed(data, {
@ -1199,7 +1165,9 @@ export class SignalProtocolStore extends EventsMixin {
return window.Signal.Data.updateUnprocessedWithData(id, data);
}
updateUnprocessedsWithData(items: Array<UnprocessedType>): Promise<void> {
updateUnprocessedsWithData(
items: Array<{ id: string; data: UnprocessedType }>
): Promise<void> {
return window.Signal.Data.updateUnprocessedsWithData(items);
}

View file

@ -3071,6 +3071,10 @@ export async function startApp(): Promise<void> {
reconnectTimer = setTimeout(connect, 60000);
window.Whisper.events.trigger('reconnectTimer');
// If we couldn't connect during startup - we should still switch SQL to
// the main process to avoid stalling UI.
window.sqlInitializer.goBackToMainProcess();
}
return;
}

View file

@ -2319,7 +2319,7 @@ export async function joinGroupV2ViaLinkAndMigrate({
derivedGroupV2Id: undefined,
members: undefined,
};
const groupChangeMessages = [
const groupChangeMessages: Array<MessageAttributesType> = [
{
...generateBasicMessage(),
type: 'group-v1-migration',
@ -3018,7 +3018,7 @@ async function generateLeftGroupChanges(
const isNewlyRemoved =
existingMembers.length > (newAttributes.membersV2 || []).length;
const youWereRemovedMessage = {
const youWereRemovedMessage: MessageAttributesType = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {

74
ts/model-types.d.ts vendored
View file

@ -63,7 +63,7 @@ export type MessageAttributesType = {
deletedForEveryoneTimestamp?: number;
delivered: number;
delivered_to: Array<string | null>;
errors: Array<CustomError> | null;
errors?: Array<CustomError>;
expirationStartTimestamp: number | null;
expireTimer: number;
expires_at: number;
@ -86,7 +86,7 @@ export type MessageAttributesType = {
message: unknown;
messageTimer: unknown;
profileChange: ProfileNameChangeType;
quote: {
quote?: {
attachments: Array<typeof window.WhatIsThis>;
// `author` is an old attribute that holds the author's E164. We shouldn't use it for
// new messages, but old messages might have this attribute.
@ -96,8 +96,21 @@ export type MessageAttributesType = {
id: string;
referencedMessageNotFound: boolean;
text: string;
} | null;
reactions: Array<{ fromId: string; emoji: string; timestamp: number }>;
};
reactions?: Array<{
emoji: string;
timestamp: number;
fromId: string;
from: {
id: string;
color?: string;
avatarPath?: string;
name?: string;
profileName?: string;
isMe?: boolean;
phoneNumber?: string;
};
}>;
read_by: Array<string | null>;
requiredProtocolVersion: number;
sent: boolean;
@ -110,7 +123,19 @@ export type MessageAttributesType = {
verifiedChanged: string;
id: string;
type?: string;
type?:
| 'incoming'
| 'outgoing'
| 'group'
| 'keychange'
| 'verified-change'
| 'message-history-unsynced'
| 'call-history'
| 'chat-session-refreshed'
| 'group-v1-migration'
| 'group-v2-change'
| 'profile-change'
| 'timer-notification';
body: string;
attachments: Array<WhatIsThis>;
preview: Array<WhatIsThis>;
@ -135,7 +160,7 @@ export type MessageAttributesType = {
flags?: number;
groupV2Change?: GroupV2ChangeType;
// Required. Used to sort messages in the database for the conversation timeline.
received_at?: number;
received_at: number;
received_at_ms?: number;
// More of a legacy feature, needed as we were updating the schema of messages in the
// background, when we were still in IndexedDB, before attachments had gone to disk
@ -145,7 +170,7 @@ export type MessageAttributesType = {
source?: string;
sourceUuid?: string;
unread: number;
unread: boolean;
timestamp: number;
// Backwards-compatibility with prerelease data schema
@ -156,34 +181,37 @@ export type MessageAttributesType = {
export type ConversationAttributesTypeType = 'private' | 'group';
export type ConversationAttributesType = {
accessKey: string | null;
accessKey?: string | null;
addedBy?: string;
capabilities?: CapabilitiesType;
color?: string;
discoveredUnregisteredAt?: number;
draftAttachments: Array<unknown>;
draftBodyRanges: Array<BodyRangeType>;
draftTimestamp: number | null;
draftAttachments?: Array<{
path?: string;
screenshotPath?: string;
}>;
draftBodyRanges?: Array<BodyRangeType>;
draftTimestamp?: number | null;
inbox_position: number;
isPinned: boolean;
lastMessageDeletedForEveryone: boolean;
lastMessageStatus: LastMessageStatus | null;
lastMessageStatus?: LastMessageStatus | null;
markedUnread: boolean;
messageCount: number;
messageCountBeforeMessageRequests: number | null;
messageRequestResponseType: number;
muteExpiresAt: number | undefined;
profileAvatar: WhatIsThis;
profileKeyCredential: string | null;
profileKeyVersion: string | null;
quotedMessageId: string | null;
sealedSender: unknown;
messageCountBeforeMessageRequests?: number | null;
messageRequestResponseType?: number;
muteExpiresAt?: number;
profileAvatar?: WhatIsThis;
profileKeyCredential?: string | null;
profileKeyVersion?: string | null;
quotedMessageId?: string | null;
sealedSender?: unknown;
sentMessageCount: number;
sharedGroupNames: Array<string>;
sharedGroupNames?: Array<string>;
id: string;
type: ConversationAttributesTypeType;
timestamp: number | null;
timestamp?: number | null;
// Shared fields
active_at?: number | null;
@ -217,7 +245,7 @@ export type ConversationAttributesType = {
// A shorthand, representing whether the user is part of the group. Not strictly for
// when the user manually left the group. But historically, that was the only way
// to leave a group.
left: boolean;
left?: boolean;
groupVersion?: number;
// GroupV1 only

View file

@ -1388,7 +1388,7 @@ export class ConversationModel extends window.Backbone.Model<
profileSharing: this.get('profileSharing'),
publicParams: this.get('publicParams'),
secretParams: this.get('secretParams'),
sharedGroupNames: this.get('sharedGroupNames')!,
sharedGroupNames: this.get('sharedGroupNames'),
shouldShowDraft,
sortedGroupMembers,
timestamp,
@ -2574,7 +2574,7 @@ export class ConversationModel extends window.Backbone.Model<
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
unread: true,
unread: 1,
changedId: conversationId || this.id,
profileChange,
// TODO: DESKTOP-722

View file

@ -1745,7 +1745,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
body: '',
bodyRanges: undefined,
attachments: [],
quote: null,
quote: undefined,
contact: [],
sticker: null,
preview: [],
@ -2034,7 +2034,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return null;
}
this.set({ errors: null });
this.set({ errors: undefined });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = this.getConversation()!;
@ -3934,7 +3934,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.clearNotifications(reaction.get('fromId'));
}
const newCount = this.get('reactions').length;
const newCount = (this.get('reactions') || []).length;
window.log.info(
`Done processing reaction for message ${messageId}. Went from ${count} to ${newCount} reactions.`
);

View file

@ -36,6 +36,7 @@ import {
import {
AttachmentDownloadJobType,
ClientInterface,
ClientSearchResultMessageType,
ClientJobType,
ConversationType,
IdentityKeyType,
@ -55,7 +56,6 @@ import {
import Server from './Server';
import { MessageModel } from '../models/messages';
import { ConversationModel } from '../models/conversations';
import { waitForPendingQueries } from './Queueing';
// We listen to a lot of events on ipcRenderer, often on the same channel. This prevents
// any warnings that might be sent to the console in that case.
@ -243,12 +243,14 @@ const dataInterface: ClientInterface = {
export default dataInterface;
async function goBackToMainProcess(): Promise<void> {
window.log.info('data.goBackToMainProcess: waiting for pending queries');
// Let pending queries finish before we'll give write access to main process.
// We don't want to be writing from two processes at the same time!
await waitForPendingQueries();
if (!shouldUseRendererProcess) {
window.log.info(
'data.goBackToMainProcess: already switched to main process'
);
return;
}
// We don't need to wait for pending queries since they are synchronous.
window.log.info('data.goBackToMainProcess: switching to main process');
shouldUseRendererProcess = false;
@ -514,8 +516,6 @@ function keysFromArrayBuffer(keys: Array<string>, data: any) {
// Top-level calls
async function shutdown() {
await waitForPendingQueries();
// Stop accepting new SQL jobs, flush outstanding queue
await _shutdown();
@ -761,7 +761,13 @@ const updateConversationBatcher = createBatcher<ConversationType>({
// We only care about the most recent update for each conversation
const byId = groupBy(items, item => item.id);
const ids = Object.keys(byId);
const mostRecent = ids.map(id => last(byId[id]));
const mostRecent = ids.map(
(id: string): ConversationType => {
const maybeLast = last(byId[id]);
assert(maybeLast !== undefined, 'Empty array in `groupBy` result');
return maybeLast;
}
);
await updateConversations(mostRecent);
},
@ -857,9 +863,13 @@ async function searchConversations(query: string) {
return conversations;
}
function handleSearchMessageJSON(messages: Array<SearchResultMessageType>) {
function handleSearchMessageJSON(
messages: Array<SearchResultMessageType>
): Array<ClientSearchResultMessageType> {
return messages.map(message => ({
json: message.json,
...JSON.parse(message.json),
bodyRanges: [],
snippet: message.snippet,
}));
}
@ -940,7 +950,7 @@ async function getMessageById(
) {
const message = await channels.getMessageById(id);
if (!message) {
return null;
return undefined;
}
return new Message(message);
@ -1262,7 +1272,9 @@ async function updateUnprocessedAttempts(id: string, attempts: number) {
async function updateUnprocessedWithData(id: string, data: UnprocessedType) {
await channels.updateUnprocessedWithData(id, data);
}
async function updateUnprocessedsWithData(array: Array<UnprocessedType>) {
async function updateUnprocessedsWithData(
array: Array<{ id: string; data: UnprocessedType }>
) {
await channels.updateUnprocessedsWithData(array);
}

View file

@ -4,31 +4,129 @@
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { LocaleMessagesType } from '../types/I18N';
import {
ConversationAttributesType,
ConversationModelCollectionType,
MessageAttributesType,
MessageModelCollectionType,
} from '../model-types.d';
import { MessageModel } from '../models/messages';
import { ConversationModel } from '../models/conversations';
export type AttachmentDownloadJobType = any;
export type ConverationMetricsType = any;
export type ConversationType = any;
export type EmojiType = any;
export type IdentityKeyType = any;
export type AttachmentDownloadJobType = {
id: string;
timestamp: number;
pending: number;
attempts: number;
};
export type MessageMetricsType = {
id: string;
// eslint-disable-next-line camelcase
received_at: number;
// eslint-disable-next-line camelcase
sent_at: number;
};
export type ConversationMetricsType = {
oldest?: MessageMetricsType;
newest?: MessageMetricsType;
oldestUnread?: MessageMetricsType;
totalUnread: number;
};
export type ConversationType = ConversationAttributesType;
export type EmojiType = {
shortName: string;
lastUsage: number;
};
export type IdentityKeyType = {
firstUse: boolean;
id: string;
nonblockingApproval: boolean;
publicKey: ArrayBuffer;
timestamp: number;
verified: number;
};
export type ItemType = any;
export type MessageType = any;
export type MessageTypeUnhydrated = any;
export type PreKeyType = any;
export type SearchResultMessageType = any;
export type SessionType = any;
export type SignedPreKeyType = any;
export type StickerPackStatusType = string;
export type StickerPackType = any;
export type StickerType = any;
export type UnprocessedType = any;
export type MessageType = MessageAttributesType;
export type MessageTypeUnhydrated = {
json: string;
};
export type PreKeyType = {
id: number;
privateKey: ArrayBuffer;
publicKey: ArrayBuffer;
};
export type SearchResultMessageType = {
json: string;
snippet: string;
};
export type ClientSearchResultMessageType = MessageType & {
json: string;
bodyRanges: [];
snippet: string;
};
export type SessionType = {
id: string;
conversationId: string;
deviceId: number;
record: string;
};
export type SignedPreKeyType = {
confirmed: boolean;
// eslint-disable-next-line camelcase
created_at: number;
id: number;
privateKey: ArrayBuffer;
publicKey: ArrayBuffer;
};
export type StickerPackStatusType =
| 'known'
| 'ephemeral'
| 'downloaded'
| 'installed'
| 'pending'
| 'error';
export type StickerType = {
id: number;
packId: string;
emoji: string;
isCoverOnly: string;
lastUsed: number;
path: string;
width: number;
height: number;
};
export type StickerPackType = {
id: string;
key: string;
attemptedStatus: 'downloaded' | 'installed' | 'ephemeral';
author: string;
coverStickerId: number;
createdAt: number;
downloadAttempts: number;
installedAt: number | null;
lastUsed: number;
status: StickerPackStatusType;
stickerCount: number;
stickers: ReadonlyArray<string>;
title: string;
};
export type UnprocessedType = {
id: string;
timestamp: number;
version: number;
attempts: number;
envelope: string;
source?: string;
sourceUuid?: string;
sourceDevice?: string;
serverTimestamp?: number;
decrypted?: string;
};
export type DataInterface = {
close: () => Promise<void>;
@ -84,15 +182,6 @@ export type DataInterface = {
query: string,
options?: { limit?: number }
) => Promise<Array<ConversationType>>;
searchMessages: (
query: string,
options?: { limit?: number }
) => Promise<Array<SearchResultMessageType>>;
searchMessagesInConversation: (
query: string,
conversationId: string,
options?: { limit?: number }
) => Promise<Array<SearchResultMessageType>>;
getMessageCount: (conversationId?: string) => Promise<number>;
saveMessages: (
@ -102,7 +191,7 @@ export type DataInterface = {
getAllMessageIds: () => Promise<Array<string>>;
getMessageMetricsForConversation: (
conversationId: string
) => Promise<ConverationMetricsType>;
) => Promise<ConversationMetricsType>;
hasGroupCallHistoryMessage: (
conversationId: string,
eraId: string
@ -117,13 +206,15 @@ export type DataInterface = {
saveUnprocessed: (
data: UnprocessedType,
options?: { forceSave?: boolean }
) => Promise<number>;
) => Promise<string>;
updateUnprocessedAttempts: (id: string, attempts: number) => Promise<void>;
updateUnprocessedWithData: (
id: string,
data: UnprocessedType
) => Promise<void>;
updateUnprocessedsWithData: (array: Array<UnprocessedType>) => Promise<void>;
updateUnprocessedsWithData: (
array: Array<{ id: string; data: UnprocessedType }>
) => Promise<void>;
getUnprocessedById: (id: string) => Promise<UnprocessedType | undefined>;
saveUnprocesseds: (
arrayOfUnprocessed: Array<UnprocessedType>,
@ -203,7 +294,7 @@ export type ServerInterface = DataInterface & {
getAllConversations: () => Promise<Array<ConversationType>>;
getAllGroupsInvolvingId: (id: string) => Promise<Array<ConversationType>>;
getAllPrivateConversations: () => Promise<Array<ConversationType>>;
getConversationById: (id: string) => Promise<ConversationType | null>;
getConversationById: (id: string) => Promise<ConversationType | undefined>;
getExpiredMessages: () => Promise<Array<MessageType>>;
getMessageById: (id: string) => Promise<MessageType | undefined>;
getMessageBySender: (options: {
@ -234,8 +325,8 @@ export type ServerInterface = DataInterface & {
conversationId: string;
ourConversationId: string;
}) => Promise<MessageType | undefined>;
getNextExpiringMessage: () => Promise<MessageType>;
getNextTapToViewMessageToAgeOut: () => Promise<MessageType>;
getNextExpiringMessage: () => Promise<MessageType | undefined>;
getNextTapToViewMessageToAgeOut: () => Promise<MessageType | undefined>;
getOutgoingWithoutExpiresAt: () => Promise<Array<MessageType>>;
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
getUnreadByConversation: (
@ -244,6 +335,15 @@ export type ServerInterface = DataInterface & {
removeConversation: (id: Array<string> | string) => Promise<void>;
removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: Array<string>) => Promise<void>;
searchMessages: (
query: string,
options?: { limit?: number }
) => Promise<Array<SearchResultMessageType>>;
searchMessagesInConversation: (
query: string,
conversationId: string,
options?: { limit?: number }
) => Promise<Array<SearchResultMessageType>>;
saveMessage: (
data: MessageType,
options: { forceSave?: boolean }
@ -255,11 +355,7 @@ export type ServerInterface = DataInterface & {
// Server-only
initialize: (options: {
configDir: string;
key: string;
messages: LocaleMessagesType;
}) => Promise<void>;
initialize: (options: { configDir: string; key: string }) => Promise<void>;
initializeRenderer: (options: {
configDir: string;
@ -298,7 +394,7 @@ export type ClientInterface = DataInterface & {
getMessageById: (
id: string,
options: { Message: typeof MessageModel }
) => Promise<MessageType | undefined>;
) => Promise<MessageModel | undefined>;
getMessageBySender: (
data: {
source: string;
@ -373,6 +469,15 @@ export type ClientInterface = DataInterface & {
data: MessageType,
options: { forceSave?: boolean; Message: typeof MessageModel }
) => Promise<string>;
searchMessages: (
query: string,
options?: { limit?: number }
) => Promise<Array<ClientSearchResultMessageType>>;
searchMessagesInConversation: (
query: string,
conversationId: string,
options?: { limit?: number }
) => Promise<Array<ClientSearchResultMessageType>>;
updateConversation: (data: ConversationType, extra?: unknown) => void;
// Test-only

View file

@ -1,141 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Queue from 'p-queue';
import { ServerInterface } from './Interface';
let allQueriesDone: () => void | undefined;
let sqlQueries = 0;
let singleQueue: Queue | null = null;
let multipleQueue: Queue | null = null;
// Note: we don't want queue timeouts, because delays here are due to in-progress sql
// operations. For example we might try to start a transaction when the previous isn't
// done, causing that database operation to fail.
function makeNewSingleQueue(): Queue {
singleQueue = new Queue({ concurrency: 1 });
return singleQueue;
}
function makeNewMultipleQueue(): Queue {
multipleQueue = new Queue({ concurrency: 10 });
return multipleQueue;
}
const DEBUG = false;
function makeSQLJob(
fn: ServerInterface[keyof ServerInterface],
args: Array<unknown>,
callName: keyof ServerInterface
) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log(`SQL(${callName}) queued`);
}
return async () => {
sqlQueries += 1;
const start = Date.now();
if (DEBUG) {
// eslint-disable-next-line no-console
console.log(`SQL(${callName}) started`);
}
let result;
try {
// Ignoring this error TS2556: Expected 3 arguments, but got 0 or more.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
result = await fn(...args);
} finally {
sqlQueries -= 1;
if (allQueriesDone && sqlQueries <= 0) {
allQueriesDone();
}
}
const end = Date.now();
const delta = end - start;
if (DEBUG || delta > 10) {
// eslint-disable-next-line no-console
console.log(`SQL(${callName}) succeeded in ${end - start}ms`);
}
return result;
};
}
async function handleCall(
fn: ServerInterface[keyof ServerInterface],
args: Array<unknown>,
callName: keyof ServerInterface
) {
if (!fn) {
throw new Error(`sql channel: ${callName} is not an available function`);
}
let result;
// We queue here to keep multi-query operations atomic. Without it, any multistage
// data operation (even within a BEGIN/COMMIT) can become interleaved, since all
// requests share one database connection.
// A needsSerial method must be run in our single concurrency queue.
if (fn.needsSerial) {
if (singleQueue) {
result = await singleQueue.add(makeSQLJob(fn, args, callName));
} else if (multipleQueue) {
const queue = makeNewSingleQueue();
const multipleQueueLocal = multipleQueue;
queue.add(() => multipleQueueLocal.onIdle());
multipleQueue = null;
result = await queue.add(makeSQLJob(fn, args, callName));
} else {
const queue = makeNewSingleQueue();
result = await queue.add(makeSQLJob(fn, args, callName));
}
} else {
// The request can be parallelized. To keep the same structure as the above block
// we force this section into the 'lonely if' pattern.
// eslint-disable-next-line no-lonely-if
if (multipleQueue) {
result = await multipleQueue.add(makeSQLJob(fn, args, callName));
} else if (singleQueue) {
const queue = makeNewMultipleQueue();
queue.pause();
const singleQueueRef = singleQueue;
singleQueue = null;
const promise = queue.add(makeSQLJob(fn, args, callName));
if (singleQueueRef) {
await singleQueueRef.onIdle();
}
queue.start();
result = await promise;
} else {
const queue = makeNewMultipleQueue();
result = await queue.add(makeSQLJob(fn, args, callName));
}
}
return result;
}
export async function waitForPendingQueries(): Promise<void> {
return new Promise<void>(resolve => {
if (sqlQueries === 0) {
resolve();
} else {
allQueriesDone = () => resolve();
}
});
}
export function applyQueueing(dataInterface: ServerInterface): ServerInterface {
return Object.keys(dataInterface).reduce((acc, callName) => {
const serverInterfaceKey = callName as keyof ServerInterface;
acc[serverInterfaceKey] = async (...args: Array<unknown>) =>
handleCall(dataInterface[serverInterfaceKey], args, serverInterfaceKey);
return acc;
}, {} as ServerInterface);
}

File diff suppressed because it is too large Load diff

113
ts/sql/main.ts Normal file
View file

@ -0,0 +1,113 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'path';
import { Worker } from 'worker_threads';
export type InitializeOptions = {
readonly configDir: string;
readonly key: string;
};
export type WorkerRequest =
| {
readonly type: 'init';
readonly options: InitializeOptions;
}
| {
readonly type: 'close';
}
| {
readonly type: 'sqlCall';
readonly method: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly args: ReadonlyArray<any>;
};
export type WrappedWorkerRequest = {
readonly seq: number;
readonly request: WorkerRequest;
};
export type WrappedWorkerResponse = {
readonly seq: number;
readonly error: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly response: any;
};
type PromisePair<T> = {
resolve: (response: T) => void;
reject: (error: Error) => void;
};
export class MainSQL {
private readonly worker: Worker;
private readonly onExit: Promise<void>;
private seq = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private onResponse = new Map<number, PromisePair<any>>();
constructor() {
const appDir = join(__dirname, '..', '..').replace(
/app\.asar$/,
'app.asar.unpacked'
);
this.worker = new Worker(join(appDir, 'ts', 'sql', 'mainWorker.js'));
this.worker.on('message', (wrappedResponse: WrappedWorkerResponse) => {
const { seq, error, response } = wrappedResponse;
const pair = this.onResponse.get(seq);
this.onResponse.delete(seq);
if (!pair) {
throw new Error(`Unexpected worker response with seq: ${seq}`);
}
if (error) {
pair.reject(new Error(error));
} else {
pair.resolve(response);
}
});
this.onExit = new Promise<void>(resolve => {
this.worker.once('exit', resolve);
});
}
public async initialize(options: InitializeOptions): Promise<void> {
return this.send({ type: 'init', options });
}
public async close(): Promise<void> {
await this.send({ type: 'close' });
await this.onExit;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public async sqlCall(method: string, args: ReadonlyArray<any>): Promise<any> {
return this.send({ type: 'sqlCall', method, args });
}
private async send<Response>(request: WorkerRequest): Promise<Response> {
const { seq } = this;
this.seq += 1;
const result = new Promise<Response>((resolve, reject) => {
this.onResponse.set(seq, { resolve, reject });
});
const wrappedRequest: WrappedWorkerRequest = {
seq,
request,
};
this.worker.postMessage(wrappedRequest);
return result;
}
}

56
ts/sql/mainWorker.ts Normal file
View file

@ -0,0 +1,56 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { parentPort } from 'worker_threads';
import { WrappedWorkerRequest, WrappedWorkerResponse } from './main';
import db from './Server';
if (!parentPort) {
throw new Error('Must run as a worker thread');
}
const port = parentPort;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function respond(seq: number, error: Error | undefined, response?: any) {
const wrappedResponse: WrappedWorkerResponse = {
seq,
error: error ? error.stack : undefined,
response,
};
port.postMessage(wrappedResponse);
}
port.on('message', async ({ seq, request }: WrappedWorkerRequest) => {
try {
if (request.type === 'init') {
await db.initialize(request.options);
respond(seq, undefined, undefined);
return;
}
if (request.type === 'close') {
await db.close();
respond(seq, undefined, undefined);
process.exit(0);
return;
}
if (request.type === 'sqlCall') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const method = (db as any)[request.method];
if (typeof method !== 'function') {
throw new Error(`Invalid sql method: ${method}`);
}
respond(seq, undefined, await method.apply(db, request.args));
} else {
throw new Error('Unexpected request type');
}
} catch (error) {
respond(seq, error, undefined);
}
});

184
ts/sqlcipher.d.ts vendored
View file

@ -1,184 +0,0 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Taken from:
// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/8bf8aedba75ada257428c4846d2bc7d14e3b4be8/types/sqlite3/index.d.ts
declare module '@journeyapps/sqlcipher' {
// Type definitions for sqlite3 3.1
// Project: http://github.com/mapbox/node-sqlite3
// Definitions by: Nick Malaguti <https://github.com/nmalaguti>
// Sumant Manne <https://github.com/dpyro>
// Behind The Math <https://github.com/BehindTheMath>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
/// <reference types="node" />
import events = require('events');
export const OPEN_READONLY: number;
export const OPEN_READWRITE: number;
export const OPEN_CREATE: number;
export const OPEN_SHAREDCACHE: number;
export const OPEN_PRIVATECACHE: number;
export const OPEN_URI: number;
export const cached: {
Database(
filename: string,
callback?: (this: Database, err: Error | null) => void
): Database;
Database(
filename: string,
mode?: number,
callback?: (this: Database, err: Error | null) => void
): Database;
};
export type RunResult = Statement & {
lastID: number;
changes: number;
};
export class Statement {
bind(callback?: (err: Error | null) => void): this;
bind(...params: any[]): this;
reset(callback?: (err: null) => void): this;
finalize(callback?: (err: Error) => void): Database;
run(callback?: (err: Error | null) => void): this;
run(
params: any,
callback?: (this: RunResult, err: Error | null) => void
): this;
run(...params: any[]): this;
get(callback?: (err: Error | null, row?: any) => void): this;
get(
params: any,
callback?: (this: RunResult, err: Error | null, row?: any) => void
): this;
get(...params: any[]): this;
all(callback?: (err: Error | null, rows: any[]) => void): this;
all(
params: any,
callback?: (this: RunResult, err: Error | null, rows: any[]) => void
): this;
all(...params: any[]): this;
each(
callback?: (err: Error | null, row: any) => void,
complete?: (err: Error | null, count: number) => void
): this;
each(
params: any,
callback?: (this: RunResult, err: Error | null, row: any) => void,
complete?: (err: Error | null, count: number) => void
): this;
each(...params: any[]): this;
}
export class Database extends events.EventEmitter {
constructor(filename: string, callback?: (err: Error | null) => void);
constructor(
filename: string,
mode?: number,
callback?: (err: Error | null) => void
);
close(callback?: (err: Error | null) => void): void;
run(
sql: string,
callback?: (this: RunResult, err: Error | null) => void
): this;
run(
sql: string,
params: any,
callback?: (this: RunResult, err: Error | null) => void
): this;
run(sql: string, ...params: any[]): this;
get(
sql: string,
callback?: (this: Statement, err: Error | null, row: any) => void
): this;
get(
sql: string,
params: any,
callback?: (this: Statement, err: Error | null, row: any) => void
): this;
get(sql: string, ...params: any[]): this;
all(
sql: string,
callback?: (this: Statement, err: Error | null, rows: any[]) => void
): this;
all(
sql: string,
params: any,
callback?: (this: Statement, err: Error | null, rows: any[]) => void
): this;
all(sql: string, ...params: any[]): this;
each(
sql: string,
callback?: (this: Statement, err: Error | null, row: any) => void,
complete?: (err: Error | null, count: number) => void
): this;
each(
sql: string,
params: any,
callback?: (this: Statement, err: Error | null, row: any) => void,
complete?: (err: Error | null, count: number) => void
): this;
each(sql: string, ...params: any[]): this;
exec(
sql: string,
callback?: (this: Statement, err: Error | null) => void
): this;
prepare(
sql: string,
callback?: (this: Statement, err: Error | null) => void
): Statement;
prepare(
sql: string,
params: any,
callback?: (this: Statement, err: Error | null) => void
): Statement;
prepare(sql: string, ...params: any[]): Statement;
serialize(callback?: () => void): void;
parallelize(callback?: () => void): void;
on(event: 'trace', listener: (sql: string) => void): this;
on(event: 'profile', listener: (sql: string, time: number) => void): this;
on(event: 'error', listener: (err: Error) => void): this;
on(event: 'open' | 'close', listener: () => void): this;
on(event: string, listener: (...args: any[]) => void): this;
configure(option: 'busyTimeout', value: number): void;
interrupt(): void;
}
export function verbose(): sqlite3;
export interface sqlite3 {
OPEN_READONLY: number;
OPEN_READWRITE: number;
OPEN_CREATE: number;
OPEN_SHAREDCACHE: number;
OPEN_PRIVATECACHE: number;
OPEN_URI: number;
cached: typeof cached;
RunResult: RunResult;
Statement: typeof Statement;
Database: typeof Database;
verbose(): this;
}
}

View file

@ -42,7 +42,7 @@ import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelect
export type DBConversationType = {
id: string;
activeAt?: number;
lastMessage: string;
lastMessage?: string | null;
type: string;
};
@ -138,19 +138,28 @@ export type ConversationType = {
export type ConversationLookupType = {
[key: string]: ConversationType;
};
export type CustomError = Error & {
identifier?: string;
number?: string;
};
export type MessageType = {
id: string;
conversationId: string;
source?: string;
sourceUuid?: string;
type:
type?:
| 'incoming'
| 'outgoing'
| 'group'
| 'keychange'
| 'verified-change'
| 'message-history-unsynced'
| 'call-history';
| 'call-history'
| 'chat-session-refreshed'
| 'group-v1-migration'
| 'group-v2-change'
| 'profile-change'
| 'timer-notification';
quote?: { author?: string; authorUuid?: string };
received_at: number;
sent_at?: number;
@ -179,7 +188,7 @@ export type MessageType = {
}>;
deletedForEveryone?: boolean;
errors?: Array<Error>;
errors?: Array<CustomError>;
group_update?: unknown;
callHistoryDetails?: CallHistoryDetailsFromDiskType;

View file

@ -5,6 +5,10 @@ import { omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import {
ClientSearchResultMessageType,
ClientInterface,
} from '../../sql/Interface';
import dataInterface from '../../sql/Client';
import { makeLookup } from '../../util/makeLookup';
import { BodyRangesType } from '../../types/Util';
@ -23,7 +27,7 @@ const {
searchConversations: dataSearchConversations,
searchMessages: dataSearchMessages,
searchMessagesInConversation,
} = dataInterface;
}: ClientInterface = dataInterface;
// State
@ -244,7 +248,10 @@ function updateSearchTerm(query: string): UpdateSearchTermActionType {
};
}
async function queryMessages(query: string, searchConversationId?: string) {
async function queryMessages(
query: string,
searchConversationId?: string
): Promise<Array<ClientSearchResultMessageType>> {
try {
const normalized = cleanSearchTerm(query);

View file

@ -36,7 +36,7 @@ const Hangul_Syllables = /[\uAC00-\uD7AF]/;
const isIdeographic = /[\u3006\u3007\u3021-\u3029\u3038-\u303A\u3400-\u4DB5\u4E00-\u9FEF\uF900-\uFA6D\uFA70-\uFAD9]|[\uD81C-\uD820\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD821[\uDC00-\uDFF7]|\uD822[\uDC00-\uDEF2]|\uD82C[\uDD70-\uDEFB]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]/;
export function combineNames(
given: string,
given?: string,
family?: string
): undefined | string {
if (!given) {

View file

@ -10236,78 +10236,6 @@
"reasonCategory": "falseMatch",
"updated": "2019-07-19T17:16:02.404Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/node-pre-gyp/node_modules/debug/dist/debug.js",
"line": " createDebug.enable(createDebug.load());",
"lineNumber": 694,
"reasonCategory": "falseMatch",
"updated": "2021-01-21T16:16:34.352Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/node-pre-gyp/node_modules/debug/dist/debug.js",
"line": " function load() {",
"lineNumber": 828,
"reasonCategory": "falseMatch",
"updated": "2021-01-21T16:16:34.352Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/node-pre-gyp/node_modules/debug/src/browser.js",
"line": "function load() {",
"lineNumber": 129,
"reasonCategory": "falseMatch",
"updated": "2021-01-21T16:16:34.352Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/node-pre-gyp/node_modules/debug/src/common.js",
"line": " createDebug.enable(createDebug.load());",
"lineNumber": 244,
"reasonCategory": "falseMatch",
"updated": "2021-01-21T16:16:34.352Z"
},
{
"rule": "jQuery-load(",
"path": "node_modules/node-pre-gyp/node_modules/debug/src/node.js",
"line": "function load() {",
"lineNumber": 135,
"reasonCategory": "falseMatch",
"updated": "2021-01-21T16:16:34.352Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/node-pre-gyp/node_modules/needle/lib/multipart.js",
"line": " function append(data, filename) {",
"lineNumber": 42,
"reasonCategory": "falseMatch",
"updated": "2020-04-30T22:35:27.860Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/node-pre-gyp/node_modules/needle/lib/multipart.js",
"line": " if (part.buffer) return append(part.buffer, filename);",
"lineNumber": 58,
"reasonCategory": "falseMatch",
"updated": "2020-04-30T22:35:27.860Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/node-pre-gyp/node_modules/needle/lib/multipart.js",
"line": " append(data, filename);",
"lineNumber": 62,
"reasonCategory": "falseMatch",
"updated": "2020-04-30T22:35:27.860Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/node-pre-gyp/node_modules/needle/lib/multipart.js",
"line": " append();",
"lineNumber": 77,
"reasonCategory": "falseMatch",
"updated": "2020-04-30T22:35:27.860Z"
},
{
"rule": "jQuery-$(",
"path": "node_modules/nugget/node_modules/ajv/dist/ajv.min.js",
@ -15681,7 +15609,7 @@
"rule": "jQuery-load(",
"path": "ts/LibSignalStore.ts",
"line": " await window.ConversationController.load();",
"lineNumber": 1222,
"lineNumber": 1190,
"reasonCategory": "falseMatch",
"updated": "2021-02-27T00:48:49.313Z"
},
@ -16785,4 +16713,4 @@
"updated": "2021-01-08T15:46:32.143Z",
"reasonDetail": "Doesn't manipulate the DOM. This is just a function."
}
]
]

View file

@ -44,6 +44,8 @@ const basePath = join(__dirname, '../../..');
const searchPattern = normalizePath(join(basePath, '**/*.{js,ts,tsx}'));
const excludedFilesRegexps = [
'^release/',
// Non-distributed files
'\\.d\\.ts$',

View file

@ -1138,7 +1138,7 @@ Whisper.ConversationView = Whisper.View.extend({
// If newest in-memory message is unread, scrolling down would mean going to
// the very bottom, not the oldest unread.
if (newestInMemoryMessage.isUnread()) {
if (newestInMemoryMessage && newestInMemoryMessage.isUnread()) {
scrollToLatestUnread = false;
}
}
@ -3247,9 +3247,13 @@ Whisper.ConversationView = Whisper.View.extend({
? await getMessageById(messageId, {
Message: Whisper.Message,
})
: null;
: undefined;
try {
if (!messageModel) {
throw new Error('Message not found');
}
await this.model.sendReactionMessage(reaction, {
targetAuthorUuid: messageModel.getSourceUuid(),
targetTimestamp: messageModel.get('sent_at'),
@ -3329,7 +3333,7 @@ Whisper.ConversationView = Whisper.View.extend({
? await getMessageById(messageId, {
Message: Whisper.Message,
})
: null;
: undefined;
if (model && !model.canReply()) {
return;