♻️(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 { WorkboxPlugin } from 'workbox-core';
import { Doc, DocsResponse } from '@/features/docs'; 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 { 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 { interface OptionsReadonly {
tableName: 'doc-list' | 'doc-item'; tableName: 'doc-list' | 'doc-item';
@@ -33,35 +12,30 @@ interface OptionsReadonly {
} }
interface OptionsMutate { interface OptionsMutate {
tableName: 'doc-mutation';
type: 'update' | 'delete' | 'create'; type: 'update' | 'delete' | 'create';
} }
type Options = (OptionsReadonly | OptionsMutate) & { syncManager: SyncManager }; type Options = (OptionsReadonly | OptionsMutate) & { syncManager: SyncManager };
export class ApiPlugin implements WorkboxPlugin { export class ApiPlugin implements WorkboxPlugin {
private static readonly DBNAME = 'api-docs-db';
private readonly options: Options; private readonly options: Options;
private isFetchDidFailed = false; private isFetchDidFailed = false;
private initialRequest?: Request; 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) { constructor(options: Options) {
this.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. * This method is called after the response is received.
* An error server response is not a failed fetch. * An error server response is not a failed fetch.
@@ -75,17 +49,9 @@ export class ApiPlugin implements WorkboxPlugin {
return response; return response;
} }
try { const tableName = this.options.tableName;
const tableName = this.options.tableName; const body = (await response.clone().json()) as DocsResponse | Doc;
const body = (await response.clone().json()) as DocsResponse | Doc; await DocsDB.cacheResponse(request.url, body, tableName);
await this.cacheResponse(request.url, body, tableName);
} catch (error) {
console.error(
'SW-DEV: Failed to save response in IndexedDB',
error,
this.options,
);
}
} }
return response; return response;
@@ -119,28 +85,22 @@ export class ApiPlugin implements WorkboxPlugin {
*/ */
handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => { handlerDidError: WorkboxPlugin['handlerDidError'] = async ({ request }) => {
if (!this.isFetchDidFailed) { if (!this.isFetchDidFailed) {
return Promise.resolve(getApiCatchHandler()); return Promise.resolve(ApiPlugin.getApiCatchHandler());
} }
/** switch (this.options.type) {
* Update the cache item to sync it later. case 'update':
*/ return this.handlerDidErrorUpdate(request);
if (this.options.type === 'update') { case 'list':
return this.handlerDidErrorUpdate(request); case 'item':
return this.handlerDidErrorRead(this.options.tableName, request.url);
} }
/** return Promise.resolve(ApiPlugin.getApiCatchHandler());
* 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());
}; };
private handlerDidErrorUpdate = async (request: Request) => { 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); const storedResponse = await db.get('doc-item', request.url);
if (!storedResponse || !this.initialRequest) { if (!storedResponse || !this.initialRequest) {
@@ -159,7 +119,7 @@ export class ApiPlugin implements WorkboxPlugin {
key: `${Date.now()}`, key: `${Date.now()}`,
}; };
await this.cacheResponse( await DocsDB.cacheResponse(
serializeRequest.key, serializeRequest.key,
serializeRequest, serializeRequest,
'doc-mutation', 'doc-mutation',
@@ -177,7 +137,7 @@ export class ApiPlugin implements WorkboxPlugin {
...bodyMutate, ...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. * Update the cache list with the new data.
@@ -205,9 +165,11 @@ export class ApiPlugin implements WorkboxPlugin {
return result; 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. * All is good for our client, we return the new response.
*/ */
@@ -224,11 +186,11 @@ export class ApiPlugin implements WorkboxPlugin {
tableName: OptionsReadonly['tableName'], tableName: OptionsReadonly['tableName'],
url: string, url: string,
) => { ) => {
const db = await openDB<DocsDB>(ApiPlugin.DBNAME, 1); const db = await DocsDB.open();
const storedResponse = await db.get(tableName, url); const storedResponse = await db.get(tableName, url);
if (!storedResponse) { if (!storedResponse) {
return Promise.resolve(getApiCatchHandler()); return Promise.resolve(ApiPlugin.getApiCatchHandler());
} }
return new Response(JSON.stringify(storedResponse), { 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 { NetworkOnly } from 'workbox-strategies';
import { ApiPlugin } from './ApiPlugin'; import { ApiPlugin } from './ApiPlugin';
import { DocsDB } from './DocsDB';
import { SyncManager } from './SyncManager'; import { SyncManager } from './SyncManager';
declare const self: ServiceWorkerGlobalScope; 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) => { export const isApiUrl = (href: string) => {
const devDomain = 'http://localhost:8071'; const devDomain = 'http://localhost:8071';
@@ -53,7 +58,6 @@ registerRoute(
new NetworkOnly({ new NetworkOnly({
plugins: [ plugins: [
new ApiPlugin({ new ApiPlugin({
tableName: 'doc-mutation',
type: 'update', type: 'update',
syncManager, syncManager,
}), }),