♻️(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:
@@ -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');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
168
src/frontend/apps/impress/src/core/service-worker/DocsDB.ts
Normal file
168
src/frontend/apps/impress/src/core/service-worker/DocsDB.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user