feat: add services package
This commit is contained in:
28
packages/services/src/Domain/Alert/AlertService.ts
Normal file
28
packages/services/src/Domain/Alert/AlertService.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum ButtonType {
|
||||
Info = 0,
|
||||
Danger = 1,
|
||||
}
|
||||
|
||||
export type DismissBlockingDialog = () => void
|
||||
|
||||
export abstract class AlertService {
|
||||
abstract confirm(
|
||||
text: string,
|
||||
title?: string,
|
||||
confirmButtonText?: string,
|
||||
confirmButtonType?: ButtonType,
|
||||
cancelButtonText?: string,
|
||||
): Promise<boolean>
|
||||
|
||||
abstract alert(text: string, title?: string, closeButtonText?: string): Promise<void>
|
||||
|
||||
abstract blockingDialog(text: string, title?: string): DismissBlockingDialog | Promise<DismissBlockingDialog>
|
||||
|
||||
showErrorAlert(error: ClientDisplayableError): Promise<void> {
|
||||
return this.alert(error.text, error.title)
|
||||
}
|
||||
}
|
||||
19
packages/services/src/Domain/Api/ApiServiceInterface.ts
Normal file
19
packages/services/src/Domain/Api/ApiServiceInterface.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { Role } from '@standardnotes/auth'
|
||||
import { FilesApiInterface } from '../Files/FilesApiInterface'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum ApiServiceEvent {
|
||||
MetaReceived = 'MetaReceived',
|
||||
}
|
||||
|
||||
export type MetaReceivedData = {
|
||||
userUuid: Uuid
|
||||
userRoles: Role[]
|
||||
}
|
||||
|
||||
export interface ApiServiceInterface
|
||||
extends AbstractService<ApiServiceEvent.MetaReceived, MetaReceivedData>,
|
||||
FilesApiInterface {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
|
||||
import { DeinitCallback } from './DeinitCallback'
|
||||
import { DeinitMode } from './DeinitMode'
|
||||
import { DeinitSource } from './DeinitSource'
|
||||
import { UserClientInterface } from './UserClientInterface'
|
||||
|
||||
export interface ApplicationInterface {
|
||||
deinit(mode: DeinitMode, source: DeinitSource): void
|
||||
|
||||
getDeinitMode(): DeinitMode
|
||||
|
||||
get user(): UserClientInterface
|
||||
|
||||
readonly identifier: ApplicationIdentifier
|
||||
}
|
||||
|
||||
export interface AppGroupManagedApplication extends ApplicationInterface {
|
||||
onDeinit: DeinitCallback
|
||||
|
||||
setOnDeinit(onDeinit: DeinitCallback): void
|
||||
}
|
||||
11
packages/services/src/Domain/Application/ApplicationStage.ts
Normal file
11
packages/services/src/Domain/Application/ApplicationStage.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/* istanbul ignore file */
|
||||
export enum ApplicationStage {
|
||||
PreparingForLaunch_0 = 0.0,
|
||||
ReadyForLaunch_05 = 0.5,
|
||||
StorageDecrypted_09 = 0.9,
|
||||
Launched_10 = 1.0,
|
||||
LoadingDatabase_11 = 1.1,
|
||||
LoadedDatabase_12 = 1.2,
|
||||
FullSyncCompleted_13 = 1.3,
|
||||
SignedIn_30 = 3.0,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DeinitSource } from './DeinitSource'
|
||||
import { DeinitMode } from './DeinitMode'
|
||||
import { AppGroupManagedApplication } from './ApplicationInterface'
|
||||
|
||||
export type DeinitCallback = (application: AppGroupManagedApplication, mode: DeinitMode, source: DeinitSource) => void
|
||||
6
packages/services/src/Domain/Application/DeinitMode.ts
Normal file
6
packages/services/src/Domain/Application/DeinitMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum DeinitMode {
|
||||
Soft = 'Soft',
|
||||
Hard = 'Hard',
|
||||
}
|
||||
8
packages/services/src/Domain/Application/DeinitSource.ts
Normal file
8
packages/services/src/Domain/Application/DeinitSource.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum DeinitSource {
|
||||
SignOut = 1,
|
||||
Lock,
|
||||
SwitchWorkspace,
|
||||
SignOutAll,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DeinitSource } from './DeinitSource'
|
||||
export interface UserClientInterface {
|
||||
deleteAccount(): Promise<{
|
||||
error: boolean
|
||||
message?: string
|
||||
}>
|
||||
|
||||
signOut(force?: boolean, source?: DeinitSource): Promise<void>
|
||||
}
|
||||
21
packages/services/src/Domain/Challenge/ChallengeInterface.ts
Normal file
21
packages/services/src/Domain/Challenge/ChallengeInterface.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
|
||||
import { ChallengeReason } from './Types/ChallengeReason'
|
||||
import { ChallengeValidation } from './Types/ChallengeValidation'
|
||||
|
||||
export interface ChallengeInterface {
|
||||
readonly id: number
|
||||
readonly prompts: ChallengePromptInterface[]
|
||||
readonly reason: ChallengeReason
|
||||
readonly cancelable: boolean
|
||||
|
||||
/** Outside of the modal, this is the title of the modal itself */
|
||||
get modalTitle(): string
|
||||
|
||||
/** Inside of the modal, this is the H1 */
|
||||
get heading(): string | undefined
|
||||
|
||||
/** Inside of the modal, this is the H2 */
|
||||
get subheading(): string | undefined
|
||||
|
||||
hasPromptForValidationType(type: ChallengeValidation): boolean
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ChallengeInterface } from './ChallengeInterface'
|
||||
import { ChallengeArtifacts } from './Types/ChallengeArtifacts'
|
||||
import { ChallengeValidation } from './Types/ChallengeValidation'
|
||||
import { ChallengeValue } from './Types/ChallengeValue'
|
||||
|
||||
export interface ChallengeResponseInterface {
|
||||
readonly challenge: ChallengeInterface
|
||||
readonly values: ChallengeValue[]
|
||||
readonly artifacts?: ChallengeArtifacts
|
||||
|
||||
getValueForType(type: ChallengeValidation): ChallengeValue
|
||||
getDefaultValue(): ChallengeValue
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { ChallengeInterface } from './ChallengeInterface'
|
||||
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
|
||||
import { ChallengeResponseInterface } from './ChallengeResponseInterface'
|
||||
import { ChallengeReason } from './Types/ChallengeReason'
|
||||
|
||||
export interface ChallengeServiceInterface extends AbstractService {
|
||||
/**
|
||||
* Resolves when the challenge has been completed.
|
||||
* For non-validated challenges, will resolve when the first value is submitted.
|
||||
*/
|
||||
promptForChallengeResponse(challenge: ChallengeInterface): Promise<ChallengeResponseInterface | undefined>
|
||||
|
||||
createChallenge(
|
||||
prompts: ChallengePromptInterface[],
|
||||
reason: ChallengeReason,
|
||||
cancelable: boolean,
|
||||
heading?: string,
|
||||
subheading?: string,
|
||||
): ChallengeInterface
|
||||
|
||||
completeChallenge(challenge: ChallengeInterface): void
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { assertUnreachable } from '@standardnotes/utils'
|
||||
import { ChallengeKeyboardType } from '../Types/ChallengeKeyboardType'
|
||||
import { ChallengeRawValue } from '../Types/ChallengeRawValue'
|
||||
import { ChallengeValidation } from '../Types/ChallengeValidation'
|
||||
import { ChallengePromptInterface } from './ChallengePromptInterface'
|
||||
import { ChallengePromptTitle } from './PromptTitles'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export class ChallengePrompt implements ChallengePromptInterface {
|
||||
public readonly id = Math.random()
|
||||
public readonly placeholder: string
|
||||
public readonly title: string
|
||||
public readonly validates: boolean
|
||||
|
||||
constructor(
|
||||
public readonly validation: ChallengeValidation,
|
||||
title?: string,
|
||||
placeholder?: string,
|
||||
public readonly secureTextEntry = true,
|
||||
public readonly keyboardType?: ChallengeKeyboardType,
|
||||
public readonly initialValue?: ChallengeRawValue,
|
||||
) {
|
||||
switch (this.validation) {
|
||||
case ChallengeValidation.AccountPassword:
|
||||
this.title = title ?? ChallengePromptTitle.AccountPassword
|
||||
this.placeholder = placeholder ?? ChallengePromptTitle.AccountPassword
|
||||
this.validates = true
|
||||
break
|
||||
case ChallengeValidation.LocalPasscode:
|
||||
this.title = title ?? ChallengePromptTitle.LocalPasscode
|
||||
this.placeholder = placeholder ?? ChallengePromptTitle.LocalPasscode
|
||||
this.validates = true
|
||||
break
|
||||
case ChallengeValidation.Biometric:
|
||||
this.title = title ?? ChallengePromptTitle.Biometrics
|
||||
this.placeholder = placeholder ?? ''
|
||||
this.validates = true
|
||||
break
|
||||
case ChallengeValidation.ProtectionSessionDuration:
|
||||
this.title = title ?? ChallengePromptTitle.RememberFor
|
||||
this.placeholder = placeholder ?? ''
|
||||
this.validates = true
|
||||
break
|
||||
case ChallengeValidation.None:
|
||||
this.title = title ?? ''
|
||||
this.placeholder = placeholder ?? ''
|
||||
this.validates = false
|
||||
break
|
||||
default:
|
||||
assertUnreachable(this.validation)
|
||||
}
|
||||
Object.freeze(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ChallengeKeyboardType } from '../Types/ChallengeKeyboardType'
|
||||
import { ChallengeRawValue } from '../Types/ChallengeRawValue'
|
||||
import { ChallengeValidation } from '../Types/ChallengeValidation'
|
||||
|
||||
/**
|
||||
* A Challenge can have many prompts. Each prompt represents a unique input,
|
||||
* such as a text field, or biometric scanner.
|
||||
*/
|
||||
export interface ChallengePromptInterface {
|
||||
readonly id: number
|
||||
readonly placeholder: string
|
||||
readonly title: string
|
||||
readonly validates: boolean
|
||||
|
||||
readonly validation: ChallengeValidation
|
||||
readonly secureTextEntry: boolean
|
||||
readonly keyboardType?: ChallengeKeyboardType
|
||||
readonly initialValue?: ChallengeRawValue
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export const ChallengePromptTitle = {
|
||||
AccountPassword: 'Account Password',
|
||||
LocalPasscode: 'Application Passcode',
|
||||
Biometrics: 'Biometrics',
|
||||
RememberFor: 'Remember For',
|
||||
Mfa: 'Two-factor Authentication Code',
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RootKeyInterface } from '@standardnotes/models'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export type ChallengeArtifacts = {
|
||||
wrappingKey?: RootKeyInterface
|
||||
rootKey?: RootKeyInterface
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
/** For mobile */
|
||||
export enum ChallengeKeyboardType {
|
||||
Alphanumeric = 'default',
|
||||
Numeric = 'numeric',
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export type ChallengeRawValue = number | string | boolean
|
||||
@@ -0,0 +1,27 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum ChallengeReason {
|
||||
AccessProtectedFile,
|
||||
AccessProtectedNote,
|
||||
AddPasscode,
|
||||
ApplicationUnlock,
|
||||
ChangeAutolockInterval,
|
||||
ChangePasscode,
|
||||
CreateDecryptedBackupWithProtectedItems,
|
||||
Custom,
|
||||
DecryptEncryptedFile,
|
||||
DisableBiometrics,
|
||||
DisableMfa,
|
||||
ExportBackup,
|
||||
ImportFile,
|
||||
Migration,
|
||||
ProtocolUpgrade,
|
||||
RemovePasscode,
|
||||
ResaveRootKey,
|
||||
RevokeSession,
|
||||
SearchProtectedNotesText,
|
||||
SelectProtectedNote,
|
||||
UnprotectFile,
|
||||
UnprotectNote,
|
||||
DeleteAccount,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum ChallengeValidation {
|
||||
None = 0,
|
||||
LocalPasscode = 1,
|
||||
AccountPassword = 2,
|
||||
Biometric = 3,
|
||||
ProtectionSessionDuration = 4,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ChallengePromptInterface } from '../Prompt/ChallengePromptInterface'
|
||||
import { ChallengeRawValue } from './ChallengeRawValue'
|
||||
|
||||
export interface ChallengeValue {
|
||||
readonly prompt: ChallengePromptInterface
|
||||
readonly value: ChallengeRawValue
|
||||
}
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export function CreateChallengeValue(prompt: ChallengePromptInterface, value: ChallengeRawValue): ChallengeValue {
|
||||
return { prompt, value }
|
||||
}
|
||||
12
packages/services/src/Domain/Challenge/index.ts
Normal file
12
packages/services/src/Domain/Challenge/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './ChallengeInterface'
|
||||
export * from './ChallengeResponseInterface'
|
||||
export * from './ChallengeServiceInterface'
|
||||
export * from './Prompt/ChallengePrompt'
|
||||
export * from './Prompt/ChallengePromptInterface'
|
||||
export * from './Prompt/PromptTitles'
|
||||
export * from './Types/ChallengeArtifacts'
|
||||
export * from './Types/ChallengeKeyboardType'
|
||||
export * from './Types/ChallengeRawValue'
|
||||
export * from './Types/ChallengeReason'
|
||||
export * from './Types/ChallengeValidation'
|
||||
export * from './Types/ChallengeValue'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { WebClientRequiresDesktopMethods } from './DesktopWebCommunication'
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
import { Environment } from './Environments'
|
||||
import { WebOrDesktopDeviceInterface } from './WebOrDesktopDeviceInterface'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export function isDesktopDevice(x: DeviceInterface): x is DesktopDeviceInterface {
|
||||
return x.environment === Environment.Desktop
|
||||
}
|
||||
|
||||
export interface DesktopDeviceInterface extends WebOrDesktopDeviceInterface, WebClientRequiresDesktopMethods {
|
||||
environment: Environment.Desktop
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DecryptedTransferPayload } from '@standardnotes/models'
|
||||
import { FileBackupsDevice } from './FileBackupsDevice'
|
||||
|
||||
export interface WebClientRequiresDesktopMethods extends FileBackupsDevice {
|
||||
localBackupsCount(): Promise<number>
|
||||
|
||||
viewlocalBackups(): void
|
||||
|
||||
deleteLocalBackups(): Promise<void>
|
||||
|
||||
syncComponents(payloads: unknown[]): void
|
||||
|
||||
onMajorDataChange(): void
|
||||
|
||||
onInitialDataLoad(): void
|
||||
|
||||
onSearch(text?: string): void
|
||||
|
||||
downloadBackup(): void | Promise<void>
|
||||
|
||||
get extensionsServerHost(): string
|
||||
}
|
||||
|
||||
export interface DesktopClientRequiresWebMethods {
|
||||
updateAvailable(): void
|
||||
|
||||
windowGainedFocus(): void
|
||||
|
||||
windowLostFocus(): void
|
||||
|
||||
onComponentInstallationComplete(componentData: DecryptedTransferPayload, error: unknown): Promise<void>
|
||||
|
||||
requestBackupFile(): Promise<string | undefined>
|
||||
|
||||
didBeginBackup(): void
|
||||
|
||||
didFinishBackup(success: boolean): void
|
||||
}
|
||||
84
packages/services/src/Domain/Device/DeviceInterface.ts
Normal file
84
packages/services/src/Domain/Device/DeviceInterface.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Environment } from './Environments'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import {
|
||||
FullyFormedTransferPayload,
|
||||
TransferPayload,
|
||||
LegacyRawKeychainValue,
|
||||
NamespacedRootKeyInKeychain,
|
||||
} from '@standardnotes/models'
|
||||
|
||||
/**
|
||||
* Platforms must override this class to provide platform specific utilities
|
||||
* and access to the migration service, such as exposing an interface to read
|
||||
* raw values from the database or value storage.
|
||||
*/
|
||||
export interface DeviceInterface {
|
||||
environment: Environment
|
||||
|
||||
deinit(): void
|
||||
|
||||
getRawStorageValue(key: string): Promise<string | undefined>
|
||||
|
||||
getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined>
|
||||
|
||||
getAllRawStorageKeyValues(): Promise<{ key: string; value: unknown }[]>
|
||||
|
||||
setRawStorageValue(key: string, value: string): Promise<void>
|
||||
|
||||
removeRawStorageValue(key: string): Promise<void>
|
||||
|
||||
removeAllRawStorageValues(): Promise<void>
|
||||
|
||||
/**
|
||||
* On web platforms, databased created may be new.
|
||||
* New databases can be because of new sessions, or if the browser deleted it.
|
||||
* In this case, callers should orchestrate with the server to redownload all items
|
||||
* from scratch.
|
||||
* @returns { isNewDatabase } - True if the database was newly created
|
||||
*/
|
||||
openDatabase(identifier: ApplicationIdentifier): Promise<{ isNewDatabase?: boolean } | undefined>
|
||||
|
||||
/**
|
||||
* In a key/value database, this function returns just the keys.
|
||||
*/
|
||||
getDatabaseKeys(): Promise<string[]>
|
||||
|
||||
/**
|
||||
* Remove all keychain and database data from device.
|
||||
* @param workspaceIdentifiers An array of identifiers present during time of function call. Used in case
|
||||
* caller needs to reference the identifiers. This param should not be used to selectively clear workspaces.
|
||||
* @returns true for killsApplication if the clear data operation kills the application process completely.
|
||||
* This tends to be the case for the desktop application.
|
||||
*/
|
||||
clearAllDataFromDevice(workspaceIdentifiers: ApplicationIdentifier[]): Promise<{ killsApplication: boolean }>
|
||||
|
||||
getAllRawDatabasePayloads<T extends FullyFormedTransferPayload = FullyFormedTransferPayload>(
|
||||
identifier: ApplicationIdentifier,
|
||||
): Promise<T[]>
|
||||
|
||||
saveRawDatabasePayload(payload: TransferPayload, identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
saveRawDatabasePayloads(payloads: TransferPayload[], identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
removeRawDatabasePayloadWithId(id: string, identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
removeAllRawDatabasePayloads(identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
getNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<NamespacedRootKeyInKeychain | undefined>
|
||||
|
||||
setNamespacedKeychainValue(value: NamespacedRootKeyInKeychain, identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
clearNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<void>
|
||||
|
||||
setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void>
|
||||
|
||||
clearRawKeychainValue(): Promise<void>
|
||||
|
||||
openUrl(url: string): void
|
||||
|
||||
performSoftReset(): void
|
||||
|
||||
performHardReset(): void
|
||||
|
||||
isDeviceDestroyed(): boolean
|
||||
}
|
||||
16
packages/services/src/Domain/Device/Environments.ts
Normal file
16
packages/services/src/Domain/Device/Environments.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export enum Environment {
|
||||
Web = 1,
|
||||
Desktop = 2,
|
||||
Mobile = 3,
|
||||
}
|
||||
|
||||
export enum Platform {
|
||||
Ios = 1,
|
||||
Android = 2,
|
||||
MacWeb = 3,
|
||||
MacDesktop = 4,
|
||||
WindowsWeb = 5,
|
||||
WindowsDesktop = 6,
|
||||
LinuxWeb = 7,
|
||||
LinuxDesktop = 8,
|
||||
}
|
||||
51
packages/services/src/Domain/Device/FileBackupsDevice.ts
Normal file
51
packages/services/src/Domain/Device/FileBackupsDevice.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { BackupFileEncryptedContextualPayload } from '@standardnotes/models'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export const FileBackupsConstantsV1 = {
|
||||
Version: '1.0.0',
|
||||
MetadataFileName: 'metadata.sn.json',
|
||||
BinaryFileName: 'file.encrypted',
|
||||
}
|
||||
|
||||
export interface FileBackupMetadataFile {
|
||||
info: Record<string, string>
|
||||
file: BackupFileEncryptedContextualPayload
|
||||
itemsKey: BackupFileEncryptedContextualPayload
|
||||
version: '1.0.0'
|
||||
}
|
||||
|
||||
export interface FileBackupsMapping {
|
||||
version: typeof FileBackupsConstantsV1.Version
|
||||
files: Record<
|
||||
Uuid,
|
||||
{
|
||||
backedUpOn: Date
|
||||
absolutePath: string
|
||||
relativePath: string
|
||||
metadataFileName: typeof FileBackupsConstantsV1.MetadataFileName
|
||||
binaryFileName: typeof FileBackupsConstantsV1.BinaryFileName
|
||||
version: typeof FileBackupsConstantsV1.Version
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export interface FileBackupsDevice {
|
||||
getFilesBackupsMappingFile(): Promise<FileBackupsMapping>
|
||||
saveFilesBackupsFile(
|
||||
uuid: Uuid,
|
||||
metaFile: string,
|
||||
downloadRequest: {
|
||||
chunkSizes: number[]
|
||||
valetToken: string
|
||||
url: string
|
||||
},
|
||||
): Promise<'success' | 'failed'>
|
||||
isFilesBackupsEnabled(): Promise<boolean>
|
||||
enableFilesBackups(): Promise<void>
|
||||
disableFilesBackups(): Promise<void>
|
||||
changeFilesBackupsLocation(): Promise<string | undefined>
|
||||
getFilesBackupsLocation(): Promise<string>
|
||||
openFilesBackupsLocation(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
import { Environment } from './Environments'
|
||||
import { RawKeychainValue } from '@standardnotes/models'
|
||||
|
||||
export interface MobileDeviceInterface extends DeviceInterface {
|
||||
environment: Environment.Mobile
|
||||
|
||||
getRawKeychainValue(): Promise<RawKeychainValue | undefined>
|
||||
}
|
||||
18
packages/services/src/Domain/Device/TypeCheck.spec.ts
Normal file
18
packages/services/src/Domain/Device/TypeCheck.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
import { Environment } from './Environments'
|
||||
import { MobileDeviceInterface } from './MobileDeviceInterface'
|
||||
import { isMobileDevice } from './TypeCheck'
|
||||
|
||||
describe('device type check', () => {
|
||||
it('should return true for mobile devices', () => {
|
||||
const device = { environment: Environment.Mobile } as jest.Mocked<MobileDeviceInterface>
|
||||
|
||||
expect(isMobileDevice(device)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return false for non mobile devices', () => {
|
||||
const device = { environment: Environment.Web } as jest.Mocked<DeviceInterface>
|
||||
|
||||
expect(isMobileDevice(device)).toBeFalsy()
|
||||
})
|
||||
})
|
||||
9
packages/services/src/Domain/Device/TypeCheck.ts
Normal file
9
packages/services/src/Domain/Device/TypeCheck.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Environment } from './Environments'
|
||||
import { MobileDeviceInterface } from './MobileDeviceInterface'
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export function isMobileDevice(x: DeviceInterface): x is MobileDeviceInterface {
|
||||
return x.environment === Environment.Mobile
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DeviceInterface } from './DeviceInterface'
|
||||
import { RawKeychainValue } from '@standardnotes/models'
|
||||
|
||||
export interface WebOrDesktopDeviceInterface extends DeviceInterface {
|
||||
readonly appVersion: string
|
||||
|
||||
getKeychainValue(): Promise<RawKeychainValue>
|
||||
|
||||
setKeychainValue(value: RawKeychainValue): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
type DiagnosticValue =
|
||||
| string
|
||||
| number
|
||||
| Date
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| DiagnosticValue[]
|
||||
| { [key: string]: DiagnosticValue }
|
||||
|
||||
export type DiagnosticInfo = {
|
||||
[key: string]: Record<string, DiagnosticValue>
|
||||
}
|
||||
|
||||
export interface ServiceDiagnostics {
|
||||
getDiagnostics(): Promise<DiagnosticInfo | undefined>
|
||||
}
|
||||
1
packages/services/src/Domain/Event/EventObserver.ts
Normal file
1
packages/services/src/Domain/Event/EventObserver.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type EventObserver<E, D> = (eventName: E, data?: D) => Promise<void> | void
|
||||
25
packages/services/src/Domain/Event/SyncEvent.ts
Normal file
25
packages/services/src/Domain/Event/SyncEvent.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/* istanbul ignore file */
|
||||
export enum SyncEvent {
|
||||
/**
|
||||
* A potentially multi-round trip that keeps syncing until all items have been uploaded.
|
||||
* However, this event will still trigger if there are more items waiting to be downloaded on the
|
||||
* server
|
||||
*/
|
||||
SyncCompletedWithAllItemsUploaded = 'SyncCompletedWithAllItemsUploaded',
|
||||
SyncCompletedWithAllItemsUploadedAndDownloaded = 'SyncCompletedWithAllItemsUploadedAndDownloaded',
|
||||
SingleRoundTripSyncCompleted = 'SingleRoundTripSyncCompleted',
|
||||
SyncWillBegin = 'sync:will-begin',
|
||||
DownloadFirstSyncCompleted = 'sync:download-first-completed',
|
||||
SyncTakingTooLong = 'sync:taking-too-long',
|
||||
SyncError = 'sync:error',
|
||||
InvalidSession = 'sync:invalid-session',
|
||||
MajorDataChange = 'major-data-change',
|
||||
LocalDataIncrementalLoad = 'local-data-incremental-load',
|
||||
LocalDataLoaded = 'local-data-loaded',
|
||||
EnterOutOfSync = 'enter-out-of-sync',
|
||||
ExitOutOfSync = 'exit-out-of-sync',
|
||||
StatusChanged = 'status-changed',
|
||||
DatabaseWriteError = 'database-write-error',
|
||||
DatabaseReadError = 'database-read-error',
|
||||
SyncRequestsIntegrityCheck = 'sync:requests-integrity-check',
|
||||
}
|
||||
3
packages/services/src/Domain/Event/SyncEventReceiver.ts
Normal file
3
packages/services/src/Domain/Event/SyncEventReceiver.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { SyncEvent } from './SyncEvent'
|
||||
|
||||
export type SyncEventReceiver = (event: SyncEvent) => void
|
||||
27
packages/services/src/Domain/FileSystem/FileSystemApi.ts
Normal file
27
packages/services/src/Domain/FileSystem/FileSystemApi.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface DirectoryHandle {
|
||||
nativeHandle: unknown
|
||||
}
|
||||
export interface FileHandleReadWrite {
|
||||
nativeHandle: unknown
|
||||
writableStream: unknown
|
||||
}
|
||||
export interface FileHandleRead {
|
||||
nativeHandle: unknown
|
||||
}
|
||||
|
||||
export type FileSystemResult = 'aborted' | 'success' | 'failed'
|
||||
export type FileSystemNoSelection = 'aborted' | 'failed'
|
||||
|
||||
export interface FileSystemApi {
|
||||
selectDirectory(): Promise<DirectoryHandle | FileSystemNoSelection>
|
||||
selectFile(): Promise<FileHandleRead | FileSystemNoSelection>
|
||||
readFile(
|
||||
file: FileHandleRead,
|
||||
onBytes: (bytes: Uint8Array, isLast: boolean) => Promise<void>,
|
||||
): Promise<FileSystemResult>
|
||||
createDirectory(parentDirectory: DirectoryHandle, name: string): Promise<DirectoryHandle | FileSystemNoSelection>
|
||||
createFile(directory: DirectoryHandle, name: string): Promise<FileHandleReadWrite | FileSystemNoSelection>
|
||||
saveBytes(file: FileHandleReadWrite, bytes: Uint8Array): Promise<'success' | 'failed'>
|
||||
saveString(file: FileHandleReadWrite, contents: string): Promise<'success' | 'failed'>
|
||||
closeFileWriteStream(file: FileHandleReadWrite): Promise<'success' | 'failed'>
|
||||
}
|
||||
28
packages/services/src/Domain/Files/FilesApiInterface.ts
Normal file
28
packages/services/src/Domain/Files/FilesApiInterface.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StartUploadSessionResponse, MinimalHttpResponse, ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
export interface FilesApiInterface {
|
||||
startUploadSession(apiToken: string): Promise<StartUploadSessionResponse>
|
||||
|
||||
uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise<boolean>
|
||||
|
||||
closeUploadSession(apiToken: string): Promise<boolean>
|
||||
|
||||
downloadFile(
|
||||
file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] },
|
||||
chunkIndex: number,
|
||||
apiToken: string,
|
||||
contentRangeStart: number,
|
||||
onBytesReceived: (bytes: Uint8Array) => Promise<void>,
|
||||
): Promise<ClientDisplayableError | undefined>
|
||||
|
||||
deleteFile(apiToken: string): Promise<MinimalHttpResponse>
|
||||
|
||||
createFileValetToken(
|
||||
remoteIdentifier: string,
|
||||
operation: 'write' | 'read' | 'delete',
|
||||
unencryptedFileSize?: number,
|
||||
): Promise<string | ClientDisplayableError>
|
||||
|
||||
getFilesDownloadUrl(): string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CheckIntegrityResponse, IntegrityPayload } from '@standardnotes/responses'
|
||||
|
||||
export interface IntegrityApiInterface {
|
||||
checkIntegrity(integrityPayloads: IntegrityPayload[]): Promise<CheckIntegrityResponse>
|
||||
}
|
||||
4
packages/services/src/Domain/Integrity/IntegrityEvent.ts
Normal file
4
packages/services/src/Domain/Integrity/IntegrityEvent.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/* istanbul ignore file */
|
||||
export enum IntegrityEvent {
|
||||
IntegrityCheckCompleted = 'IntegrityCheckCompleted',
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ServerItemResponse } from '@standardnotes/responses'
|
||||
import { SyncSource } from '../Sync/SyncSource'
|
||||
|
||||
export type IntegrityEventPayload = {
|
||||
rawPayloads: ServerItemResponse[]
|
||||
source: SyncSource
|
||||
}
|
||||
160
packages/services/src/Domain/Integrity/IntegrityService.spec.ts
Normal file
160
packages/services/src/Domain/Integrity/IntegrityService.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { TransferPayload } from '@standardnotes/models'
|
||||
import { SyncEvent } from '../Event/SyncEvent'
|
||||
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { ItemsServerInterface } from '../Item/ItemsServerInterface'
|
||||
import { SyncSource } from '../Sync/SyncSource'
|
||||
import { IntegrityApiInterface } from './IntegrityApiInterface'
|
||||
import { IntegrityService } from './IntegrityService'
|
||||
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
|
||||
import { IntegrityPayload } from '@standardnotes/responses'
|
||||
|
||||
describe('IntegrityService', () => {
|
||||
let integrityApi: IntegrityApiInterface
|
||||
let itemApi: ItemsServerInterface
|
||||
let payloadManager: PayloadManagerInterface
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
|
||||
const createService = () => new IntegrityService(integrityApi, itemApi, payloadManager, internalEventBus)
|
||||
|
||||
beforeEach(() => {
|
||||
integrityApi = {} as jest.Mocked<IntegrityApiInterface>
|
||||
integrityApi.checkIntegrity = jest.fn()
|
||||
|
||||
itemApi = {} as jest.Mocked<ItemsServerInterface>
|
||||
itemApi.getSingleItem = jest.fn()
|
||||
|
||||
payloadManager = {} as jest.Mocked<PayloadManagerInterface>
|
||||
payloadManager.integrityPayloads = []
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publishSync = jest.fn()
|
||||
})
|
||||
|
||||
it('should check integrity of payloads and publish mismatches', async () => {
|
||||
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
mismatches: [{ uuid: '1-2-3', updated_at_timestamp: 234 } as IntegrityPayload],
|
||||
},
|
||||
})
|
||||
itemApi.getSingleItem = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
item: {
|
||||
uuid: '1-2-3',
|
||||
content: 'foobar',
|
||||
} as Partial<TransferPayload>,
|
||||
},
|
||||
})
|
||||
|
||||
await createService().handleEvent({
|
||||
type: SyncEvent.SyncRequestsIntegrityCheck,
|
||||
payload: {
|
||||
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
|
||||
source: SyncSource.AfterDownloadFirst,
|
||||
},
|
||||
})
|
||||
|
||||
expect(internalEventBus.publishSync).toHaveBeenCalledWith(
|
||||
{
|
||||
payload: {
|
||||
rawPayloads: [
|
||||
{
|
||||
content: 'foobar',
|
||||
uuid: '1-2-3',
|
||||
},
|
||||
],
|
||||
source: 5,
|
||||
},
|
||||
type: 'IntegrityCheckCompleted',
|
||||
},
|
||||
'SEQUENCE',
|
||||
)
|
||||
})
|
||||
|
||||
it('should publish empty mismatches if everything is in sync', async () => {
|
||||
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
mismatches: [],
|
||||
},
|
||||
})
|
||||
|
||||
await createService().handleEvent({
|
||||
type: SyncEvent.SyncRequestsIntegrityCheck,
|
||||
payload: {
|
||||
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
|
||||
source: SyncSource.AfterDownloadFirst,
|
||||
},
|
||||
})
|
||||
|
||||
expect(internalEventBus.publishSync).toHaveBeenCalledWith(
|
||||
{
|
||||
payload: {
|
||||
rawPayloads: [],
|
||||
source: 5,
|
||||
},
|
||||
type: 'IntegrityCheckCompleted',
|
||||
},
|
||||
'SEQUENCE',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not publish mismatches if checking integrity fails', async () => {
|
||||
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
|
||||
error: 'Ooops',
|
||||
})
|
||||
|
||||
await createService().handleEvent({
|
||||
type: SyncEvent.SyncRequestsIntegrityCheck,
|
||||
payload: {
|
||||
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
|
||||
source: SyncSource.AfterDownloadFirst,
|
||||
},
|
||||
})
|
||||
|
||||
expect(internalEventBus.publishSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish empty mismatches if fetching items fails', async () => {
|
||||
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
|
||||
data: {
|
||||
mismatches: [{ uuid: '1-2-3', updated_at_timestamp: 234 } as IntegrityPayload],
|
||||
},
|
||||
})
|
||||
itemApi.getSingleItem = jest.fn().mockReturnValue({
|
||||
error: 'Ooops',
|
||||
})
|
||||
|
||||
await createService().handleEvent({
|
||||
type: SyncEvent.SyncRequestsIntegrityCheck,
|
||||
payload: {
|
||||
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
|
||||
source: SyncSource.AfterDownloadFirst,
|
||||
},
|
||||
})
|
||||
|
||||
expect(internalEventBus.publishSync).toHaveBeenCalledWith(
|
||||
{
|
||||
payload: {
|
||||
rawPayloads: [],
|
||||
source: 5,
|
||||
},
|
||||
type: 'IntegrityCheckCompleted',
|
||||
},
|
||||
'SEQUENCE',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not handle different event types', async () => {
|
||||
await createService().handleEvent({
|
||||
type: SyncEvent.SyncCompletedWithAllItemsUploaded,
|
||||
payload: {
|
||||
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
|
||||
source: SyncSource.AfterDownloadFirst,
|
||||
},
|
||||
})
|
||||
|
||||
expect(integrityApi.checkIntegrity).not.toHaveBeenCalled()
|
||||
expect(itemApi.getSingleItem).not.toHaveBeenCalled()
|
||||
expect(internalEventBus.publishSync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
62
packages/services/src/Domain/Integrity/IntegrityService.ts
Normal file
62
packages/services/src/Domain/Integrity/IntegrityService.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { IntegrityEvent } from './IntegrityEvent'
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { ItemsServerInterface } from '../Item/ItemsServerInterface'
|
||||
import { IntegrityApiInterface } from './IntegrityApiInterface'
|
||||
import { GetSingleItemResponse } from '@standardnotes/responses'
|
||||
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
|
||||
import { InternalEventInterface } from '../Internal/InternalEventInterface'
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { SyncEvent } from '../Event/SyncEvent'
|
||||
import { IntegrityEventPayload } from './IntegrityEventPayload'
|
||||
import { SyncSource } from '../Sync/SyncSource'
|
||||
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
|
||||
|
||||
export class IntegrityService
|
||||
extends AbstractService<IntegrityEvent, IntegrityEventPayload>
|
||||
implements InternalEventHandlerInterface
|
||||
{
|
||||
constructor(
|
||||
private integrityApi: IntegrityApiInterface,
|
||||
private itemApi: ItemsServerInterface,
|
||||
private payloadManager: PayloadManagerInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
}
|
||||
|
||||
async handleEvent(event: InternalEventInterface): Promise<void> {
|
||||
if (event.type !== SyncEvent.SyncRequestsIntegrityCheck) {
|
||||
return
|
||||
}
|
||||
|
||||
const integrityCheckResponse = await this.integrityApi.checkIntegrity(this.payloadManager.integrityPayloads)
|
||||
if (integrityCheckResponse.error !== undefined) {
|
||||
this.log(`Could not obtain integrity check: ${integrityCheckResponse.error}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const serverItemResponsePromises: Promise<GetSingleItemResponse>[] = []
|
||||
for (const mismatch of integrityCheckResponse.data.mismatches) {
|
||||
serverItemResponsePromises.push(this.itemApi.getSingleItem(mismatch.uuid))
|
||||
}
|
||||
|
||||
const serverItemResponses = await Promise.all(serverItemResponsePromises)
|
||||
|
||||
const rawPayloads = []
|
||||
for (const serverItemResponse of serverItemResponses) {
|
||||
if (serverItemResponse.data === undefined || serverItemResponse.error || !('item' in serverItemResponse.data)) {
|
||||
this.log(`Could not obtain item for integrity adjustments: ${serverItemResponse.error}`)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
rawPayloads.push(serverItemResponse.data.item)
|
||||
}
|
||||
|
||||
await this.notifyEventSync(IntegrityEvent.IntegrityCheckCompleted, {
|
||||
rawPayloads: rawPayloads,
|
||||
source: (event.payload as { source: SyncSource }).source,
|
||||
})
|
||||
}
|
||||
}
|
||||
117
packages/services/src/Domain/Internal/InternalEventBus.spec.ts
Normal file
117
packages/services/src/Domain/Internal/InternalEventBus.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { InternalEventHandlerInterface } from './InternalEventHandlerInterface'
|
||||
import { InternalEventBus } from './InternalEventBus'
|
||||
import { InternalEventPublishStrategy } from './InternalEventPublishStrategy'
|
||||
|
||||
describe('InternalEventBus', () => {
|
||||
let eventHandler1: InternalEventHandlerInterface
|
||||
let eventHandler2: InternalEventHandlerInterface
|
||||
let eventHandler3: InternalEventHandlerInterface
|
||||
|
||||
const createEventBus = () => new InternalEventBus()
|
||||
|
||||
beforeEach(() => {
|
||||
eventHandler1 = {} as jest.Mocked<InternalEventHandlerInterface>
|
||||
eventHandler1.handleEvent = jest.fn()
|
||||
|
||||
eventHandler2 = {} as jest.Mocked<InternalEventHandlerInterface>
|
||||
eventHandler2.handleEvent = jest.fn()
|
||||
|
||||
eventHandler3 = {} as jest.Mocked<InternalEventHandlerInterface>
|
||||
eventHandler3.handleEvent = jest.fn()
|
||||
})
|
||||
|
||||
it('should trigger appropriate event handlers upon event publishing', () => {
|
||||
const eventBus = createEventBus()
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_1')
|
||||
eventBus.addEventHandler(eventHandler2, 'test_event_2')
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_3')
|
||||
eventBus.addEventHandler(eventHandler3, 'test_event_2')
|
||||
|
||||
eventBus.publish({ type: 'test_event_2', payload: { foo: 'bar' } })
|
||||
|
||||
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler2.handleEvent).toHaveBeenCalledWith({
|
||||
type: 'test_event_2',
|
||||
payload: { foo: 'bar' },
|
||||
})
|
||||
expect(eventHandler3.handleEvent).toHaveBeenCalledWith({
|
||||
type: 'test_event_2',
|
||||
payload: { foo: 'bar' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should do nothing if there are no appropriate event handlers', () => {
|
||||
const eventBus = createEventBus()
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_1')
|
||||
eventBus.addEventHandler(eventHandler2, 'test_event_2')
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_3')
|
||||
eventBus.addEventHandler(eventHandler3, 'test_event_2')
|
||||
|
||||
eventBus.publish({ type: 'test_event_4', payload: { foo: 'bar' } })
|
||||
|
||||
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler2.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler3.handleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle event synchronously in a sequential order', async () => {
|
||||
const eventBus = createEventBus()
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_1')
|
||||
eventBus.addEventHandler(eventHandler2, 'test_event_2')
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_3')
|
||||
eventBus.addEventHandler(eventHandler3, 'test_event_2')
|
||||
|
||||
await eventBus.publishSync({ type: 'test_event_2', payload: { foo: 'bar' } }, InternalEventPublishStrategy.SEQUENCE)
|
||||
|
||||
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler2.handleEvent).toHaveBeenCalledWith({
|
||||
type: 'test_event_2',
|
||||
payload: { foo: 'bar' },
|
||||
})
|
||||
expect(eventHandler3.handleEvent).toHaveBeenCalledWith({
|
||||
type: 'test_event_2',
|
||||
payload: { foo: 'bar' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle event synchronously in a random order', async () => {
|
||||
const eventBus = createEventBus()
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_1')
|
||||
eventBus.addEventHandler(eventHandler2, 'test_event_2')
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_3')
|
||||
eventBus.addEventHandler(eventHandler3, 'test_event_2')
|
||||
|
||||
await eventBus.publishSync({ type: 'test_event_2', payload: { foo: 'bar' } }, InternalEventPublishStrategy.ASYNC)
|
||||
|
||||
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler2.handleEvent).toHaveBeenCalledWith({
|
||||
type: 'test_event_2',
|
||||
payload: { foo: 'bar' },
|
||||
})
|
||||
expect(eventHandler3.handleEvent).toHaveBeenCalledWith({
|
||||
type: 'test_event_2',
|
||||
payload: { foo: 'bar' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should do nothing if there are no appropriate event handlers for synchronous handling', async () => {
|
||||
const eventBus = createEventBus()
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_1')
|
||||
eventBus.addEventHandler(eventHandler2, 'test_event_2')
|
||||
eventBus.addEventHandler(eventHandler1, 'test_event_3')
|
||||
eventBus.addEventHandler(eventHandler3, 'test_event_2')
|
||||
|
||||
await eventBus.publishSync({ type: 'test_event_4', payload: { foo: 'bar' } }, InternalEventPublishStrategy.ASYNC)
|
||||
|
||||
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler2.handleEvent).not.toHaveBeenCalled()
|
||||
expect(eventHandler3.handleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear event observers on deinit', async () => {
|
||||
const eventBus = createEventBus()
|
||||
eventBus.deinit()
|
||||
|
||||
expect(eventBus['eventHandlers']).toBeUndefined
|
||||
})
|
||||
})
|
||||
61
packages/services/src/Domain/Internal/InternalEventBus.ts
Normal file
61
packages/services/src/Domain/Internal/InternalEventBus.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { InternalEventBusInterface } from './InternalEventBusInterface'
|
||||
import { InternalEventHandlerInterface } from './InternalEventHandlerInterface'
|
||||
import { InternalEventInterface } from './InternalEventInterface'
|
||||
import { InternalEventPublishStrategy } from './InternalEventPublishStrategy'
|
||||
import { InternalEventType } from './InternalEventType'
|
||||
|
||||
export class InternalEventBus implements InternalEventBusInterface {
|
||||
private eventHandlers: Map<InternalEventType, InternalEventHandlerInterface[]>
|
||||
|
||||
constructor() {
|
||||
this.eventHandlers = new Map<InternalEventType, InternalEventHandlerInterface[]>()
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
;(this.eventHandlers as unknown) = undefined
|
||||
}
|
||||
|
||||
addEventHandler(handler: InternalEventHandlerInterface, eventType: string): void {
|
||||
let handlersForEventType = this.eventHandlers.get(eventType)
|
||||
if (handlersForEventType === undefined) {
|
||||
handlersForEventType = []
|
||||
}
|
||||
|
||||
handlersForEventType.push(handler)
|
||||
|
||||
this.eventHandlers.set(eventType, handlersForEventType)
|
||||
}
|
||||
|
||||
publish(event: InternalEventInterface): void {
|
||||
const handlersForEventType = this.eventHandlers.get(event.type)
|
||||
if (handlersForEventType === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const handlerForEventType of handlersForEventType) {
|
||||
void handlerForEventType.handleEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
async publishSync(event: InternalEventInterface, strategy: InternalEventPublishStrategy): Promise<void> {
|
||||
const handlersForEventType = this.eventHandlers.get(event.type)
|
||||
if (handlersForEventType === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (strategy === InternalEventPublishStrategy.SEQUENCE) {
|
||||
for (const handlerForEventType of handlersForEventType) {
|
||||
await handlerForEventType.handleEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
if (strategy === InternalEventPublishStrategy.ASYNC) {
|
||||
const handlerPromises = []
|
||||
for (const handlerForEventType of handlersForEventType) {
|
||||
handlerPromises.push(handlerForEventType.handleEvent(event))
|
||||
}
|
||||
|
||||
await Promise.all(handlerPromises)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { InternalEventInterface } from './InternalEventInterface'
|
||||
import { InternalEventType } from './InternalEventType'
|
||||
import { InternalEventHandlerInterface } from './InternalEventHandlerInterface'
|
||||
import { InternalEventPublishStrategy } from '..'
|
||||
|
||||
export interface InternalEventBusInterface {
|
||||
/**
|
||||
* Associate an event handler with a certain event type
|
||||
* @param handler event handler instance
|
||||
* @param eventType event type to associate with
|
||||
*/
|
||||
addEventHandler(handler: InternalEventHandlerInterface, eventType: InternalEventType): void
|
||||
/**
|
||||
* Asynchronously publish an event for handling
|
||||
* @param event internal event object
|
||||
*/
|
||||
publish(event: InternalEventInterface): void
|
||||
/**
|
||||
* Synchronously publish an event for handling.
|
||||
* This will await for all handlers to finish processing the event.
|
||||
* @param event internal event object
|
||||
* @param strategy strategy with which the handlers will process the event.
|
||||
* Either all handlers will start at once or they will do it sequentially.
|
||||
*/
|
||||
publishSync(event: InternalEventInterface, strategy: InternalEventPublishStrategy): Promise<void>
|
||||
|
||||
deinit(): void
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InternalEventInterface } from './InternalEventInterface'
|
||||
|
||||
export interface InternalEventHandlerInterface {
|
||||
handleEvent(event: InternalEventInterface): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { InternalEventType } from './InternalEventType'
|
||||
|
||||
export interface InternalEventInterface {
|
||||
type: InternalEventType
|
||||
payload: unknown
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum InternalEventPublishStrategy {
|
||||
ASYNC = 'ASYNC',
|
||||
SEQUENCE = 'SEQUENCE',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type InternalEventType = string
|
||||
133
packages/services/src/Domain/Item/ItemManagerInterface.ts
Normal file
133
packages/services/src/Domain/Item/ItemManagerInterface.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import {
|
||||
MutationType,
|
||||
ItemsKeyInterface,
|
||||
ItemsKeyMutatorInterface,
|
||||
DecryptedItemInterface,
|
||||
DecryptedItemMutator,
|
||||
DecryptedPayloadInterface,
|
||||
PayloadEmitSource,
|
||||
EncryptedItemInterface,
|
||||
DeletedItemInterface,
|
||||
ItemContent,
|
||||
PredicateInterface,
|
||||
} from '@standardnotes/models'
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
|
||||
export type ItemManagerChangeData<I extends DecryptedItemInterface = DecryptedItemInterface> = {
|
||||
/** The items are pre-existing but have been changed */
|
||||
changed: I[]
|
||||
|
||||
/** The items have been newly inserted */
|
||||
inserted: I[]
|
||||
|
||||
/** The items should no longer be displayed in the interface, either due to being deleted, or becoming error-encrypted */
|
||||
removed: (EncryptedItemInterface | DeletedItemInterface)[]
|
||||
|
||||
/** Items for which encrypted overwrite protection is enabled and enacted */
|
||||
ignored: EncryptedItemInterface[]
|
||||
|
||||
/** Items which were previously error decrypting but now successfully decrypted */
|
||||
unerrored: I[]
|
||||
|
||||
source: PayloadEmitSource
|
||||
sourceKey?: string
|
||||
}
|
||||
|
||||
export type ItemManagerChangeObserverCallback<I extends DecryptedItemInterface = DecryptedItemInterface> = (
|
||||
data: ItemManagerChangeData<I>,
|
||||
) => void
|
||||
|
||||
export interface ItemManagerInterface extends AbstractService {
|
||||
addObserver<I extends DecryptedItemInterface = DecryptedItemInterface>(
|
||||
contentType: ContentType | ContentType[],
|
||||
callback: ItemManagerChangeObserverCallback<I>,
|
||||
): () => void
|
||||
|
||||
/**
|
||||
* Marks the item as deleted and needing sync.
|
||||
*/
|
||||
setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise<void>
|
||||
|
||||
setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise<void>
|
||||
|
||||
setItemsDirty(
|
||||
itemsToLookupUuidsFor: DecryptedItemInterface[],
|
||||
isUserModified?: boolean,
|
||||
): Promise<DecryptedItemInterface[]>
|
||||
|
||||
get items(): DecryptedItemInterface[]
|
||||
|
||||
/**
|
||||
* Inserts the item as-is by reading its payload value. This function will not
|
||||
* modify item in any way (such as marking it as dirty). It is up to the caller
|
||||
* to pass in a dirtied item if that is their intention.
|
||||
*/
|
||||
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
|
||||
|
||||
emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface>
|
||||
|
||||
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
|
||||
|
||||
/**
|
||||
* Returns all non-deleted items keys
|
||||
*/
|
||||
getDisplayableItemsKeys(): ItemsKeyInterface[]
|
||||
|
||||
/**
|
||||
* Creates an item and conditionally maps it and marks it as dirty.
|
||||
* @param needsSync - Whether to mark the item as needing sync
|
||||
*/
|
||||
createItem<T extends DecryptedItemInterface, C extends ItemContent = ItemContent>(
|
||||
contentType: ContentType,
|
||||
content: C,
|
||||
needsSync?: boolean,
|
||||
): Promise<T>
|
||||
|
||||
/**
|
||||
* Create an unmanaged item that can later be inserted via `insertItem`
|
||||
*/
|
||||
createTemplateItem<
|
||||
C extends ItemContent = ItemContent,
|
||||
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
|
||||
>(
|
||||
contentType: ContentType,
|
||||
content?: C,
|
||||
): I
|
||||
|
||||
/**
|
||||
* Consumers wanting to modify an item should run it through this block,
|
||||
* so that data is properly mapped through our function, and latest state
|
||||
* is properly reconciled.
|
||||
*/
|
||||
changeItem<
|
||||
M extends DecryptedItemMutator = DecryptedItemMutator,
|
||||
I extends DecryptedItemInterface = DecryptedItemInterface,
|
||||
>(
|
||||
itemToLookupUuidFor: I,
|
||||
mutate?: (mutator: M) => void,
|
||||
mutationType?: MutationType,
|
||||
emitSource?: PayloadEmitSource,
|
||||
payloadSourceKey?: string,
|
||||
): Promise<I>
|
||||
|
||||
changeItemsKey(
|
||||
itemToLookupUuidFor: ItemsKeyInterface,
|
||||
mutate: (mutator: ItemsKeyMutatorInterface) => void,
|
||||
mutationType?: MutationType,
|
||||
emitSource?: PayloadEmitSource,
|
||||
payloadSourceKey?: string,
|
||||
): Promise<ItemsKeyInterface>
|
||||
|
||||
itemsMatchingPredicate<T extends DecryptedItemInterface>(
|
||||
contentType: ContentType,
|
||||
predicate: PredicateInterface<T>,
|
||||
): T[]
|
||||
|
||||
itemsMatchingPredicates<T extends DecryptedItemInterface>(
|
||||
contentType: ContentType,
|
||||
predicates: PredicateInterface<T>[],
|
||||
): T[]
|
||||
|
||||
subItemsMatchingPredicates<T extends DecryptedItemInterface>(items: T[], predicates: PredicateInterface<T>[]): T[]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { GetSingleItemResponse } from '@standardnotes/responses'
|
||||
|
||||
export interface ItemsServerInterface {
|
||||
getSingleItem(itemUuid: Uuid): Promise<GetSingleItemResponse>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
PayloadInterface,
|
||||
EncryptedPayloadInterface,
|
||||
FullyFormedPayloadInterface,
|
||||
PayloadEmitSource,
|
||||
} from '@standardnotes/models'
|
||||
import { IntegrityPayload } from '@standardnotes/responses'
|
||||
|
||||
export interface PayloadManagerInterface {
|
||||
emitPayloads(
|
||||
payloads: PayloadInterface[],
|
||||
emitSource: PayloadEmitSource,
|
||||
sourceKey?: string,
|
||||
): Promise<PayloadInterface[]>
|
||||
|
||||
integrityPayloads: IntegrityPayload[]
|
||||
|
||||
get invalidPayloads(): EncryptedPayloadInterface[]
|
||||
|
||||
/**
|
||||
* Returns a detached array of all items which are not deleted
|
||||
*/
|
||||
get nonDeletedItems(): FullyFormedPayloadInterface[]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrefKey, PrefValue } from '@standardnotes/models'
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum PreferencesServiceEvent {
|
||||
PreferencesChanged = 'PreferencesChanged',
|
||||
}
|
||||
|
||||
export interface PreferenceServiceInterface extends AbstractService<PreferencesServiceEvent> {
|
||||
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K] | undefined): PrefValue[K] | undefined
|
||||
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K]): PrefValue[K]
|
||||
getValue<K extends PrefKey>(key: K, defaultValue?: PrefValue[K]): PrefValue[K] | undefined
|
||||
setValue<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void>
|
||||
}
|
||||
108
packages/services/src/Domain/Service/AbstractService.ts
Normal file
108
packages/services/src/Domain/Service/AbstractService.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { log, removeFromArray } from '@standardnotes/utils'
|
||||
import { EventObserver } from '../Event/EventObserver'
|
||||
import { ServiceInterface } from './ServiceInterface'
|
||||
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
|
||||
import { ApplicationStage } from '../Application/ApplicationStage'
|
||||
import { InternalEventPublishStrategy } from '../Internal/InternalEventPublishStrategy'
|
||||
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
|
||||
|
||||
export abstract class AbstractService<EventName = string, EventData = undefined>
|
||||
implements ServiceInterface<EventName, EventData>
|
||||
{
|
||||
private eventObservers: EventObserver<EventName, EventData>[] = []
|
||||
public loggingEnabled = false
|
||||
private criticalPromises: Promise<unknown>[] = []
|
||||
|
||||
constructor(protected internalEventBus: InternalEventBusInterface) {}
|
||||
|
||||
public addEventObserver(observer: EventObserver<EventName, EventData>): () => void {
|
||||
this.eventObservers.push(observer)
|
||||
|
||||
const thislessEventObservers = this.eventObservers
|
||||
return () => {
|
||||
removeFromArray(thislessEventObservers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
protected async notifyEvent(eventName: EventName, data?: EventData): Promise<void> {
|
||||
for (const observer of this.eventObservers) {
|
||||
await observer(eventName, data)
|
||||
}
|
||||
|
||||
this.internalEventBus?.publish({
|
||||
type: eventName as unknown as string,
|
||||
payload: data,
|
||||
})
|
||||
}
|
||||
|
||||
protected async notifyEventSync(eventName: EventName, data?: EventData): Promise<void> {
|
||||
for (const observer of this.eventObservers) {
|
||||
await observer(eventName, data)
|
||||
}
|
||||
|
||||
await this.internalEventBus?.publishSync(
|
||||
{
|
||||
type: eventName as unknown as string,
|
||||
payload: data,
|
||||
},
|
||||
InternalEventPublishStrategy.SEQUENCE,
|
||||
)
|
||||
}
|
||||
|
||||
getDiagnostics(): Promise<DiagnosticInfo | undefined> {
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by application to allow services to momentarily block deinit until
|
||||
* sensitive operations complete.
|
||||
*/
|
||||
public async blockDeinit(): Promise<void> {
|
||||
await Promise.all(this.criticalPromises)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by application before restart.
|
||||
* Subclasses should deregister any observers/timers
|
||||
*/
|
||||
public deinit(): void {
|
||||
this.eventObservers.length = 0
|
||||
;(this.internalEventBus as unknown) = undefined
|
||||
;(this.criticalPromises as unknown) = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A critical function is one that should block signing out or destroying application
|
||||
* session until the crticial function has completed. For example, persisting keys to
|
||||
* disk is a critical operation, and should be wrapped in this function call. The
|
||||
* parent application instance will await all criticial functions via the `blockDeinit`
|
||||
* function before signing out and deiniting.
|
||||
*/
|
||||
protected async executeCriticalFunction<T = void>(func: () => Promise<T>): Promise<T> {
|
||||
const promise = func()
|
||||
this.criticalPromises.push(promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Application instances will call this function directly when they arrive
|
||||
* at a certain migratory state.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public async handleApplicationStage(_stage: ApplicationStage): Promise<void> {
|
||||
// optional override
|
||||
}
|
||||
|
||||
getServiceName(): string {
|
||||
return this.constructor.name
|
||||
}
|
||||
|
||||
log(..._args: unknown[]): void {
|
||||
if (this.loggingEnabled) {
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
log(this.getServiceName(), ...arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/services/src/Domain/Service/ServiceInterface.ts
Normal file
12
packages/services/src/Domain/Service/ServiceInterface.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ApplicationStage } from '../Application/ApplicationStage'
|
||||
import { ServiceDiagnostics } from '../Diagnostics/ServiceDiagnostics'
|
||||
import { EventObserver } from '../Event/EventObserver'
|
||||
|
||||
export interface ServiceInterface<E, D> extends ServiceDiagnostics {
|
||||
loggingEnabled: boolean
|
||||
addEventObserver(observer: EventObserver<E, D>): () => void
|
||||
blockDeinit(): Promise<void>
|
||||
deinit(): void
|
||||
handleApplicationStage(stage: ApplicationStage): Promise<void>
|
||||
log(message: string, ...args: unknown[]): void
|
||||
}
|
||||
62
packages/services/src/Domain/Status/StatusService.ts
Normal file
62
packages/services/src/Domain/Status/StatusService.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
import { StatusServiceEvent, StatusServiceInterface, StatusMessageIdentifier } from './StatusServiceInterface'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export class StatusService extends AbstractService<StatusServiceEvent, string> implements StatusServiceInterface {
|
||||
private _message = ''
|
||||
private directSetMessage?: string
|
||||
private dynamicMessages: string[] = []
|
||||
|
||||
get message(): string {
|
||||
return this._message
|
||||
}
|
||||
|
||||
setMessage(message: string | undefined): void {
|
||||
this.directSetMessage = message
|
||||
this.recomputeMessage()
|
||||
}
|
||||
|
||||
addMessage(message: string): StatusMessageIdentifier {
|
||||
this.dynamicMessages.push(message)
|
||||
|
||||
this.recomputeMessage()
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
removeMessage(message: StatusMessageIdentifier): void {
|
||||
removeFromArray(this.dynamicMessages, message)
|
||||
|
||||
this.recomputeMessage()
|
||||
}
|
||||
|
||||
private recomputeMessage(): void {
|
||||
const messages = [...this.dynamicMessages]
|
||||
|
||||
if (this.directSetMessage) {
|
||||
messages.unshift(this.directSetMessage)
|
||||
}
|
||||
|
||||
this._message = this.messageFromArray(messages)
|
||||
|
||||
void this.notifyEvent(StatusServiceEvent.MessageChanged, this._message)
|
||||
}
|
||||
|
||||
private messageFromArray(messages: string[]): string {
|
||||
let message = ''
|
||||
|
||||
messages.forEach((value, index) => {
|
||||
const isLast = index === messages.length - 1
|
||||
|
||||
message += value
|
||||
|
||||
if (!isLast) {
|
||||
message += ', '
|
||||
}
|
||||
})
|
||||
|
||||
return message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AbstractService } from '../Service/AbstractService'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum StatusServiceEvent {
|
||||
MessageChanged = 'MessageChanged',
|
||||
}
|
||||
|
||||
export type StatusMessageIdentifier = string
|
||||
|
||||
export interface StatusServiceInterface extends AbstractService<StatusServiceEvent, string> {
|
||||
get message(): string
|
||||
setMessage(message: string | undefined): void
|
||||
addMessage(message: string): StatusMessageIdentifier
|
||||
removeMessage(message: StatusMessageIdentifier): void
|
||||
}
|
||||
24
packages/services/src/Domain/Storage/InMemoryStore.spec.ts
Normal file
24
packages/services/src/Domain/Storage/InMemoryStore.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { InMemoryStore } from './InMemoryStore'
|
||||
import { StorageKey } from './StorageKeys'
|
||||
|
||||
describe('InMemoryStore', () => {
|
||||
const createStore = () => new InMemoryStore()
|
||||
|
||||
it('should set and retrieve a value', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.setValue(StorageKey.CodeVerifier, 'test')
|
||||
|
||||
expect(store.getValue(StorageKey.CodeVerifier)).toEqual('test')
|
||||
})
|
||||
|
||||
it('should remove a value', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.setValue(StorageKey.CodeVerifier, 'test')
|
||||
|
||||
store.removeValue(StorageKey.CodeVerifier)
|
||||
|
||||
expect(store.getValue(StorageKey.CodeVerifier)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
22
packages/services/src/Domain/Storage/InMemoryStore.ts
Normal file
22
packages/services/src/Domain/Storage/InMemoryStore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { KeyValueStoreInterface } from './KeyValueStoreInterface'
|
||||
import { StorageKey } from './StorageKeys'
|
||||
|
||||
export class InMemoryStore implements KeyValueStoreInterface<string> {
|
||||
private values: Map<StorageKey, string>
|
||||
|
||||
constructor() {
|
||||
this.values = new Map<StorageKey, string>()
|
||||
}
|
||||
|
||||
setValue(key: StorageKey, value: string): void {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
|
||||
getValue(key: StorageKey): string | undefined {
|
||||
return this.values.get(key)
|
||||
}
|
||||
|
||||
removeValue(key: StorageKey): void {
|
||||
this.values.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { StorageKey } from './StorageKeys'
|
||||
|
||||
export interface KeyValueStoreInterface<T> {
|
||||
setValue(key: StorageKey, value: T): void
|
||||
getValue(key: StorageKey): T | undefined
|
||||
removeValue(key: StorageKey): void
|
||||
}
|
||||
7
packages/services/src/Domain/Storage/StorageKeys.spec.ts
Normal file
7
packages/services/src/Domain/Storage/StorageKeys.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { namespacedKey } from './StorageKeys'
|
||||
|
||||
describe('StorageKeys', () => {
|
||||
it('namespacedKey', () => {
|
||||
expect(namespacedKey('namespace', 'key')).toEqual('namespace-key')
|
||||
})
|
||||
})
|
||||
66
packages/services/src/Domain/Storage/StorageKeys.ts
Normal file
66
packages/services/src/Domain/Storage/StorageKeys.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Unmanaged keys stored in root storage.
|
||||
* Raw storage keys exist outside of StorageManager domain
|
||||
*/
|
||||
export enum RawStorageKey {
|
||||
StorageObject = 'storage',
|
||||
DescriptorRecord = 'descriptors',
|
||||
SnjsVersion = 'snjs_version',
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys used for retrieving and saving simple key/value pairs.
|
||||
* These keys are managed and are embedded inside RawStorageKey.StorageObject
|
||||
*/
|
||||
export enum StorageKey {
|
||||
RootKeyParams = 'ROOT_KEY_PARAMS',
|
||||
WrappedRootKey = 'WRAPPED_ROOT_KEY',
|
||||
RootKeyWrapperKeyParams = 'ROOT_KEY_WRAPPER_KEY_PARAMS',
|
||||
Session = 'session',
|
||||
User = 'user',
|
||||
ServerHost = 'server',
|
||||
LegacyUuid = 'uuid',
|
||||
LastSyncToken = 'syncToken',
|
||||
PaginationToken = 'cursorToken',
|
||||
BiometricsState = 'biometrics_state',
|
||||
MobilePasscodeTiming = 'passcode_timing',
|
||||
MobileBiometricsTiming = 'biometrics_timing',
|
||||
MobilePasscodeKeyboardType = 'passcodeKeyboardType',
|
||||
MobilePreferences = 'preferences',
|
||||
MobileScreenshotPrivacyEnabled = 'screenshotPrivacy_enabled',
|
||||
ProtectionExpirey = 'SessionExpiresAtKey',
|
||||
ProtectionSessionLength = 'SessionLengthKey',
|
||||
KeyRecoveryUndecryptableItems = 'key_recovery_undecryptable',
|
||||
StorageEncryptionPolicy = 'storage_policy',
|
||||
WebSocketUrl = 'webSocket_url',
|
||||
UserRoles = 'user_roles',
|
||||
UserFeatures = 'user_features',
|
||||
ExperimentalFeatures = 'experimental_features',
|
||||
DeinitMode = 'deinit_mode',
|
||||
CodeVerifier = 'code_verifier',
|
||||
}
|
||||
|
||||
export enum NonwrappedStorageKey {
|
||||
MobileFirstRun = 'first_run',
|
||||
}
|
||||
|
||||
export function namespacedKey(namespace: string, key: string) {
|
||||
return `${namespace}-${key}`
|
||||
}
|
||||
|
||||
export const LegacyKeys1_0_0 = {
|
||||
WebPasscodeParamsKey: 'offlineParams',
|
||||
MobilePasscodeParamsKey: 'pc_params',
|
||||
AllAccountKeyParamsKey: 'auth_params',
|
||||
WebEncryptedStorageKey: 'encryptedStorage',
|
||||
MobileWrappedRootKeyKey: 'encrypted_account_keys',
|
||||
MobileBiometricsPrefs: 'biometrics_prefs',
|
||||
AllMigrations: 'migrations',
|
||||
MobileThemesCache: 'ThemePreferencesKey',
|
||||
MobileLightTheme: 'lightTheme',
|
||||
MobileDarkTheme: 'darkTheme',
|
||||
MobileLastExportDate: 'LastExportDateKey',
|
||||
MobileDoNotWarnUnsupportedEditors: 'DoNotShowAgainUnsupportedEditorsKey',
|
||||
MobileOptionsState: 'options',
|
||||
MobilePasscodeKeyboardType: 'passcodeKeyboardType',
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PayloadInterface, RootKeyInterface } from '@standardnotes/models'
|
||||
import { StorageValueModes } from './StorageTypes'
|
||||
|
||||
export interface StorageServiceInterface {
|
||||
getValue<T>(key: string, mode?: StorageValueModes, defaultValue?: T): T
|
||||
|
||||
canDecryptWithKey(key: RootKeyInterface): Promise<boolean>
|
||||
|
||||
savePayload(payload: PayloadInterface): Promise<void>
|
||||
|
||||
savePayloads(decryptedPayloads: PayloadInterface[]): Promise<void>
|
||||
|
||||
setValue(key: string, value: unknown, mode?: StorageValueModes): void
|
||||
|
||||
removeValue(key: string, mode?: StorageValueModes): Promise<void>
|
||||
}
|
||||
39
packages/services/src/Domain/Storage/StorageTypes.ts
Normal file
39
packages/services/src/Domain/Storage/StorageTypes.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { LocalStorageEncryptedContextualPayload, LocalStorageDecryptedContextualPayload } from '@standardnotes/models'
|
||||
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum StoragePersistencePolicies {
|
||||
Default = 1,
|
||||
Ephemeral = 2,
|
||||
}
|
||||
|
||||
export enum StorageEncryptionPolicy {
|
||||
Default = 1,
|
||||
Disabled = 2,
|
||||
}
|
||||
|
||||
export enum StorageValueModes {
|
||||
/** Stored inside wrapped encrpyed storage object */
|
||||
Default = 1,
|
||||
/** Stored outside storage object, unencrypted */
|
||||
Nonwrapped = 2,
|
||||
}
|
||||
|
||||
export enum ValueModesKeys {
|
||||
/* Is encrypted */
|
||||
Wrapped = 'wrapped',
|
||||
/* Is decrypted */
|
||||
Unwrapped = 'unwrapped',
|
||||
/* Lives outside of wrapped/unwrapped */
|
||||
Nonwrapped = 'nonwrapped',
|
||||
}
|
||||
|
||||
export type ValuesObjectRecord = Record<string, unknown>
|
||||
|
||||
export type WrappedStorageValue = LocalStorageEncryptedContextualPayload | LocalStorageDecryptedContextualPayload
|
||||
|
||||
export type StorageValuesObject = {
|
||||
[ValueModesKeys.Wrapped]: WrappedStorageValue
|
||||
[ValueModesKeys.Unwrapped]: ValuesObjectRecord
|
||||
[ValueModesKeys.Nonwrapped]: ValuesObjectRecord
|
||||
}
|
||||
14
packages/services/src/Domain/Sync/SyncMode.ts
Normal file
14
packages/services/src/Domain/Sync/SyncMode.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum SyncMode {
|
||||
/**
|
||||
* Performs a standard sync, uploading any dirty items and retrieving items.
|
||||
*/
|
||||
Default = 1,
|
||||
/**
|
||||
* The first sync for an account, where we first want to download all remote items first
|
||||
* before uploading any dirty items. This allows a consumer, for example, to download
|
||||
* all data to see if user has an items key, and if not, only then create a new one.
|
||||
*/
|
||||
DownloadFirst = 2,
|
||||
}
|
||||
21
packages/services/src/Domain/Sync/SyncOptions.ts
Normal file
21
packages/services/src/Domain/Sync/SyncOptions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { SyncMode } from './SyncMode'
|
||||
import { SyncQueueStrategy } from './SyncQueueStrategy'
|
||||
import { SyncSource } from './SyncSource'
|
||||
|
||||
export type SyncOptions = {
|
||||
queueStrategy?: SyncQueueStrategy
|
||||
mode?: SyncMode
|
||||
/** Whether the server should compute and return an integrity hash. */
|
||||
checkIntegrity?: boolean
|
||||
/** Internally used to keep track of how sync requests were spawned. */
|
||||
source: SyncSource
|
||||
/** Whether to await any sync requests that may be queued from this call. */
|
||||
awaitAll?: boolean
|
||||
/**
|
||||
* A callback that is triggered after pre-sync save completes,
|
||||
* and before the sync request is network dispatched
|
||||
*/
|
||||
onPresyncSave?: () => void
|
||||
}
|
||||
14
packages/services/src/Domain/Sync/SyncQueueStrategy.ts
Normal file
14
packages/services/src/Domain/Sync/SyncQueueStrategy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum SyncQueueStrategy {
|
||||
/**
|
||||
* Promise will be resolved on the next sync request after the current one completes.
|
||||
* If there is no scheduled sync request, one will be scheduled.
|
||||
*/
|
||||
ResolveOnNext = 1,
|
||||
/**
|
||||
* A new sync request is guarenteed to be generated for your request, no matter how long it takes.
|
||||
* Promise will be resolved whenever this sync request is processed in the serial queue.
|
||||
*/
|
||||
ForceSpawnNew = 2,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
import { SyncOptions } from './SyncOptions'
|
||||
|
||||
export interface SyncServiceInterface {
|
||||
sync(options?: Partial<SyncOptions>): Promise<unknown>
|
||||
}
|
||||
11
packages/services/src/Domain/Sync/SyncSource.ts
Normal file
11
packages/services/src/Domain/Sync/SyncSource.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/* istanbul ignore file */
|
||||
|
||||
export enum SyncSource {
|
||||
External = 1,
|
||||
SpawnQueue = 2,
|
||||
ResolveQueue = 3,
|
||||
MoreDirtyItems = 4,
|
||||
AfterDownloadFirst = 5,
|
||||
IntegrityCheck = 6,
|
||||
ResolveOutOfSync = 7,
|
||||
}
|
||||
51
packages/services/src/Domain/index.ts
Normal file
51
packages/services/src/Domain/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export * from './Alert/AlertService'
|
||||
export * from './Api/ApiServiceInterface'
|
||||
export * from './Application/ApplicationStage'
|
||||
export * from './Application/DeinitCallback'
|
||||
export * from './Application/DeinitSource'
|
||||
export * from './Application/DeinitMode'
|
||||
export * from './Application/UserClientInterface'
|
||||
export * from './Application/ApplicationInterface'
|
||||
export * from './Challenge'
|
||||
export * from './Device/DesktopDeviceInterface'
|
||||
export * from './Device/DesktopWebCommunication'
|
||||
export * from './Device/DeviceInterface'
|
||||
export * from './Device/Environments'
|
||||
export * from './Device/FileBackupsDevice'
|
||||
export * from './Device/MobileDeviceInterface'
|
||||
export * from './Device/TypeCheck'
|
||||
export * from './Device/WebOrDesktopDeviceInterface'
|
||||
export * from './Diagnostics/ServiceDiagnostics'
|
||||
export * from './Event/EventObserver'
|
||||
export * from './Event/SyncEvent'
|
||||
export * from './Event/SyncEventReceiver'
|
||||
export * from './Files/FilesApiInterface'
|
||||
export * from './FileSystem/FileSystemApi'
|
||||
export * from './Integrity/IntegrityApiInterface'
|
||||
export * from './Integrity/IntegrityEvent'
|
||||
export * from './Integrity/IntegrityEventPayload'
|
||||
export * from './Integrity/IntegrityService'
|
||||
export * from './Internal/InternalEventBus'
|
||||
export * from './Internal/InternalEventBusInterface'
|
||||
export * from './Internal/InternalEventHandlerInterface'
|
||||
export * from './Internal/InternalEventInterface'
|
||||
export * from './Internal/InternalEventPublishStrategy'
|
||||
export * from './Internal/InternalEventType'
|
||||
export * from './Item/ItemManagerInterface'
|
||||
export * from './Item/ItemsServerInterface'
|
||||
export * from './Payloads/PayloadManagerInterface'
|
||||
export * from './Preferences/PreferenceServiceInterface'
|
||||
export * from './Service/AbstractService'
|
||||
export * from './Service/ServiceInterface'
|
||||
export * from './Status/StatusService'
|
||||
export * from './Status/StatusServiceInterface'
|
||||
export * from './Storage/StorageKeys'
|
||||
export * from './Storage/InMemoryStore'
|
||||
export * from './Storage/KeyValueStoreInterface'
|
||||
export * from './Storage/StorageServiceInterface'
|
||||
export * from './Storage/StorageTypes'
|
||||
export * from './Sync/SyncMode'
|
||||
export * from './Sync/SyncOptions'
|
||||
export * from './Sync/SyncQueueStrategy'
|
||||
export * from './Sync/SyncServiceInterface'
|
||||
export * from './Sync/SyncSource'
|
||||
1
packages/services/src/index.ts
Normal file
1
packages/services/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Domain'
|
||||
Reference in New Issue
Block a user