♻️(service-worker) add DocsDB to store docs in indexedDB

Export the storage of docs to a separate
class `DocsDB` that will be responsible for
storing / retrieving / synching docs from indexedDB.
This commit is contained in:
Anthony LC
2024-06-20 15:10:54 +02:00
committed by Anthony LC
parent b4e8db9050
commit a0644efb45
3 changed files with 205 additions and 116 deletions

View File

@@ -1,31 +1,10 @@
import { DBSchema, openDB } from 'idb';
import { WorkboxPlugin } from 'workbox-core';
import { Doc, DocsResponse } from '@/features/docs';
import { RequestData, RequestSerializer } from './RequestSerializer';
import { DBRequest, DocsDB } from './DocsDB';
import { RequestSerializer } from './RequestSerializer';
import { SyncManager } from './SyncManager';
import { getApiCatchHandler } from './utils';
type DBRequest = {
requestData: RequestData;
key: string;
};
interface DocsDB extends DBSchema {
'doc-list': {
key: string;
value: DocsResponse;
};
'doc-item': {
key: string;
value: Doc;
};
'doc-mutation': {
key: string;
value: DBRequest;
};
}
interface OptionsReadonly {
tableName: 'doc-list' | 'doc-item';
@@ -33,35 +12,30 @@ interface OptionsReadonly {
}
interface OptionsMutate {
tableName: 'doc-mutation';
type: 'update' | 'delete' | 'create';
}
type Options = (OptionsReadonly | OptionsMutate) & { syncManager: SyncManager };
export class ApiPlugin implements WorkboxPlugin {
private static readonly DBNAME = 'api-docs-db';
private readonly options: Options;
private isFetchDidFailed = false;
private initialRequest?: Request;
static getApiCatchHandler = () => {
return new Response(JSON.stringify({ error: 'Network is unavailable.' }), {
status: 502,
statusText: 'Network is unavailable.',
headers: {
'Content-Type': 'application/json',
},
});
};
constructor(options: Options) {
this.options = options;
}
/**
* Save the response in the IndexedDB.
*/
private async cacheResponse(
key: string,
body: DocsResponse | Doc | DBRequest,
tableName: Options['tableName'],
): Promise<void> {
const db = await ApiPlugin.DB();
await db.put(tableName, body, key);
}
/**
* This method is called after the response is received.
* An error server response is not a failed fetch.
@@ -75,17 +49,9 @@ export class ApiPlugin implements WorkboxPlugin {
return response;
}
try {
const tableName = this.options.tableName;
const body = (await response.clone().json()) as DocsResponse | Doc;
await this.cacheResponse(request.url, body, tableName);
} catch (error) {
console.error(
'SW-DEV: Failed to save response in IndexedDB',
error,
this.options,
);
}
const tableName = this.options.tableName;
const body = (await response.clone().json()) as DocsResponse | Doc;
await DocsDB.cacheResponse(request.url, body, tableName);
}
return response;
@@ -119,28 +85,22 @@ export class ApiPlugin implements WorkboxPlugin {
*/
handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => {
if (!this.isFetchDidFailed) {
return Promise.resolve(getApiCatchHandler());
return Promise.resolve(ApiPlugin.getApiCatchHandler());
}
/**
* Update the cache item to sync it later.
*/
if (this.options.type === 'update') {
return this.handlerDidErrorUpdate(request);
switch (this.options.type) {
case 'update':
return this.handlerDidErrorUpdate(request);
case 'list':
case 'item':
return this.handlerDidErrorRead(this.options.tableName, request.url);
}
/**
* Get data from the cache.
*/
if (this.options.type === 'list' || this.options.type === 'item') {
return this.handlerDidErrorRead(this.options.tableName, request.url);
}
return Promise.resolve(getApiCatchHandler());
return Promise.resolve(ApiPlugin.getApiCatchHandler());
};
private handlerDidErrorUpdate = async (request: Request) => {
const db = await openDB<DocsDB>(ApiPlugin.DBNAME, 1);
const db = await DocsDB.open();
const storedResponse = await db.get('doc-item', request.url);
if (!storedResponse || !this.initialRequest) {
@@ -159,7 +119,7 @@ export class ApiPlugin implements WorkboxPlugin {
key: `${Date.now()}`,
};
await this.cacheResponse(
await DocsDB.cacheResponse(
serializeRequest.key,
serializeRequest,
'doc-mutation',
@@ -177,7 +137,7 @@ export class ApiPlugin implements WorkboxPlugin {
...bodyMutate,
};
await db.put('doc-item', newResponse, request.url);
await DocsDB.cacheResponse(request.url, newResponse, 'doc-item');
/**
* Update the cache list with the new data.
@@ -205,9 +165,11 @@ export class ApiPlugin implements WorkboxPlugin {
return result;
});
await db.put('doc-list', list, key);
await DocsDB.cacheResponse(key, list, 'doc-list');
}
db.close();
/**
* All is good for our client, we return the new response.
*/
@@ -224,11 +186,11 @@ export class ApiPlugin implements WorkboxPlugin {
tableName: OptionsReadonly['tableName'],
url: string,
) => {
const db = await openDB<DocsDB>(ApiPlugin.DBNAME, 1);
const db = await DocsDB.open();
const storedResponse = await db.get(tableName, url);
if (!storedResponse) {
return Promise.resolve(getApiCatchHandler());
return Promise.resolve(ApiPlugin.getApiCatchHandler());
}
return new Response(JSON.stringify(storedResponse), {
@@ -239,49 +201,4 @@ export class ApiPlugin implements WorkboxPlugin {
},
});
};
public static hasSyncToDo = async () => {
const db = await ApiPlugin.DB();
const requests = await db.getAll('doc-mutation');
return requests.length > 0;
};
/**
* Sync the queue with the server.
*/
public static sync = async () => {
const db = await ApiPlugin.DB();
const requests = await db.getAll('doc-mutation');
for (const request of requests) {
try {
await fetch(new RequestSerializer(request.requestData).toRequest());
await db.delete('doc-mutation', request.key);
} catch (error) {
console.error('SW-DEV: Replay failed for request', request, error);
break;
}
}
};
/**
* IndexedDB instance.
* @returns Promise<IDBPDatabase<DocsDB>>
*/
private static DB = async () => {
return await openDB<DocsDB>(ApiPlugin.DBNAME, 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('doc-list')) {
db.createObjectStore('doc-list');
}
if (!db.objectStoreNames.contains('doc-item')) {
db.createObjectStore('doc-item');
}
if (!db.objectStoreNames.contains('doc-mutation')) {
db.createObjectStore('doc-mutation');
}
},
});
};
}

View File

@@ -0,0 +1,168 @@
import { DBSchema, IDBPDatabase, deleteDB, openDB } from 'idb';
import { Doc, DocsResponse } from '@/features/docs';
import { RequestData, RequestSerializer } from './RequestSerializer';
// eslint-disable-next-line import/order
import pkg from '@/../package.json';
export type DBRequest = {
requestData: RequestData;
key: string;
};
interface IDocsDB extends DBSchema {
'doc-list': {
key: string;
value: DocsResponse;
};
'doc-item': {
key: string;
value: Doc;
};
'doc-mutation': {
key: string;
value: DBRequest;
};
'doc-version': {
key: 'version';
value: number;
};
}
type TableName = 'doc-list' | 'doc-item' | 'doc-mutation';
/**
* IndexDB version must be a integer
* @returns
*/
const getCurrentVersion = () => {
const [major, minor, patch] = pkg.version.split('.');
return parseFloat(`${major}${minor}${patch}`);
};
/**
* Static class for managing the Docs with IndexedDB.
*/
export class DocsDB {
private static readonly DBNAME = 'api-docs-db';
/**
* IndexedDB instance.
* @returns Promise<IDBPDatabase<DocsDB>>
*/
public static open = async () => {
let db: IDBPDatabase<IDocsDB>;
try {
db = await openDB<IDocsDB>(DocsDB.DBNAME, getCurrentVersion(), {
upgrade(db) {
if (!db.objectStoreNames.contains('doc-list')) {
db.createObjectStore('doc-list');
}
if (!db.objectStoreNames.contains('doc-item')) {
db.createObjectStore('doc-item');
}
if (!db.objectStoreNames.contains('doc-mutation')) {
db.createObjectStore('doc-mutation');
}
if (!db.objectStoreNames.contains('doc-version')) {
db.createObjectStore('doc-version');
}
},
});
} catch (error) {
/**
* If for any reason the current version is lower than the previous one,
* we need to delete the database and create a new one.
*/
console.error('SW: Failed to open IndexedDB', error);
await deleteDB(DocsDB.DBNAME);
db = await DocsDB.open();
}
return db;
};
public static deleteAll = async (tableName: TableName) => {
const db = await DocsDB.open();
const keys = await db.getAllKeys(tableName);
for (const key of keys) {
await db.delete(tableName, key);
}
db.close();
};
public static cleanupOutdatedVersion = async () => {
const db = await DocsDB.open();
const version = await db.get('doc-version', 'version');
const currentVersion = getCurrentVersion();
if (version != currentVersion) {
console.debug('SW: Cleaning up outdated caches', version, currentVersion);
await DocsDB.deleteAll('doc-item');
await DocsDB.deleteAll('doc-list');
await DocsDB.deleteAll('doc-mutation');
await db.put('doc-version', currentVersion, 'version');
}
db.close();
};
/**
* Save the response in the IndexedDB.
*/
public static async cacheResponse(
key: string,
body: DocsResponse | Doc | DBRequest,
tableName: TableName,
): Promise<void> {
const db = await DocsDB.open();
try {
await db.put(tableName, body, key);
} catch (error) {
console.error(
'SW: Failed to save response in IndexedDB',
error,
key,
body,
);
}
db.close();
}
public static hasSyncToDo = async () => {
const db = await DocsDB.open();
const requests = await db.getAll('doc-mutation');
db.close();
return requests.length > 0;
};
/**
* Sync the queue with the server.
*/
public static sync = async () => {
const db = await DocsDB.open();
const requests = await db.getAll('doc-mutation');
for (const request of requests) {
try {
await fetch(new RequestSerializer(request.requestData).toRequest());
await db.delete('doc-mutation', request.key);
} catch (error) {
console.error('SW: Replay failed for request', request, error);
break;
}
}
db.close();
};
}

View File

@@ -2,11 +2,16 @@ import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
import { ApiPlugin } from './ApiPlugin';
import { DocsDB } from './DocsDB';
import { SyncManager } from './SyncManager';
declare const self: ServiceWorkerGlobalScope;
const syncManager = new SyncManager(ApiPlugin.sync, ApiPlugin.hasSyncToDo);
const syncManager = new SyncManager(DocsDB.sync, DocsDB.hasSyncToDo);
self.addEventListener('activate', function (event) {
event.waitUntil(DocsDB.cleanupOutdatedVersion());
});
export const isApiUrl = (href: string) => {
const devDomain = 'http://localhost:8071';
@@ -53,7 +58,6 @@ registerRoute(
new NetworkOnly({
plugins: [
new ApiPlugin({
tableName: 'doc-mutation',
type: 'update',
syncManager,
}),