1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 18:33:50 +00:00

wip: working in vscode, but broken when testing types

This commit is contained in:
Matt Gibson
2025-07-09 07:06:10 -07:00
parent 8135e840ab
commit 3b5bf2abf0
20 changed files with 345 additions and 47 deletions

View File

@@ -1,5 +1,3 @@
import { Jsonify } from "type-fest";
import {
COLLECTION_DATA,
DeriveDefinition,
@@ -15,7 +13,7 @@ export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<Collection
COLLECTION_DATA,
"collections",
{
deserializer: (jsonData: Jsonify<CollectionData>) => CollectionData.fromJSON(jsonData),
deserializer: (jsonData) => CollectionData.fromJSON(jsonData!),
clearOn: ["logout"],
},
);

View File

@@ -16,11 +16,11 @@ import {
import { LoginEmailServiceAbstraction } from "../../abstractions/login-email.service";
export const LOGIN_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_MEMORY, "loginEmail", {
deserializer: (value: string) => value,
deserializer: (value: string | null) => value,
});
export const STORED_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_DISK, "storedEmail", {
deserializer: (value: string) => value,
deserializer: (value: string | null) => value,
});
export class LoginEmailService implements LoginEmailServiceAbstraction {

View File

@@ -1,5 +1,4 @@
import { map } from "rxjs";
import { Jsonify } from "type-fest";
import {
ORGANIZATION_MANAGEMENT_PREFERENCES_DISK,
@@ -20,7 +19,7 @@ import {
*/
function buildKeyDefinition<T>(key: string): UserKeyDefinition<T> {
return new UserKeyDefinition<T>(ORGANIZATION_MANAGEMENT_PREFERENCES_DISK, key, {
deserializer: (obj: Jsonify<T>) => obj as T,
deserializer: (obj) => obj! as T,
clearOn: ["logout"],
});
}

View File

@@ -1,5 +1,3 @@
import { Jsonify } from "type-fest";
import { ORGANIZATIONS_DISK, UserKeyDefinition } from "../../../platform/state";
import { OrganizationData } from "../../models/data/organization.data";
@@ -13,7 +11,7 @@ export const ORGANIZATIONS = UserKeyDefinition.record<OrganizationData>(
ORGANIZATIONS_DISK,
"organizations",
{
deserializer: (obj: Jsonify<OrganizationData>) => OrganizationData.fromJSON(obj),
deserializer: (obj) => OrganizationData.fromJSON(obj!),
clearOn: ["logout"],
},
);

View File

@@ -122,7 +122,7 @@ export class SymmetricCryptoKey {
return new SymmetricCryptoKey(arrayBuffer);
}
static fromJSON(obj: Jsonify<SymmetricCryptoKey>): SymmetricCryptoKey {
static fromJSON(obj: Jsonify<SymmetricCryptoKey> | null): SymmetricCryptoKey {
return SymmetricCryptoKey.fromString(obj?.keyB64);
}
}

View File

@@ -8,8 +8,8 @@ import { Jsonify } from "type-fest";
* @returns
*/
export function array<T>(
elementDeserializer: (element: Jsonify<T>) => T,
): (array: Jsonify<T[]>) => T[] {
elementDeserializer: (element: Jsonify<T> | null) => T | null,
): (array: Jsonify<T[]> | null) => T[] | null {
return (array) => {
if (array == null) {
return null;
@@ -24,9 +24,9 @@ export function array<T>(
* @param valueDeserializer
*/
export function record<T, TKey extends string | number = string>(
valueDeserializer: (value: Jsonify<T>) => T,
): (record: Jsonify<Record<TKey, T>>) => Record<TKey, T> {
return (jsonValue: Jsonify<Record<TKey, T> | null>) => {
valueDeserializer: (value: Jsonify<T> | null) => T | null,
): (record: Jsonify<Record<TKey, T>> | null) => Record<TKey, T> | null {
return (jsonValue: Jsonify<Record<TKey, T>> | null) => {
if (jsonValue == null) {
return null;
}

View File

@@ -1,7 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { mergeOptions, OptionsWithDefaultsDeep } from "../../types/options";
import { StorageKey } from "../../types/state";
import { array, record } from "./deserialization-helpers";
@@ -42,7 +41,7 @@ export type KeyDefinitionOptions<T> = {
* @param jsonValue The JSON object representation of your state.
* @returns The fully typed version of your state.
*/
readonly deserializer: (jsonValue: Jsonify<T>) => T | null;
readonly deserializer: (jsonValue: Jsonify<T> | null) => T | null;
/**
* The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
* Defaults to 1000ms.
@@ -55,13 +54,27 @@ export type KeyDefinitionOptions<T> = {
readonly debug?: DebugOptions;
};
const DEFAULT_KEY_DEFINITION_OPTIONS = Object.freeze({
cleanupDelayMs: 1000 as number,
debug: {
enableUpdateLogging: false,
enableRetrievalLogging: false,
},
});
/**
* KeyDefinitions describe the precise location to store data for a given piece of state.
* The StateDefinition is used to describe the domain of the state, and the KeyDefinition
* sub-divides that domain into specific keys.
*/
export class KeyDefinition<T> {
readonly debug: Required<DebugOptions>;
private readonly options: OptionsWithDefaultsDeep<
KeyDefinitionOptions<T>,
typeof DEFAULT_KEY_DEFINITION_OPTIONS
>;
get debug() {
return Object.freeze({ ...this.options.debug });
}
/**
* Creates a new instance of a KeyDefinition
@@ -75,24 +88,19 @@ export class KeyDefinition<T> {
constructor(
readonly stateDefinition: StateDefinition,
readonly key: string,
private readonly options: KeyDefinitionOptions<T>,
options: KeyDefinitionOptions<T>,
) {
if (options.deserializer == null) {
throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`);
}
if (options.cleanupDelayMs < 0) {
this.options = mergeOptions(options, DEFAULT_KEY_DEFINITION_OPTIONS);
if (this.options.cleanupDelayMs < 0) {
throw new Error(
`'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
`'cleanupDelayMs' must be greater than or equal to 0. Value of ${this.options.cleanupDelayMs} passed to key ${this.errorKeyName} `,
);
}
// Normalize optional debug options
const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {};
this.debug = {
enableUpdateLogging,
enableRetrievalLogging,
};
}
/**
@@ -106,7 +114,7 @@ export class KeyDefinition<T> {
* Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed.
*/
get cleanupDelayMs() {
return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000);
return this.options.cleanupDelayMs;
}
/**

View File

@@ -5,7 +5,7 @@ export const EVENT_COLLECTION = UserKeyDefinition.array<EventData>(
EVENT_COLLECTION_DISK,
"events",
{
deserializer: (s) => EventData.fromJSON(s),
deserializer: (s) => EventData.fromJSON(s!),
clearOn: ["logout"],
},
);

View File

@@ -7,7 +7,7 @@ export const SEND_USER_ENCRYPTED = UserKeyDefinition.record<SendData>(
SEND_DISK,
"sendUserEncrypted",
{
deserializer: (obj: SendData) => obj,
deserializer: (obj) => obj,
clearOn: ["logout"],
},
);

View File

View File

@@ -0,0 +1,100 @@
import { mergeOptions } from "./options";
type ExampleOptions = {
readonly a?: number;
readonly required_func: () => void;
readonly optional_func?: () => void;
readonly b: string;
readonly c?: {
readonly d?: number;
readonly e: string;
};
};
const EXAMPLE_DEFAULTS = Object.freeze({
a: 0,
optional_func: () => {},
c: {
d: 1,
},
f: {
h: 1,
},
});
describe("mergeOptions", () => {
it("merges options with defaults", () => {
const options: ExampleOptions = {
required_func: () => {},
b: "test",
};
const merged = mergeOptions(options, EXAMPLE_DEFAULTS);
// can access properties
expect(merged.a).toBe(42);
expect(merged).toEqual({
a: 0,
b: "test",
});
});
it("overrides defaults with options", () => {
const options: ExampleOptions = {
a: 42,
b: "test",
f: {
i: "example",
},
required_func: () => {},
};
const merged = mergeOptions(options, EXAMPLE_DEFAULTS);
// can access properties
expect(merged.a).toBe(42);
expect(merged).toEqual({
a: 42,
b: "test",
});
});
//Defaults has a required property 'a', but options does not provide it. Not an error
mergeOptions({ b: "test" } as { b: string }, { a: 0 });
//Defaults provides a required function, but options does not provide it. Not an error
mergeOptions({ b: "test" } as { b: string }, { required_func: () => {} });
//Defaults provides a required property of the wrong type. Not an error because default will never be used
mergeOptions({ a: "test" } as { a: string }, { a: 0 });
//Defaults provides a required function of the wrong type. Not an error because default will never be used
mergeOptions({ required_func: () => "" } as { required_func: () => string }, {
required_func: () => {},
});
//@ts-expect-error -- Defaults provides an optional property of the wrong type
mergeOptions({} as { a?: string }, { a: 0 });
//@ts-expect-error -- Defaults provides an optional function of the wrong type
mergeOptions({} as { optional_func?: () => string }, { optional_func: () => {} });
//@ts-expect-error -- defaults missing an property optional in options
mergeOptions({ a: 42 } as { a?: number }, {});
//@ts-expect-error -- defaults missing an method optional in options
mergeOptions({ f: () => {} } as { f?: () => void }, {});
//@ts-expect-error -- defaults missing a deep optional property defined in options
mergeOptions({ a: { required_func: () => {} } } as { a?: { required_func: () => void } }, {});
//@ts-expect-error -- defaults missing a deep optional property defined in options
mergeOptions({ a: { required_func: () => {} } } as { a: { required_func?: () => void } }, {});
//@ts-expect-error -- defaults missing a optional object defined in options
mergeOptions({} as { a?: { b: number } }, {});
//@ts-expect-error -- defaults missing a deep optional property defined in required option
mergeOptions({ a: {} } as { a: { b?: number } }, {});
//@ts-expect-error -- defaults missing a deep optional property defined in optional option
mergeOptions({ a: {} } as { a?: { b?: number } }, {});
//@ts-expect-error -- defaults missing a deep optional property defined in optional option
mergeOptions({ a: {} } as { a?: { b?: number } }, { a: {} });
});

View File

@@ -0,0 +1,186 @@
/* eslint-disable @typescript-eslint/no-empty-object-type -- used in type-fest's code*/
import { RequiredKeysOf, Simplify, Primitive } from "type-fest";
type Function = (...args: any[]) => any;
/** FIXME: this is pulled from type-fest-v4. remove when we update package */
export type ConditionalSimplifyDeep<
Type,
ExcludeType = never,
IncludeType = unknown,
> = Type extends ExcludeType
? Type
: Type extends IncludeType
? { [TypeKey in keyof Type]: ConditionalSimplifyDeep<Type[TypeKey], ExcludeType, IncludeType> }
: Type;
export type BuiltIns = Primitive | void | Date | RegExp;
export type NonRecursiveType = BuiltIns | Function | (new (...arguments_: any[]) => unknown);
export type SimplifyDeep<Type, ExcludeType = never> = ConditionalSimplifyDeep<
Type,
ExcludeType | NonRecursiveType | Set<unknown> | Map<unknown, unknown>,
object
>;
type SimplifyDeepExcludeArray<T> = SimplifyDeep<T, UnknownArray>;
export type UnknownArray = readonly unknown[];
export type UnknownRecord = Record<PropertyKey, unknown>;
export type UnknownArrayOrTuple = readonly [...unknown[]];
export type OmitIndexSignature<ObjectType> = {
[KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
? never
: KeyType]: ObjectType[KeyType];
};
export type PickIndexSignature<ObjectType> = {
[KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
? KeyType
: never]: ObjectType[KeyType];
};
type MergeDeepRecordProperty<Destination, Source> = undefined extends Source
?
| MergeDeepOrReturn<Source, Exclude<Destination, undefined>, Exclude<Source, undefined>>
| undefined
: MergeDeepOrReturn<Source, Destination, Source>;
type RequiredFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
? Type[Key] extends undefined
? Key
: never
: Key;
// Returns `never` if the key is required otherwise return the key type.
type OptionalFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
? Type[Key] extends undefined
? never
: Key
: never;
export type EnforceOptional<ObjectType> = Simplify<
{
[Key in keyof ObjectType as RequiredFilter<ObjectType, Key>]: ObjectType[Key];
} & {
[Key in keyof ObjectType as OptionalFilter<ObjectType, Key>]?: Exclude<
ObjectType[Key],
undefined
>;
}
>;
type MergeDeepOrReturn<DefaultType, Destination, Source> = SimplifyDeepExcludeArray<
[undefined] extends [Destination | Source]
? DefaultType
: Destination extends UnknownRecord
? Source extends UnknownRecord
? MergeDeepRecord<Destination, Source>
: DefaultType
: Destination extends UnknownArrayOrTuple
? Source extends UnknownArrayOrTuple
? MergeDeepArrayOrTuple<Destination, Source>
: DefaultType
: DefaultType
>;
type MergeDeepArrayOrTuple<
Destination extends UnknownArrayOrTuple,
Source extends UnknownArrayOrTuple,
> = Array<Exclude<Destination, undefined>[number] | Exclude<Source, undefined>[number]>;
type MergeDeepRecord<
Destination extends UnknownRecord,
Source extends UnknownRecord,
> = DoMergeDeepRecord<OmitIndexSignature<Destination>, OmitIndexSignature<Source>> &
Merge<PickIndexSignature<Destination>, PickIndexSignature<Source>>;
type DoMergeDeepRecord<Destination extends UnknownRecord, Source extends UnknownRecord> =
// Case in rule 1: The destination contains the key but the source doesn't.
{
[Key in keyof Destination as Key extends keyof Source ? never : Key]: Destination[Key];
} & {
// Case in rule 2: The source contains the key but the destination doesn't.
[Key in keyof Source as Key extends keyof Destination ? never : Key]: Source[Key];
} & {
// Case in rule 3: Both the source and the destination contain the key.
[Key in keyof Source as Key extends keyof Destination ? Key : never]: MergeDeepRecordProperty<
Destination[Key],
Source[Key]
>;
};
type SimpleMerge<Destination, Source> = {
[Key in keyof Destination as Key extends keyof Source ? never : Key]: Destination[Key];
} & Source;
export type Merge<Destination, Source> = Simplify<
SimpleMerge<PickIndexSignature<Destination>, PickIndexSignature<Source>> &
SimpleMerge<OmitIndexSignature<Destination>, OmitIndexSignature<Source>>
>;
export type IfNever<T, TypeIfNever = true, TypeIfNotNever = false> =
IsNever<T> extends true ? TypeIfNever : TypeIfNotNever;
export type IsNever<T> = [T] extends [never] ? true : false;
type MergeDeep<Destination, Source> = SimplifyDeepExcludeArray<
[undefined] extends [Destination | Source]
? never
: Destination extends UnknownRecord
? Source extends UnknownRecord
? MergeDeepRecord<Destination, Source>
: never
: Destination extends UnknownArrayOrTuple
? Source extends UnknownArrayOrTuple
? MergeDeepArrayOrTuple<Destination, Source>
: never
: never
>;
export type ConditionalKeys<Base, Condition> = {
// Map through all the keys of the given base type.
[Key in keyof Base]-?: Base[Key] extends Condition // Pick only keys with types extending the given `Condition` type.
? // Retain this key
// If the value for the key extends never, only include it if `Condition` also extends never
IfNever<Base[Key], IfNever<Condition, Key, never>, Key>
: // Discard this key since the condition fails.
never;
// Convert the produced object into a union type of the keys which passed the conditional test.
}[keyof Base];
/** END FIXME: this is pulled from type-fest-v4. remove when we update package */
export type OptionsWithDefaultsDeep<Options, Defaults> =
// FIXME: replace with MergeDeep<Options, Defaults> when type-fest is updated to v4+
MergeDeep<Options, Defaults>;
type DefaultsForDeep<Options extends object> = Simplify<
Omit<
Required<{
[K in keyof Options]: Required<Options>[K] extends Primitive | Function
? Options[K]
: Required<Options>[K] extends object
? DefaultsForDeep<Required<Options>[K]>
: never; // should only ever be primitive, function, or object
}>,
RequiredPrimitiveKeysOf<Options> | RequiredMethodKeysOf<Options>
>
>;
// Returns only the keys of `Obj` that are required and not records
type RequiredPrimitiveKeysOf<Obj extends object> = RequiredKeysOf<
Omit<Obj, ConditionalKeys<Obj, object>>
>;
// Returns only the keys of `Obj` that are required and are functions
type RequiredMethodKeysOf<Obj extends object> = RequiredKeysOf<
Pick<Obj, ConditionalKeys<Obj, Function>>
>;
export function mergeOptions<
Options extends object,
const Defaults extends DefaultsForDeep<Options>,
>(
options: Options,
defaults: Defaults,
): OptionsWithDefaultsDeep<Options, DefaultsForDeep<Options>> {
const result = { ...options } as any;
for (const key in defaults) {
if (result[key] == null) {
result[key] = (defaults as any)[key];
}
}
return result as OptionsWithDefaultsDeep<Options, DefaultsForDeep<Options>>;
}

View File

@@ -1,5 +1,3 @@
import { Jsonify } from "type-fest";
import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { NotificationViewData } from "../models";
@@ -8,8 +6,7 @@ export const NOTIFICATIONS = UserKeyDefinition.array<NotificationViewData>(
NOTIFICATION_DISK,
"notifications",
{
deserializer: (notification: Jsonify<NotificationViewData>) =>
NotificationViewData.fromJSON(notification),
deserializer: (notification) => NotificationViewData.fromJSON(notification!),
clearOn: ["logout", "lock"],
},
);

View File

@@ -1,5 +1,3 @@
import { Jsonify } from "type-fest";
import { FOLDER_DISK, FOLDER_MEMORY, UserKeyDefinition } from "../../../platform/state";
import { FolderData } from "../../models/data/folder.data";
import { FolderView } from "../../models/view/folder.view";
@@ -8,7 +6,7 @@ export const FOLDER_ENCRYPTED_FOLDERS = UserKeyDefinition.record<FolderData>(
FOLDER_DISK,
"folders",
{
deserializer: (obj: Jsonify<FolderData>) => FolderData.fromJSON(obj),
deserializer: (obj) => FolderData.fromJSON(obj!),
clearOn: ["logout"],
},
);
@@ -17,7 +15,7 @@ export const FOLDER_DECRYPTED_FOLDERS = new UserKeyDefinition<FolderView[]>(
FOLDER_MEMORY,
"decryptedFolders",
{
deserializer: (obj: Jsonify<FolderView[]>) => obj?.map((f) => FolderView.fromJSON(f)) ?? [],
deserializer: (obj) => obj?.map((f) => FolderView.fromJSON(f)) ?? [],
clearOn: ["logout", "lock"],
},
);

View File

@@ -1,5 +1,3 @@
import { Jsonify } from "type-fest";
import { SECURITY_TASKS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { SecurityTaskData } from "../models/security-task.data";
@@ -8,7 +6,7 @@ export const SECURITY_TASKS = UserKeyDefinition.array<SecurityTaskData>(
SECURITY_TASKS_DISK,
"securityTasks",
{
deserializer: (task: Jsonify<SecurityTaskData>) => SecurityTaskData.fromJSON(task),
deserializer: (task) => SecurityTaskData.fromJSON(task!),
clearOn: ["logout", "lock"],
},
);

View File

@@ -103,6 +103,6 @@ export const LAST_PROCESS_RELOAD = new KeyDefinition<Date>(
BIOMETRIC_SETTINGS_DISK,
"lastProcessReload",
{
deserializer: (obj) => new Date(obj),
deserializer: (obj) => new Date(obj!),
},
);

View File

@@ -13,7 +13,7 @@ import { KdfType } from "./enums/kdf-type.enum";
import { Argon2KdfConfig, KdfConfig, PBKDF2KdfConfig } from "./models/kdf-config";
export const KDF_CONFIG = new UserKeyDefinition<KdfConfig>(KDF_CONFIG_DISK, "kdfConfig", {
deserializer: (kdfConfig: Jsonify<KdfConfig>) => {
deserializer: (kdfConfig: Jsonify<KdfConfig> | null) => {
if (kdfConfig == null) {
return null;
}

View File

@@ -15,7 +15,7 @@ export const GENERATOR_HISTORY = SecretKeyDefinition.array(
"localGeneratorHistory",
SecretClassifier.allSecret<GeneratedCredential>(),
{
deserializer: GeneratedCredential.fromJSON,
deserializer: (obj) => GeneratedCredential.fromJSON(obj!),
clearOn: ["logout"],
},
);

15
package-lock.json generated
View File

@@ -179,6 +179,7 @@
"ts-loader": "9.5.2",
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "2.19.0",
"type-fest-v4": "npm:type-fest@4.41.0",
"typescript": "5.5.4",
"typescript-eslint": "8.31.0",
"typescript-strict-plugin": "2.4.4",
@@ -37241,6 +37242,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-fest-v4": {
"name": "type-fest",
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",

View File

@@ -139,6 +139,7 @@
"ts-loader": "9.5.2",
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "2.19.0",
"type-fest-v4": "npm:type-fest@4.41.0",
"typescript": "5.5.4",
"typescript-eslint": "8.31.0",
"typescript-strict-plugin": "2.4.4",