Files
vscode/src/vs/platform/storage/browser/storageService.ts
T
2022-06-15 08:31:56 +02:00

377 lines
12 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isSafari } from 'vs/base/browser/browser';
import { IndexedDB } from 'vs/base/browser/indexedDB';
import { Promises } from 'vs/base/common/async';
import { toErrorMessage } from 'vs/base/common/errorMessage';
import { Emitter } from 'vs/base/common/event';
import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { InMemoryStorageDatabase, isStorageItemsChangeEvent, IStorage, IStorageDatabase, IStorageItemsChangeEvent, IUpdateRequest, Storage } from 'vs/base/parts/storage/common/storage';
import { ILogService } from 'vs/platform/log/common/log';
import { AbstractStorageService, IS_NEW_KEY, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile';
import { IAnyWorkspaceIdentifier } from 'vs/platform/workspace/common/workspace';
export class BrowserStorageService extends AbstractStorageService {
private static BROWSER_DEFAULT_FLUSH_INTERVAL = 5 * 1000; // every 5s because async operations are not permitted on shutdown
private applicationStorage: IStorage | undefined;
private globalStorage: IStorage | undefined;
private workspaceStorage: IStorage | undefined;
private applicationStorageDatabase: IIndexedDBStorageDatabase | undefined;
private globalStorageDatabase: IIndexedDBStorageDatabase | undefined;
private workspaceStorageDatabase: IIndexedDBStorageDatabase | undefined;
get hasPendingUpdate(): boolean {
return Boolean(
this.applicationStorageDatabase?.hasPendingUpdate ||
this.globalStorageDatabase?.hasPendingUpdate ||
this.workspaceStorageDatabase?.hasPendingUpdate
);
}
constructor(
private readonly payload: IAnyWorkspaceIdentifier,
@ILogService private readonly logService: ILogService,
@IUserDataProfilesService private readonly userDataProfileService: IUserDataProfilesService
) {
super({ flushInterval: BrowserStorageService.BROWSER_DEFAULT_FLUSH_INTERVAL });
}
private getId(scope: StorageScope): string {
switch (scope) {
case StorageScope.APPLICATION:
return 'global'; // use the default profile global DB for application scope
case StorageScope.GLOBAL:
if (this.userDataProfileService.currentProfile.isDefault) {
return 'global'; // default profile DB has a fixed name for backwards compatibility
} else {
return `global-${this.userDataProfileService.currentProfile.id}`;
}
case StorageScope.WORKSPACE:
return this.payload.id;
}
}
protected async doInitialize(): Promise<void> {
// Create Storage in Parallel
const promises: Promise<IIndexedDBStorageDatabase>[] = [];
promises.push(IndexedDBStorageDatabase.create({ id: this.getId(StorageScope.APPLICATION), broadcastChanges: true }, this.logService));
promises.push(IndexedDBStorageDatabase.create({ id: this.getId(StorageScope.WORKSPACE) }, this.logService));
if (!this.userDataProfileService.currentProfile.isDefault) {
// Create global storage only if the current profile is not a
// default profie otherwise we use the application storage
promises.push(IndexedDBStorageDatabase.create({ id: this.getId(StorageScope.GLOBAL), broadcastChanges: true }, this.logService));
}
const [applicationStorageDatabase, workspaceStorageDatabase, globalStorageDatabase] = await Promises.settled(promises);
// Workspace Storage
this.workspaceStorageDatabase = this._register(workspaceStorageDatabase);
this.workspaceStorage = this._register(new Storage(this.workspaceStorageDatabase));
this._register(this.workspaceStorage.onDidChangeStorage(key => this.emitDidChangeValue(StorageScope.WORKSPACE, key)));
// Application Storage
this.applicationStorageDatabase = this._register(applicationStorageDatabase);
this.applicationStorage = this._register(new Storage(this.applicationStorageDatabase));
this._register(this.applicationStorage.onDidChangeStorage(key => this.emitDidChangeValue(StorageScope.APPLICATION, key)));
// Global Storage
if (globalStorageDatabase) {
this.globalStorageDatabase = this._register(globalStorageDatabase);
this.globalStorage = this._register(new Storage(this.globalStorageDatabase));
} else {
this.globalStorage = this.applicationStorage;
}
this._register(this.globalStorage.onDidChangeStorage(key => this.emitDidChangeValue(StorageScope.GLOBAL, key)));
// Init storages
await Promises.settled([
this.workspaceStorage.init(),
this.globalStorage.init(),
this.applicationStorage.init()
]);
// Apply is-new markers
for (const storage of [this.applicationStorage, this.globalStorage, this.workspaceStorage]) {
const firstOpen = storage.getBoolean(IS_NEW_KEY);
if (firstOpen === undefined) {
storage.set(IS_NEW_KEY, true);
} else if (firstOpen) {
storage.set(IS_NEW_KEY, false);
}
}
}
protected getStorage(scope: StorageScope): IStorage | undefined {
switch (scope) {
case StorageScope.APPLICATION:
return this.applicationStorage;
case StorageScope.GLOBAL:
return this.globalStorage;
default:
return this.workspaceStorage;
}
}
protected getLogDetails(scope: StorageScope): string | undefined {
return this.getId(scope);
}
async migrate(toWorkspace: IAnyWorkspaceIdentifier): Promise<void> {
throw new Error('Migrating storage is currently unsupported in Web');
}
protected override shouldFlushWhenIdle(): boolean {
// this flush() will potentially cause new state to be stored
// since new state will only be created while the document
// has focus, one optimization is to not run this when the
// document has no focus, assuming that state has not changed
//
// another optimization is to not collect more state if we
// have a pending update already running which indicates
// that the connection is either slow or disconnected and
// thus unhealthy.
return document.hasFocus() && !this.hasPendingUpdate;
}
close(): void {
// Safari: there is an issue where the page can hang on load when
// a previous session has kept IndexedDB transactions running.
// The only fix seems to be to cancel any pending transactions
// (https://github.com/microsoft/vscode/issues/136295)
//
// On all other browsers, we keep the databases opened because
// we expect data to be written when the unload happens.
if (isSafari) {
this.applicationStorage?.close();
this.globalStorageDatabase?.close();
this.workspaceStorageDatabase?.close();
}
// Always dispose to ensure that no timeouts or callbacks
// get triggered in this phase.
this.dispose();
}
async clear(): Promise<void> {
// Clear key/values
for (const scope of [StorageScope.APPLICATION, StorageScope.GLOBAL, StorageScope.WORKSPACE]) {
for (const target of [StorageTarget.USER, StorageTarget.MACHINE]) {
for (const key of this.keys(scope, target)) {
this.remove(key, scope);
}
}
await this.getStorage(scope)?.whenFlushed();
}
// Clear databases
await Promises.settled([
this.applicationStorageDatabase?.clear() ?? Promise.resolve(),
this.globalStorageDatabase?.clear() ?? Promise.resolve(),
this.workspaceStorageDatabase?.clear() ?? Promise.resolve()
]);
}
}
interface IIndexedDBStorageDatabase extends IStorageDatabase, IDisposable {
/**
* Whether an update in the DB is currently pending
* (either update or delete operation).
*/
readonly hasPendingUpdate: boolean;
/**
* For testing only.
*/
clear(): Promise<void>;
}
class InMemoryIndexedDBStorageDatabase extends InMemoryStorageDatabase implements IIndexedDBStorageDatabase {
readonly hasPendingUpdate = false;
async clear(): Promise<void> {
(await this.getItems()).clear();
}
dispose(): void {
// No-op
}
}
interface IndexedDBStorageDatabaseOptions {
id: string;
broadcastChanges?: boolean;
}
export class IndexedDBStorageDatabase extends Disposable implements IIndexedDBStorageDatabase {
static async create(options: IndexedDBStorageDatabaseOptions, logService: ILogService): Promise<IIndexedDBStorageDatabase> {
try {
const database = new IndexedDBStorageDatabase(options, logService);
await database.whenConnected;
return database;
} catch (error) {
logService.error(`[IndexedDB Storage ${options.id}] create(): ${toErrorMessage(error, true)}`);
return new InMemoryIndexedDBStorageDatabase();
}
}
private static readonly STORAGE_DATABASE_PREFIX = 'vscode-web-state-db-';
private static readonly STORAGE_OBJECT_STORE = 'ItemTable';
private static readonly STORAGE_BROADCAST_CHANNEL = 'vscode.web.state.changes';
private readonly _onDidChangeItemsExternal = this._register(new Emitter<IStorageItemsChangeEvent>());
readonly onDidChangeItemsExternal = this._onDidChangeItemsExternal.event;
private broadcastChannel: BroadcastChannel | undefined;
private pendingUpdate: Promise<boolean> | undefined = undefined;
get hasPendingUpdate(): boolean { return !!this.pendingUpdate; }
private readonly name: string;
private readonly whenConnected: Promise<IndexedDB>;
private constructor(
options: IndexedDBStorageDatabaseOptions,
private readonly logService: ILogService
) {
super();
this.name = `${IndexedDBStorageDatabase.STORAGE_DATABASE_PREFIX}${options.id}`;
this.broadcastChannel = options.broadcastChanges && ('BroadcastChannel' in window) ? new BroadcastChannel(IndexedDBStorageDatabase.STORAGE_BROADCAST_CHANNEL) : undefined;
this.whenConnected = this.connect();
this.registerListeners();
}
private registerListeners(): void {
// Check for global storage change events from other
// windows/tabs via `BroadcastChannel` mechanisms.
if (this.broadcastChannel) {
const listener = (event: MessageEvent) => {
if (isStorageItemsChangeEvent(event.data)) {
this._onDidChangeItemsExternal.fire(event.data);
}
};
this.broadcastChannel.addEventListener('message', listener);
this._register(toDisposable(() => {
this.broadcastChannel?.removeEventListener('message', listener);
this.broadcastChannel?.close();
}));
}
}
private async connect(): Promise<IndexedDB> {
try {
return await IndexedDB.create(this.name, undefined, [IndexedDBStorageDatabase.STORAGE_OBJECT_STORE]);
} catch (error) {
this.logService.error(`[IndexedDB Storage ${this.name}] connect() error: ${toErrorMessage(error)}`);
throw error;
}
}
async getItems(): Promise<Map<string, string>> {
const db = await this.whenConnected;
function isValid(value: unknown): value is string {
return typeof value === 'string';
}
return db.getKeyValues<string>(IndexedDBStorageDatabase.STORAGE_OBJECT_STORE, isValid);
}
async updateItems(request: IUpdateRequest): Promise<void> {
// Run the update
let didUpdate = false;
this.pendingUpdate = this.doUpdateItems(request);
try {
didUpdate = await this.pendingUpdate;
} finally {
this.pendingUpdate = undefined;
}
// Broadcast changes to other windows/tabs if enabled
// and only if we actually did update storage items.
if (this.broadcastChannel && didUpdate) {
const event: IStorageItemsChangeEvent = {
changed: request.insert,
deleted: request.delete
};
this.broadcastChannel.postMessage(event);
}
}
private async doUpdateItems(request: IUpdateRequest): Promise<boolean> {
// Return early if the request is empty
const toInsert = request.insert;
const toDelete = request.delete;
if ((!toInsert && !toDelete) || (toInsert?.size === 0 && toDelete?.size === 0)) {
return false;
}
const db = await this.whenConnected;
// Update `ItemTable` with inserts and/or deletes
await db.runInTransaction(IndexedDBStorageDatabase.STORAGE_OBJECT_STORE, 'readwrite', objectStore => {
const requests: IDBRequest[] = [];
// Inserts
if (toInsert) {
for (const [key, value] of toInsert) {
requests.push(objectStore.put(value, key));
}
}
// Deletes
if (toDelete) {
for (const key of toDelete) {
requests.push(objectStore.delete(key));
}
}
return requests;
});
return true;
}
async close(): Promise<void> {
const db = await this.whenConnected;
// Wait for pending updates to having finished
await this.pendingUpdate;
// Finally, close IndexedDB
return db.close();
}
async clear(): Promise<void> {
const db = await this.whenConnected;
await db.runInTransaction(IndexedDBStorageDatabase.STORAGE_OBJECT_STORE, 'readwrite', objectStore => objectStore.clear());
}
}