diff --git a/api-report/driver-web-cache.api.md b/api-report/driver-web-cache.api.md new file mode 100644 index 000000000000..044e641296aa --- /dev/null +++ b/api-report/driver-web-cache.api.md @@ -0,0 +1,36 @@ +## API Report File for "@fluidframework/driver-web-cache" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ICacheEntry } from '@fluidframework/odsp-driver-definitions'; +import { IFileEntry } from '@fluidframework/odsp-driver-definitions'; +import { IPersistedCache } from '@fluidframework/odsp-driver-definitions'; +import { ITelemetryBaseLogger } from '@fluidframework/common-definitions'; + +// @public (undocumented) +export function deleteFluidCacheIndexDbInstance(): Promise; + +// @public +export class FluidCache implements IPersistedCache { + constructor(config: FluidCacheConfig); + // (undocumented) + get(cacheEntry: ICacheEntry): Promise; + // (undocumented) + put(entry: ICacheEntry, value: any): Promise; + // (undocumented) + removeEntries(file: IFileEntry): Promise; +} + +// @public (undocumented) +export interface FluidCacheConfig { + logger?: ITelemetryBaseLogger; + maxCacheItemAge: number; + partitionKey: string | null; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/lerna-package-lock.json b/lerna-package-lock.json index b8a5e16aa972..88175065665f 100644 --- a/lerna-package-lock.json +++ b/lerna-package-lock.json @@ -16346,6 +16346,11 @@ } } }, + "base64-arraybuffer-es6": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz", + "integrity": "sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==" + }, "base64-js": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", @@ -22778,6 +22783,14 @@ "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, + "fake-indexeddb": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-3.1.4.tgz", + "integrity": "sha512-kweAGUKgo/NLHZpyMcgYk2ihDrWQcpzwZ3Y2Ag6/Wo3h8F/ts0onw0nTEth8YjWzzBY+sxSSKFn69vc7xcK0Qg==", + "requires": { + "realistic-structured-clone": "^2.0.1" + } + }, "fast-deep-equal": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", @@ -25221,6 +25234,11 @@ "postcss": "^7.0.14" } }, + "idb": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/idb/-/idb-6.1.5.tgz", + "integrity": "sha512-IJtugpKkiVXQn5Y+LteyBCNk1N8xpGV3wWZk9EVtZWH8DYkjBn0bX1XnGP9RkyZF0sAcywa6unHqSWKe7q4LGw==" + }, "identity-obj-proxy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", @@ -37538,6 +37556,27 @@ "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", "dev": true }, + "realistic-structured-clone": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/realistic-structured-clone/-/realistic-structured-clone-2.0.3.tgz", + "integrity": "sha512-XYTwWZi5+lU4Wf+rnsQ7pukN9hF2cbJJf/yruBr1w23WhGflM6WoTBkdMVAun+oHFW2mV7UquyYo5oOI7YLJrQ==", + "requires": { + "core-js": "^2.5.3", + "domexception": "^1.0.1", + "typeson": "^6.1.0", + "typeson-registry": "^1.0.0-alpha.20" + }, + "dependencies": { + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "requires": { + "webidl-conversions": "^4.0.2" + } + } + } + }, "recast": { "version": "0.11.23", "resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz", @@ -42728,6 +42767,46 @@ "editorconfig": "^0.15.0" } }, + "typeson": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/typeson/-/typeson-6.1.0.tgz", + "integrity": "sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==" + }, + "typeson-registry": { + "version": "1.0.0-alpha.39", + "resolved": "https://registry.npmjs.org/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz", + "integrity": "sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==", + "requires": { + "base64-arraybuffer-es6": "^0.7.0", + "typeson": "^6.0.0", + "whatwg-url": "^8.4.0" + }, + "dependencies": { + "tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "requires": { + "punycode": "^2.1.1" + } + }, + "webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==" + }, + "whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "requires": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + } + } + } + }, "typewise": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", diff --git a/packages/drivers/driver-web-cache/.eslintrc.js b/packages/drivers/driver-web-cache/.eslintrc.js new file mode 100644 index 000000000000..39302fd1c0f7 --- /dev/null +++ b/packages/drivers/driver-web-cache/.eslintrc.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +module.exports = { + extends: ["@fluidframework/eslint-config-fluid"], + parserOptions: { + project: ["./tsconfig.json", "./src/test/tsconfig.json"], + }, + rules: { + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/strict-boolean-expressions": "off", + "no-null/no-null": "off", + "@typescript-eslint/promise-function-async": "off", + "@typescript-eslint/no-misused-promises": "off", + }, +}; diff --git a/packages/drivers/driver-web-cache/.gitignore b/packages/drivers/driver-web-cache/.gitignore new file mode 100644 index 000000000000..ee26a5e7bdbf --- /dev/null +++ b/packages/drivers/driver-web-cache/.gitignore @@ -0,0 +1,52 @@ +# Compiled TypeScript and CSS +dist +lib + +# Babel +public/scripts/es5 + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt +.cache-loader + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- +node_modules + +# Typings +typings + +# Debug log from npm +npm-debug.log + +# Code coverage +nyc +.nyc_output/ + +# Chart dependencies +**/charts/*.tgz + +# Generated modules +intel_modules/ +temp_modules/ diff --git a/packages/drivers/driver-web-cache/.npmignore b/packages/drivers/driver-web-cache/.npmignore new file mode 100644 index 000000000000..a40f882cf599 --- /dev/null +++ b/packages/drivers/driver-web-cache/.npmignore @@ -0,0 +1,6 @@ +nyc +*.log +**/*.tsbuildinfo +src/test +dist/test +**/_api-extractor-temp/** diff --git a/packages/drivers/driver-web-cache/LICENSE b/packages/drivers/driver-web-cache/LICENSE new file mode 100644 index 000000000000..60af0a6a40e9 --- /dev/null +++ b/packages/drivers/driver-web-cache/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation and contributors. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/drivers/driver-web-cache/README.md b/packages/drivers/driver-web-cache/README.md new file mode 100644 index 000000000000..058b2f67a26e --- /dev/null +++ b/packages/drivers/driver-web-cache/README.md @@ -0,0 +1,56 @@ +# @fluidframework/driver-web-cache + +This package provides an implementation of the `IPersistedCache` interface in the odsp-driver package. This cache enables +storing of user content on the user's machine in order to provide faster boot experiences when opening the same Fluid +containers more than once. This implementation has a dependency on indexeddb, so it is intended to only be used in a browser +context. + +## Usage + +```typescript +import { FluidCache } from '@fluidframework/driver-web-cache'; + +new FluidCache({ + partitionKey: userId, + logger, + maxCacheItemAge + }) +``` + +### Parameters + +- `partitionKey` - Used to determine what partition of the cache is being used, and can prevent multiple users on the + same machine from sharing a snapshot cache. If you absolutely know that users will not share the cache, + can also be set to `null`. Currently optional, but is proposed to be required in the next major bump. + The recommendation is to use this key to differentiate users for the cache data. +- `logger` - An optional implementation of the logger contract where diagnostic data can be logged. +- `maxCacheItemAge` - The cache tracks a timestamp with each entry. This flag specifies the maximum age (in milliseconds) + for a cache entry to be used. This flag does not control when cached content is deleted since different scenarios and + applications may have different staleness thresholds for the same data. + +## Clearing cache entries + +Whenever any Fluid content is loaded with the web cache enabled, a task is scheduled to clear out all "stale" cache +entries. This task is scheduled with the `setIdleCallback` browser API. We define stale cache entries as any cache +entries that have not been used (read or written to) within the last 4 weeks. The cache is cleared of all stale cache +entries corresponding to all documents, not just the ones corresponding to the Fluid document being loaded. + +The `deleteFluidCacheIndexDbInstance` API that an application can use to clear out the entire contents of the snapshot +cache at any time. We recommend calling this API when the user explicitly signs out. Hosting applications +are on point for ensuring responsible usage of the snapshot caching capability to still meet any relevant +customer promises, such as clearing out storage when appropriate or disabling snapshot caching under certain circumstances, +such as when it is known the user is logged in to a public computer. + + +```typescript +import { deleteFluidCacheIndexDbInstance } from '@fluidframework/driver-web-cache'; + + // We put a catch here because Firefox Incognito will throw an error here. This is why we claim this method is a "best effort", since sometimes the browser won't let us access storage +deleteFluidCacheIndexDbInstance().catch(() => {}); +``` + +## Trademark + +This project may contain Microsoft trademarks or logos for Microsoft projects, products, or services. Use of these trademarks +or logos must follow Microsoft's [Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. diff --git a/packages/drivers/driver-web-cache/api-extractor.json b/packages/drivers/driver-web-cache/api-extractor.json new file mode 100644 index 000000000000..e4b08b39f245 --- /dev/null +++ b/packages/drivers/driver-web-cache/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluidframework/build-common/api-extractor-common-report.json" +} diff --git a/packages/drivers/driver-web-cache/jest.config.js b/packages/drivers/driver-web-cache/jest.config.js new file mode 100644 index 000000000000..61ca9e468101 --- /dev/null +++ b/packages/drivers/driver-web-cache/jest.config.js @@ -0,0 +1,12 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +module.exports = { + roots: ["/dist"], + testEnvironment: "jsdom", + testMatch: ["**/?(*.)+(spec|test).[j]s"], + testPathIgnorePatterns: ["/node_modules/"], + verbose: true, +}; diff --git a/packages/drivers/driver-web-cache/package.json b/packages/drivers/driver-web-cache/package.json new file mode 100644 index 000000000000..f2c0c169e837 --- /dev/null +++ b/packages/drivers/driver-web-cache/package.json @@ -0,0 +1,68 @@ +{ + "name": "@fluidframework/driver-web-cache", + "version": "0.58.2000", + "description": "Implementation of the driver caching API for a web browser", + "homepage": "https://fluidframework.com", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/FluidFramework.git", + "directory": "packages/drivers/driver-web-cache" + }, + "license": "MIT", + "author": "Microsoft and contributors", + "sideEffects": false, + "main": "dist/index.js", + "module": "lib/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "npm run build:genver && concurrently npm:build:compile npm:lint && npm run build:docs", + "build:commonjs": "npm run tsc && npm run build:test", + "build:compile": "concurrently npm:build:commonjs npm:build:esnext", + "build:docs": "api-extractor run --local --typescript-compiler-folder ../../../node_modules/typescript && copyfiles -u 1 ./_api-extractor-temp/doc-models/* ../../../_api-extractor-temp/", + "build:esnext": "tsc --project ./tsconfig.esnext.json", + "build:full": "npm run build", + "build:full:compile": "npm run build:compile", + "build:genver": "gen-version", + "build:test": "tsc --project ./src/test/tsconfig.json", + "ci:build:docs": "api-extractor run --typescript-compiler-folder ../../../node_modules/typescript && copyfiles -u 1 ./_api-extractor-temp/* ../../../_api-extractor-temp/", + "clean": "rimraf dist lib *.tsbuildinfo *.build.log", + "eslint": "eslint --format stylish src", + "eslint:fix": "eslint --format stylish src --fix --fix-type problem,suggestion,layout", + "lint": "npm run eslint", + "lint:fix": "npm run eslint:fix", + "test": "jest", + "tsc": "tsc", + "tsfmt": "tsfmt --verify", + "tsfmt:fix": "tsfmt --replace" + }, + "dependencies": { + "@fluidframework/common-definitions": "^0.20.1", + "@fluidframework/odsp-driver-definitions": "^0.58.2000", + "@fluidframework/telemetry-utils": "^0.58.2000", + "idb": "^6.1.2" + }, + "devDependencies": { + "@fluidframework/build-common": "^0.23.0", + "@fluidframework/eslint-config-fluid": "^0.27.0-0", + "@microsoft/api-extractor": "^7.16.1", + "@rushstack/eslint-config": "^2.5.1", + "@types/jest": "22.2.3", + "@types/node": "^14.18.0", + "@types/socket.io-client": "^1.4.32", + "@typescript-eslint/eslint-plugin": "~5.9.0", + "@typescript-eslint/parser": "~5.9.0", + "concurrently": "^6.2.0", + "copyfiles": "^2.1.0", + "eslint": "~8.6.0", + "eslint-plugin-editorconfig": "~3.2.0", + "eslint-plugin-eslint-comments": "~3.2.0", + "eslint-plugin-import": "~2.25.4", + "eslint-plugin-no-null": "~1.0.2", + "eslint-plugin-react": "~7.28.0", + "eslint-plugin-unicorn": "~40.0.0", + "fake-indexeddb": "3.1.4", + "jest": "^26.6.3", + "typescript": "~4.1.3", + "typescript-formatter": "7.1.0" + } +} diff --git a/packages/drivers/driver-web-cache/src/FluidCache.ts b/packages/drivers/driver-web-cache/src/FluidCache.ts new file mode 100644 index 000000000000..a3fe9667f782 --- /dev/null +++ b/packages/drivers/driver-web-cache/src/FluidCache.ts @@ -0,0 +1,276 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + IPersistedCache, + ICacheEntry, + IFileEntry, +} from "@fluidframework/odsp-driver-definitions"; +import { + ITelemetryBaseLogger, + ITelemetryLogger, +} from "@fluidframework/common-definitions"; +import { ChildLogger } from "@fluidframework/telemetry-utils"; +import { scheduleIdleTask } from "./scheduleIdleTask"; +import { + getFluidCacheIndexedDbInstance, + FluidDriverObjectStoreName, + getKeyForCacheEntry, +} from "./FluidCacheIndexedDb"; +import { + FluidCacheErrorEvent, + FluidCacheEventSubCategories, + FluidCacheGenericEvent, +} from "./fluidCacheTelemetry"; + +// Some browsers have a usageDetails property that will tell you more detailed information +// on how the storage is being used +interface StorageQuotaUsageDetails { + indexedDB: number | undefined; +} + +export interface FluidCacheConfig { + /** + * A string to specify what partition of the cache you wish to use (e.g. a user id). + * Null can be used to explicity indicate no partitioning, and has been chosen + * vs undefined so that it is clear this is an intentional choice by the caller. + * A null value should only be used when the host can ensure that the cache is not able + * to be shared with multiple users. + */ + // eslint-disable-next-line @rushstack/no-new-null + partitionKey: string | null; + + /** + * A logger that can be used to get insight into cache performance and errors + */ + logger?: ITelemetryBaseLogger; + + /** + * A value in milliseconds that determines the maximum age of a cache entry to return. + * If an entry exists in the cache, but is older than this value, the cached value will not be returned. + */ + maxCacheItemAge: number; +} + +/** + * A cache that can be used by the Fluid ODSP driver to cache data for faster performance + */ +export class FluidCache implements IPersistedCache { + private readonly logger: ITelemetryLogger; + + private readonly partitionKey: string | null; + + private readonly maxCacheItemAge: number; + + constructor(config: FluidCacheConfig) { + this.logger = ChildLogger.create(config.logger); + this.partitionKey = config.partitionKey; + this.maxCacheItemAge = config.maxCacheItemAge; + + scheduleIdleTask(async () => { + // Log how much storage space is currently being used by indexed db. + // NOTE: This API is not supported in all browsers and it doesn't let you see the size of a specific DB. + // Exception added when eslint rule was added, this should be revisited when modifying this code + if (navigator.storage && navigator.storage.estimate) { + const estimate = await navigator.storage.estimate(); + + // Some browsers have a usageDetails property that will tell you + // more detailed information on how the storage is being used + let indexedDBSize: number | undefined; + if ("usageDetails" in estimate) { + indexedDBSize = ( + (estimate as any) + .usageDetails as StorageQuotaUsageDetails + ).indexedDB; + } + + this.logger.sendTelemetryEvent({ + eventName: FluidCacheGenericEvent.FluidCacheStorageInfo, + subCategory: FluidCacheEventSubCategories.FluidCache, + quota: estimate.quota, + usage: estimate.usage, + indexedDBSize, + }); + } + }); + + scheduleIdleTask(async () => { + // Delete entries that have not been accessed recently to clean up space + try { + const db = await getFluidCacheIndexedDbInstance(this.logger); + + const transaction = db.transaction( + FluidDriverObjectStoreName, + "readwrite", + ); + const index = transaction.store.index("lastAccessTimeMs"); + // Get items that have not been accessed in 4 weeks + const keysToDelete = await index.getAllKeys( + IDBKeyRange.upperBound( + new Date().getTime() - 4 * 7 * 24 * 60 * 60 * 1000, + ), + ); + + await Promise.all( + keysToDelete.map((key) => transaction.store.delete(key)), + ); + await transaction.done; + } catch (error: any) { + this.logger.sendErrorEvent( + { + eventName: + FluidCacheErrorEvent.FluidCacheDeleteOldEntriesError, + }, + error, + ); + } + }); + } + + public async removeEntries(file: IFileEntry): Promise { + try { + const db = await getFluidCacheIndexedDbInstance(this.logger); + + const transaction = db.transaction( + FluidDriverObjectStoreName, + "readwrite", + ); + const index = transaction.store.index("fileId"); + + const keysToDelete = await index.getAllKeys(file.docId); + + await Promise.all( + keysToDelete.map((key) => transaction.store.delete(key)), + ); + await transaction.done; + } catch (error: any) { + this.logger.sendErrorEvent( + { + eventName: + FluidCacheErrorEvent.FluidCacheDeleteOldEntriesError, + }, + error, + ); + } + } + + public async get(cacheEntry: ICacheEntry): Promise { + const startTime = performance.now(); + + const cachedItem = await this.getItemFromCache(cacheEntry); + + this.logger.sendPerformanceEvent({ + eventName: "FluidCacheAccess", + cacheHit: cachedItem !== undefined, + type: cacheEntry.type, + duration: performance.now() - startTime, + }); + + // Value will contain metadata like the expiry time, we just want to return the object we were asked to cache + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return cachedItem?.cachedObject; + } + + private async getItemFromCache(cacheEntry: ICacheEntry) { + try { + const key = getKeyForCacheEntry(cacheEntry); + + const db = await getFluidCacheIndexedDbInstance(this.logger); + + const value = await db.get(FluidDriverObjectStoreName, key); + + if (!value) { + return undefined; + } + + // If the data does not come from the same partition, don't return it + if (value.partitionKey !== this.partitionKey) { + this.logger.sendTelemetryEvent({ + eventName: + FluidCacheGenericEvent.FluidCachePartitionKeyMismatch, + subCategory: FluidCacheEventSubCategories.FluidCache, + }); + + return undefined; + } + + const currentTime = new Date().getTime(); + + // If too much time has passed since this cache entry was used, we will also return undefined + if (currentTime - value.createdTimeMs > this.maxCacheItemAge) { + return undefined; + } + + const transaction = db.transaction( + FluidDriverObjectStoreName, + "readwrite", + ); + // We don't want to block the get return of this function on updating the last accessed time + // We catch this promise because there is no user bad if this is rejected. + transaction.store + .get(key) + .then(async (valueToUpdate) => { + // This value in the database could have been updated concurrently by other tabs/iframes + // since we first read it. Only update the last accessed time if the current value in the + // DB was the same one we returned. + if ( + valueToUpdate !== undefined && + valueToUpdate.createdTimeMs === value.createdTimeMs && + (valueToUpdate.lastAccessTimeMs === undefined || + valueToUpdate.lastAccessTimeMs < currentTime) + ) { + await transaction.store.put( + { ...valueToUpdate, lastAccessTimeMs: currentTime }, + key, + ); + } + await transaction.done; + + db.close(); + }) + .catch(() => { }); + return value; + } catch (error: any) { + // We can fail to open the db for a variety of reasons, + // such as the database version having upgraded underneath us. Return undefined in this case + this.logger.sendErrorEvent( + { eventName: FluidCacheErrorEvent.FluidCacheGetError }, + error, + ); + return undefined; + } + } + + public async put(entry: ICacheEntry, value: any): Promise { + try { + const db = await getFluidCacheIndexedDbInstance(this.logger); + + const currentTime = new Date().getTime(); + + await db.put( + FluidDriverObjectStoreName, + { + cachedObject: value, + fileId: entry.file.docId, + type: entry.type, + cacheItemId: entry.key, + partitionKey: this.partitionKey, + createdTimeMs: currentTime, + lastAccessTimeMs: currentTime, + }, + getKeyForCacheEntry(entry), + ); + + db.close(); + } catch (error: any) { + // We can fail to open the db for a variety of reasons, + // such as the database version having upgraded underneath us + this.logger.sendErrorEvent( + { eventName: FluidCacheErrorEvent.FluidCachePutError }, + error, + ); + } + } +} diff --git a/packages/drivers/driver-web-cache/src/FluidCacheIndexedDb.ts b/packages/drivers/driver-web-cache/src/FluidCacheIndexedDb.ts new file mode 100644 index 000000000000..e5ffd1ac839c --- /dev/null +++ b/packages/drivers/driver-web-cache/src/FluidCacheIndexedDb.ts @@ -0,0 +1,155 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { openDB, DBSchema, IDBPDatabase, deleteDB } from "idb"; +import { ICacheEntry } from "@fluidframework/odsp-driver-definitions"; +import { ITelemetryBaseLogger } from "@fluidframework/common-definitions"; +import { ChildLogger } from "@fluidframework/telemetry-utils"; +import { FluidCacheErrorEvent } from "./fluidCacheTelemetry"; + +// The name of the database that we use for caching Fluid info. +export const FluidDriverCacheDBName = "fluidDriverCache"; + +// The name of the object store within the indexed db instance that the driver will use to cache Fluid content. +export const FluidDriverObjectStoreName = "driverStorage.V3"; + +export const CurrentCacheVersion = 3; + +// Note that V1 and V2 were misspelled as "diver", and we need to keep using the misspelling here. +export const oldVersionNameMapping: Partial<{ [key: number]: string }> = { + 1: "diverStorage", + 2: "diverStorage.V2", +}; + +export function getKeyForCacheEntry(entry: ICacheEntry) { + return `${entry.file.docId}_${entry.type}_${entry.key}`; +} + +export function getFluidCacheIndexedDbInstance( + logger?: ITelemetryBaseLogger, +): Promise> { + return new Promise((resolve, reject) => { + openDB( + FluidDriverCacheDBName, + CurrentCacheVersion, + { + upgrade: (db, oldVersion) => { + try { + // We changed the format of the object store, so we must + // delete the old stores to create a new one in V3 + const cacheToDelete = oldVersionNameMapping[oldVersion]; + if (cacheToDelete) { + // We don't include the old object stores in the schema, so we need to + // use a typecast here to prevent IDB from complaining + db.deleteObjectStore(cacheToDelete as any); + } + } catch (error: any) { + // Catch any error done when attempting to delete the older version. + // If the object does not exist db will throw. + // We can now assume that the old version is no longer there regardless. + ChildLogger.create(logger).sendErrorEvent( + { + eventName: + FluidCacheErrorEvent.FluidCacheDeleteOldDbError, + }, + error, + ); + } + + const cacheObjectStore = db.createObjectStore( + FluidDriverObjectStoreName, + ); + cacheObjectStore.createIndex( + "createdTimeMs", + "createdTimeMs", + ); + cacheObjectStore.createIndex( + "lastAccessTimeMs", + "lastAccessTimeMs", + ); + cacheObjectStore.createIndex( + "partitionKey", + "partitionKey", + ); + cacheObjectStore.createIndex("fileId", "fileId"); + }, + blocked: () => { + reject( + new Error( + "Could not open DB since it is blocked by an older client that has the DB open", + ), + ); + }, + }, + ).then(resolve, reject); + }); +} + +// Deletes the indexed DB instance. +// Warning this can throw an error in Firefox incognito, where accessing storage is prohibited. +export function deleteFluidCacheIndexDbInstance(): Promise { + return deleteDB(FluidDriverCacheDBName); +} + +/** + * Schema for the object store used to cache driver information + */ +export interface FluidCacheDBSchema extends DBSchema { + [FluidDriverObjectStoreName]: { + /** + * A unique identifier for an item in the cache. It is a combination of file, type, and cacheItemId + */ + key: string; + + value: { + /** + * The identifier of the file associated with the cache entry + */ + fileId: string; + + /** + * Describes the type of content being cached, such as snapshot + */ + type: string; + + /** + * Files may have multiple cached items associated with them, + * this property uniquely identifies a specific cache entry for a file. + * This is not globally unique, but rather a unique id for this file + */ + cacheItemId: string; + + /* + * Opaque object that the driver asks us to store in a cache for performance reasons + */ + cachedObject: any; + + /** + * A string to specify what partition of the cache you wish to use (e.g. a user id). + * Null can be used to explicity indicate no partitioning. + */ + // eslint-disable-next-line @rushstack/no-new-null + partitionKey: string | null; + + /** + * The time when the cache entry was put into the cache + */ + createdTimeMs: number; + + /** + * The last time the cache entry was used. + * This is initially set to the time the cache entry was created Measured as ms since unix epoch. + */ + lastAccessTimeMs: number; + }; + + indexes: { + createdTimeMs: number; + partitionKey: string; + lastAccessTimeMs: number; + fileId: string; + }; + }; +} diff --git a/packages/drivers/driver-web-cache/src/fluidCacheTelemetry.ts b/packages/drivers/driver-web-cache/src/fluidCacheTelemetry.ts new file mode 100644 index 000000000000..9363e24dad5a --- /dev/null +++ b/packages/drivers/driver-web-cache/src/fluidCacheTelemetry.ts @@ -0,0 +1,21 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export const enum FluidCacheGenericEvent { + "FluidCacheStorageInfo" = "FluidCacheStorageInfo", + "FluidCachePartitionKeyMismatch" = "FluidCachePartitionKeyMismatch", +} + +export const enum FluidCacheErrorEvent { + "FluidCacheDeleteOldEntriesError" = "FluidCacheDeleteOldEntriesError", + "FluidCacheGetError" = "FluidCacheGetError", + "FluidCachePutError" = "FluidCachePutError", + "FluidCacheUpdateUsageError" = "FluidCacheUpdateUsageError", + "FluidCacheDeleteOldDbError" = "FluidCacheDeleteOldDbError", +} + +export const enum FluidCacheEventSubCategories { + "FluidCache" = "FluidCache", +} diff --git a/packages/drivers/driver-web-cache/src/index.ts b/packages/drivers/driver-web-cache/src/index.ts new file mode 100644 index 000000000000..2dba07a09c8b --- /dev/null +++ b/packages/drivers/driver-web-cache/src/index.ts @@ -0,0 +1,7 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export * from "./FluidCache"; +export { deleteFluidCacheIndexDbInstance } from "./FluidCacheIndexedDb"; diff --git a/packages/drivers/driver-web-cache/src/packageVersion.ts b/packages/drivers/driver-web-cache/src/packageVersion.ts new file mode 100644 index 000000000000..2bd63a6457ce --- /dev/null +++ b/packages/drivers/driver-web-cache/src/packageVersion.ts @@ -0,0 +1,9 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + * + * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY + */ + +export const pkgName = "@fluidframework/driver-web-cache"; +export const pkgVersion = "0.58.2000"; diff --git a/packages/drivers/driver-web-cache/src/scheduleIdleTask.ts b/packages/drivers/driver-web-cache/src/scheduleIdleTask.ts new file mode 100644 index 000000000000..34951317795f --- /dev/null +++ b/packages/drivers/driver-web-cache/src/scheduleIdleTask.ts @@ -0,0 +1,119 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +// Older versions of Typescript considered this API experimental and not in Typescript included types. +// This can be removed when FF updates to Typescript 4.4 or later +declare global { + interface Window { + requestIdleCallback: + | (( + callback: (deadline: { + readonly didTimeout: boolean; + timeRemaining: () => number; + }) => void, + opts?: { + timeout: number; + } + ) => number) + | undefined; + cancelIdleCallback: ((handle: number) => void) | undefined; + } +} + +interface TaskQueueItem { + /** The task to run */ + task: () => void; +} + +// A set of tasks that still have to be run +let taskQueue: TaskQueueItem[] = []; + +// Set to true when we have a pending idle task scheduled +let idleTaskScheduled = false; + +/** + * A function that schedules a non critical task to be run when the browser has cycles available + * @param task - The task to be executed + * @param options - Optional configuration for the task execution + */ +export function scheduleIdleTask(task: () => void) { + taskQueue.push({ + task, + }); + + ensureIdleCallback(); +} + +/** + * Ensures an idle callback has been scheduled for the remaining tasks + */ +function ensureIdleCallback() { + if (!idleTaskScheduled) { + // Exception added when eslint rule was added, this should be revisited when modifying this code + if (window.requestIdleCallback) { + window.requestIdleCallback(idleTaskCallback); + } else { + const deadline = Date.now() + 50; + window.setTimeout( + () => + idleTaskCallback({ + timeRemaining: () => Math.max(deadline - Date.now(), 0), + didTimeout: false, + }), + 0, + ); + } + idleTaskScheduled = true; + } +} + +/** + * Runs tasks from the task queue + * @param filter - An optional function that will be called for each task to see if it should run. + * Returns false for tasks that should not run. If omitted all tasks run. + * @param shouldContinueRunning - An optional function that will be called to determine if + * we have enough time to continue running tasks. If omitted, we don't stop running tasks. + */ +function runTasks( + filter?: (taskQueueItem: TaskQueueItem) => boolean, + shouldContinueRunning?: () => boolean, +) { + // The next value for the task queue + const newTaskQueue: TaskQueueItem[] = []; + + for (let index = 0; index < taskQueue.length; index += 1) { + if (shouldContinueRunning && !shouldContinueRunning()) { + // Add the tasks we didn't get to to the end of the new task queue + newTaskQueue.push(...taskQueue.slice(index)); + break; + } + + const taskQueueItem = taskQueue[index]; + + if (filter && !filter(taskQueueItem)) { + newTaskQueue.push(taskQueueItem); + } else { + taskQueueItem.task(); + } + } + + taskQueue = newTaskQueue; +} + +// Runs all the tasks in the task queue +function idleTaskCallback(deadline: { + timeRemaining: () => number; + readonly didTimeout: boolean; +}) { + // Minimum time that must be available on deadline to run any more tasks + const minTaskTime = 10; + runTasks(undefined, () => deadline.timeRemaining() > minTaskTime); + idleTaskScheduled = false; + + // If we didn't run through the entire queue, schedule another idle callback + if (taskQueue.length > 0) { + ensureIdleCallback(); + } +} diff --git a/packages/drivers/driver-web-cache/src/test/FluidCache.test.ts b/packages/drivers/driver-web-cache/src/test/FluidCache.test.ts new file mode 100644 index 000000000000..84515565b410 --- /dev/null +++ b/packages/drivers/driver-web-cache/src/test/FluidCache.test.ts @@ -0,0 +1,288 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { ICacheEntry } from "@fluidframework/odsp-driver-definitions"; +import { openDB } from "idb"; +import { FluidCache } from "../FluidCache"; +import { + getFluidCacheIndexedDbInstance, + FluidDriverObjectStoreName, + FluidDriverCacheDBName, + getKeyForCacheEntry, +} from "../FluidCacheIndexedDb"; +// eslint-disable-next-line max-len +// eslint-disable-next-line import/no-unassigned-import, @typescript-eslint/no-require-imports, import/no-internal-modules +require("fake-indexeddb/auto"); + +const mockPartitionKey = "FAKEPARTITIONKEY"; + +function getFluidCache(config?: { + maxCacheItemAge?: number; + // eslint-disable-next-line @rushstack/no-new-null + partitionKey?: string | null; +}) { + return new FluidCache({ + partitionKey: config?.partitionKey || mockPartitionKey, + maxCacheItemAge: config?.maxCacheItemAge || 3 * 24 * 60 * 60 * 1000, + }); +} + +class DateMock { + // The current time being used by the mock + public static mockTimeMs: number = 0; + + public static now() { + return DateMock.mockTimeMs; + } + + public getTime() { + return DateMock.mockTimeMs; + } +} + +// Sets up a mock date time for the current test. Returns a function that should be called to reset the environment +function setupDateMock(startMockTime: number) { + const realDate = window.Date; + DateMock.mockTimeMs = startMockTime; + (window.Date as any) = DateMock; + + return () => (window.Date = realDate); +} + +// Gets a mock cache entry from an item key, all entries returned will be for the same document. +function getMockCacheEntry( + itemKey: string, + options?: { docId: string }, +): ICacheEntry { + return { + file: { + docId: options?.docId || "myDocument", + resolvedUrl: { + type: "fluid", + url: "https://bing.com/myDocument", + id: "mockContainer", + tokens: {}, + endpoints: {}, + }, + }, + type: "snapshot", + key: itemKey, + }; +} + +describe("Fluid Cache tests", () => { + beforeEach(() => { + // Reset the indexed db before each test so that it starts off in an empty state + // eslint-disable-next-line max-len + // eslint-disable-next-line import/no-internal-modules, @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const FDBFactory = require("fake-indexeddb/lib/FDBFactory"); + (window.indexedDB as any) = new FDBFactory(); + }); + + it("returns undefined when there is nothing in the cache", async () => { + const fluidCache = getFluidCache(); + + const result = await fluidCache.get( + getMockCacheEntry("shouldNotExist"), + ); + expect(result).toBeUndefined(); + }); + + it("returns an item put in the cache", async () => { + const fluidCache = getFluidCache(); + + const cacheEntry = getMockCacheEntry("shouldExist"); + const cachedItem = { foo: "bar" }; + + await fluidCache.put(cacheEntry, cachedItem); + + const result = await fluidCache.get(cacheEntry); + expect(result).toEqual(cachedItem); + }); + + it("returns an item put in the cache when max ops has not passed", async () => { + const fluidCache = getFluidCache(); + + const cacheEntry = getMockCacheEntry("stillGood"); + const cachedItem = { foo: "bar" }; + + await fluidCache.put(cacheEntry, cachedItem); + + const result = await fluidCache.get(cacheEntry); + expect(result).toEqual(cachedItem); + }); + + it("does not return an item from the cache that is older than maxCacheItemAge", async () => { + const clearTimeMock = setupDateMock(100); + + const fluidCache = getFluidCache({ maxCacheItemAge: 5000 }); + + const cacheEntry = getMockCacheEntry("tooOld"); + const cachedItem = { foo: "bar" }; + + await fluidCache.put(cacheEntry, cachedItem); + + expect(await fluidCache.get(cacheEntry)).toEqual(cachedItem); + + DateMock.mockTimeMs += 5050; + + const result = await fluidCache.get(cacheEntry); + expect(result).toBeUndefined(); + + clearTimeMock(); + }); + + it("does not return items from the cache when the partition keys do not match", async () => { + const fluidCache = getFluidCache({ partitionKey: "partitionKey1" }); + + const cacheEntry = getMockCacheEntry("partitionKey1Data"); + const cachedItem = { foo: "bar" }; + await fluidCache.put(cacheEntry, cachedItem); + + expect(await fluidCache.get(cacheEntry)).toEqual(cachedItem); + + // We should not return the data from partition 1 when in partition 2 + const partition2FluidCache = getFluidCache({ + partitionKey: "partitionKey2", + }); + expect(await partition2FluidCache.get(cacheEntry)).toEqual(undefined); + }); + + it("returns values from cache when partition key is null", async () => { + const fluidCache = getFluidCache({ partitionKey: null }); + + const cacheEntry = getMockCacheEntry("partitionKey1Data"); + const cachedItem = { foo: "bar" }; + await fluidCache.put(cacheEntry, cachedItem); + + expect(await fluidCache.get(cacheEntry)).toEqual(cachedItem); + }); + + it("implements the removeAllEntriesForDocId API", async () => { + const fluidCache = getFluidCache(); + + const docId1Entry1 = getMockCacheEntry("docId1Entry1", { + docId: "docId1", + }); + const docId2Entry1 = getMockCacheEntry("docId2Entry1", { + docId: "docId2", + }); + const docId1Entry2 = getMockCacheEntry("docId1Entry2", { + docId: "docId1", + }); + + await fluidCache.put(docId1Entry1, {}); + await fluidCache.put(docId2Entry1, {}); + await fluidCache.put(docId1Entry2, {}); + + expect(await fluidCache.get(docId1Entry1)).not.toBeUndefined(); + expect(await fluidCache.get(docId2Entry1)).not.toBeUndefined(); + expect(await fluidCache.get(docId1Entry2)).not.toBeUndefined(); + + await fluidCache.removeEntries(docId1Entry1.file); + + expect(await fluidCache.get(docId1Entry1)).toBeUndefined(); + expect(await fluidCache.get(docId2Entry1)).not.toBeUndefined(); + expect(await fluidCache.get(docId1Entry2)).toBeUndefined(); + }); + + // The tests above test the public API of Fluid Cache. + // Those tests should not break if we changed the implementation. + // The tests below test implementation details of the Fluid Cache, such as the usage of indexedDB. + it("writes cached values to indexedDb", async () => { + // We need to mock out the Date API to make this test work + const clearDateMock = setupDateMock(100); + + const fluidCache = getFluidCache(); + + const cacheEntry = getMockCacheEntry("shouldBeInLocalStorage"); + const cachedItem = { dateToStore: "foo" }; + + await fluidCache.put(cacheEntry, cachedItem); + + const db = await getFluidCacheIndexedDbInstance(); + expect( + await db.get( + FluidDriverObjectStoreName, + getKeyForCacheEntry(cacheEntry), + ), + ).toEqual({ + cacheItemId: "shouldBeInLocalStorage", + cachedObject: { + dateToStore: "foo", + }, + createdTimeMs: 100, + fileId: "myDocument", + lastAccessTimeMs: 100, + type: "snapshot", + partitionKey: "FAKEPARTITIONKEY", + }); + + clearDateMock(); + }); + + it("updates the last accessed time when reading a value from storage", async () => { + // We need to mock out the Date API to make this test work + const clearDateMock = setupDateMock(100); + const fluidCache = getFluidCache(); + + const cacheEntry = getMockCacheEntry("shouldUpdateLastAccessedTime"); + const cachedItem = { dateToStore: "foo" }; + + await fluidCache.put(cacheEntry, cachedItem); + + const db = await getFluidCacheIndexedDbInstance(); + + expect( + ( + await db.get( + FluidDriverObjectStoreName, + getKeyForCacheEntry(cacheEntry), + ) + )?.lastAccessTimeMs, + ).toBe(100); + + DateMock.mockTimeMs = 800; + await fluidCache.get(cacheEntry); + + expect( + ( + await db.get( + FluidDriverObjectStoreName, + getKeyForCacheEntry(cacheEntry), + ) + )?.lastAccessTimeMs, + ).toEqual(800); + + clearDateMock(); + }); + + it("does not throw when APIs are called and the database has been upgraded by another client", async () => { + // Create a DB with a much newer version number to simulate an old client + await openDB(FluidDriverCacheDBName, 1000000); + + const fluidCache = getFluidCache(); + + const cacheEntry = getMockCacheEntry("someKey"); + const cachedItem = { dateToStore: "foo" }; + await fluidCache.put(cacheEntry, cachedItem); + + const result = await fluidCache.get(cacheEntry); + expect(result).toEqual(undefined); + }); + + it("does not hang when an older client is blocking the database from opening", async () => { + await openDB(FluidDriverCacheDBName, 1); + + const fluidCache = getFluidCache(); + + const cacheEntry = getMockCacheEntry("someKey"); + const cachedItem = { dateToStore: "foo" }; + await fluidCache.put(cacheEntry, cachedItem); + + const result = await fluidCache.get(cacheEntry); + expect(result).toEqual(undefined); + }); +}); diff --git a/packages/drivers/driver-web-cache/src/test/FluidCacheIndexedDb.test.ts b/packages/drivers/driver-web-cache/src/test/FluidCacheIndexedDb.test.ts new file mode 100644 index 000000000000..7b1f8f5b7591 --- /dev/null +++ b/packages/drivers/driver-web-cache/src/test/FluidCacheIndexedDb.test.ts @@ -0,0 +1,110 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { openDB } from "idb"; +import { + getFluidCacheIndexedDbInstance, + oldVersionNameMapping, + FluidDriverCacheDBName, + FluidDriverObjectStoreName, + CurrentCacheVersion, +} from "../FluidCacheIndexedDb"; +import { FluidCacheErrorEvent } from "../fluidCacheTelemetry"; +// eslint-disable-next-line max-len +// eslint-disable-next-line import/no-unassigned-import, @typescript-eslint/no-require-imports, import/no-internal-modules +require("fake-indexeddb/auto"); + +class MockLogger { + NamespaceLogger = this; + send = jest.fn(); +} + +const versions = Object.keys(oldVersionNameMapping); + +// Dynamically get the test cases for successful upgrades to run though all old versions +const getUpgradeTestCases = (versionsArray: string[]): any[] => { + const testCases: any[] = []; + versionsArray.map((value: string) => { + testCases.push([ + `upgrades successfully without an error for version number ${value}`, + { oldVersionNumber: parseInt(value, 10 /* base10 */) }, + ]); + }); + return testCases; +}; +const upgradeTestCases = getUpgradeTestCases(versions); + +describe("getFluidCacheIndexedDbInstance", () => { + beforeEach(() => { + // Reset the indexed db before each test so that it starts off in an empty state + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, import/no-internal-modules + const FDBFactory = require("fake-indexeddb/lib/FDBFactory"); + (window.indexedDB as any) = new FDBFactory(); + }); + + // The jest types in the FF repo are old, so it doesn't have the each signature. + // This typecast can be removed when the types are bumped. + (it as any).each(upgradeTestCases)( + "%s", + async (_, { oldVersionNumber }) => { + // Arrange + // Create a database with the old version number + const oldDb = await openDB( + FluidDriverCacheDBName, + oldVersionNumber, + { + upgrade: (dbToUpgrade) => { + // Create the old object to simulate what state we would be in + dbToUpgrade.createObjectStore( + oldVersionNameMapping[oldVersionNumber]!, + ); + }, + }, + ); + oldDb.close(); // Close so the upgrade won't be blocked + + // Act + // Now attempt to get the FluidCache instance, which will run the upgrade function + const db = await getFluidCacheIndexedDbInstance(); + + // Assert + expect(db.objectStoreNames).toEqual([FluidDriverObjectStoreName]); + expect(db.name).toEqual(FluidDriverCacheDBName); + expect(db.version).toEqual(CurrentCacheVersion); + }, + ); + + it("if error thrown in deletion of old database, is swallowed and logged", async () => { + // Arrange + // Create a database with the old version number, but DONT create the data store. + // This will cause an error that we should catch in the upgrade function where we + // delete the old data store. + const oldDb = await openDB( + FluidDriverCacheDBName, + CurrentCacheVersion - 1, + ); + oldDb.close(); // Close so the upgrade won't be blocked + + const logger = new MockLogger(); + const sendSpy = jest.spyOn(logger, "send"); + + // Act + // Now attempt to get the FluidCache instance, which will run the upgrade function + const db = await getFluidCacheIndexedDbInstance(logger); + + // Assert + // We catch the error and send it to the logger + expect(sendSpy.mock.calls).toHaveLength(1); + expect(sendSpy.mock.calls[0][0].eventName).toEqual( + FluidCacheErrorEvent.FluidCacheDeleteOldDbError, + ); + + // The cache was still created as expected + expect(db.objectStoreNames).toEqual([FluidDriverObjectStoreName]); + expect(db.name).toEqual(FluidDriverCacheDBName); + expect(db.version).toEqual(CurrentCacheVersion); + }); +}); diff --git a/packages/drivers/driver-web-cache/src/test/tsconfig.json b/packages/drivers/driver-web-cache/src/test/tsconfig.json new file mode 100644 index 000000000000..68caf64c2f41 --- /dev/null +++ b/packages/drivers/driver-web-cache/src/test/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@fluidframework/build-common/ts-common-config.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "../../dist/test", + "types": [ + "jest", + "node" + ], + "declaration": false, + "declarationMap": false, + "skipLibCheck": true + }, + "include": [ + "./**/*" + ], + "references": [ + { + "path": "../.." + } + ] +} \ No newline at end of file diff --git a/packages/drivers/driver-web-cache/tsconfig.esnext.json b/packages/drivers/driver-web-cache/tsconfig.esnext.json new file mode 100644 index 000000000000..ca3d4c50b989 --- /dev/null +++ b/packages/drivers/driver-web-cache/tsconfig.esnext.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./lib", + "module": "esnext" + }, +} \ No newline at end of file diff --git a/packages/drivers/driver-web-cache/tsconfig.json b/packages/drivers/driver-web-cache/tsconfig.json new file mode 100644 index 000000000000..909f34efbe61 --- /dev/null +++ b/packages/drivers/driver-web-cache/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@fluidframework/build-common/ts-common-config.json", + "exclude": [ + "src/test/**/*" + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "composite": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/packages/drivers/routerlicious-driver/package.json b/packages/drivers/routerlicious-driver/package.json index 1213433da284..c6f86b9c96a1 100644 --- a/packages/drivers/routerlicious-driver/package.json +++ b/packages/drivers/routerlicious-driver/package.json @@ -84,7 +84,7 @@ "@types/mocha": "^8.2.2", "@types/nock": "^9.3.0", "@types/socket.io-client": "^1.4.32", - "@types/url-parse": "^1.4.4", + "@types/url-parse": "1.4.4", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "~5.9.0", "@typescript-eslint/parser": "~5.9.0",