Skip to content

Commit

Permalink
feat: add database plugin (#17)
Browse files Browse the repository at this point in the history
* feat: add database plugin

* bundle storage data on 'get' action

* add 'database-pagination' interactive event and add 'basic' type data

* get key from 'cursor.key'

* add database unit tests

* ignore engines
  • Loading branch information
wqcstrong authored Oct 30, 2023
1 parent 2516e3a commit 7cefbf2
Show file tree
Hide file tree
Showing 16 changed files with 497 additions and 89 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ module.exports = {
'no-case-declarations': 'off',
'func-names': 'off',
'no-plusplus': 'off',
'arrow-body-style': 'off',
},
};
5 changes: 4 additions & 1 deletion .github/workflows/coveralls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ jobs:
node-version: 16.x

- name: yarn install, yarn test
# fake-indexeddb using `structureClone` which import in node^18,
# and we import the polyfill with `import 'core-js/stable/structured-clone'`
# to resolve it, so here we pass --ignore-engines after `yarn install` to ignore the error.
run: |
yarn install
yarn install --ignore-engines
yarn test --silent
- name: Coveralls
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-plugin-import": "^2.27.5",
"express": "^4.18.2",
"fake-indexeddb": "^5.0.1",
"git-cz": "^4.7.6",
"jest": "^27.5.1",
"jest-canvas-mock": "^2.4.0",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import './index.less';
import logoUrl from './assets/logo.svg';
import { mergeConfig } from './utils/config';
import { ROOM_SESSION_KEY } from './utils/constants';
import { DatabasePlugin } from './plugins/database';

const Identifier = '__pageSpy';

Expand Down Expand Up @@ -66,6 +67,7 @@ export default class PageSpy {
new SystemPlugin(),
new PagePlugin(),
new StoragePlugin(),
new DatabasePlugin(),
);
this.init();
window.addEventListener('beforeunload', () => {
Expand Down
228 changes: 228 additions & 0 deletions src/plugins/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { DBInfo, DBStoreInfo } from 'types/lib/database';
import { psLog } from 'src/utils';
import socketStore from 'src/utils/socket';
import { DEBUG_MESSAGE_TYPE, makeMessage } from 'src/utils/message';
import { SpyDatabase } from 'types';
import PageSpyPlugin from '.';

export function promisify<T = any>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.addEventListener('success', () => {
resolve(req.result);
});
req.addEventListener('error', () => {
reject();
});
});
}

export class DatabasePlugin implements PageSpyPlugin {
public name = 'DatabasePlugin';

public static hasInitd = false;

// eslint-disable-next-line class-methods-use-this
public onCreated() {
if (DatabasePlugin.hasInitd) return;
DatabasePlugin.hasInitd = true;

DatabasePlugin.listenEvents();
DatabasePlugin.initIndexedDBProxy();
}

private static listenEvents() {
socketStore.addListener(DEBUG_MESSAGE_TYPE.REFRESH, async ({ source }) => {
if (source.data === 'indexedDB') {
const result = await this.takeBasicInfo();
const data: SpyDatabase.BasicTypeDataItem = {
action: 'basic',
result,
};
DatabasePlugin.sendData(data);
}
});
socketStore.addListener(
DEBUG_MESSAGE_TYPE.DATABASE_PAGINATION,
async ({ source }) => {
const { db, store, page } = source.data;
const result = await DatabasePlugin.getStoreDataWithPagination({
db,
store,
page,
});
DatabasePlugin.sendData(result);
},
);
}

private static initIndexedDBProxy() {
const {
put: originPut,
add: originAdd,
delete: originDelete,
clear: originClear,
} = IDBObjectStore.prototype;
const { sendData } = DatabasePlugin;

const originProxyList = [
{
origin: originPut,
method: 'put',
},
{
origin: originAdd,
method: 'add',
},
{
origin: originDelete,
method: 'delete',
},
{
origin: originClear,
method: 'clear',
},
] as const;

originProxyList.forEach(({ origin, method }) => {
IDBObjectStore.prototype[method] = function (...args: any) {
const req = (origin as any).apply(this, args);
const data = {
action: method === 'clear' ? 'clear' : 'update',
database: this.transaction.db.name,
store: this.name,
} as const;
req.addEventListener('success', () => {
sendData(data);
});
return req;
};
});

const originDrop = IDBFactory.prototype.deleteDatabase;
IDBFactory.prototype.deleteDatabase = function (name: string) {
const req = originDrop.call(this, name);
const data: SpyDatabase.DropTypeDataItem = {
action: 'drop',
database: name,
};
req.addEventListener('success', () => {
sendData(data);
});
return req;
};
}

private static async takeBasicInfo() {
const dbs = await window.indexedDB.databases();
if (!dbs.length) {
return null;
}
const validDBs = dbs.filter(
(i) => i.name && i.version,
) as Required<IDBDatabaseInfo>[];
if (!validDBs.length) return null;

const data = await Promise.all(
validDBs.map((i) => DatabasePlugin.getDBData(i)),
);
return data.filter(Boolean) as DBInfo[];
}

private static async getDBData(info: Required<IDBDatabaseInfo>) {
try {
const result: DBInfo = {
name: info.name,
version: info.version,
stores: [],
};
const db = await promisify(
window.indexedDB.open(info.name, info.version),
);
if (db.objectStoreNames.length) {
const storeList = [...db.objectStoreNames].map((i) => {
return db.transaction(i, 'readonly').objectStore(i);
});
result.stores = storeList.map((store) => {
const { name, keyPath, autoIncrement, indexNames } = store;
const data: DBStoreInfo = {
name,
keyPath,
autoIncrement,
indexes: [...indexNames],
};
return data;
});
}
return result;
} catch (e: any) {
psLog.error(`Failed to get indexedDB data, more info: ${e.message}`);
return null;
}
}

private static async getStoreDataWithPagination({
db,
store,
page,
}: {
db: string;
store: string;
page: number;
}): Promise<SpyDatabase.GetTypeDataItem> {
const result: SpyDatabase.GetTypeDataItem = {
action: 'get',
database: null,
store: null,
page: {
current: page,
prev: null,
next: null,
},
total: 0,
data: [],
};
if (page < 1) return result;
const database = await promisify(window.indexedDB.open(db));
const objStore = database.transaction(store, 'readonly').objectStore(store);
result.database = {
name: database.name,
version: database.version,
};
result.store = {
name: objStore.name,
keyPath: objStore.keyPath,
autoIncrement: objStore.autoIncrement,
indexes: [...objStore.indexNames],
};
result.total = await promisify(objStore.count());

const lowerBound = 50 * (page - 1);
const upperBound = 50 * page;
result.page.prev = page > 1 ? page - 1 : null;
result.page.next = lowerBound + 50 < result.total ? page + 1 : null;

let currentIndex = 0;
const cursorRequest = objStore.openCursor();
return new Promise((resolve, reject) => {
cursorRequest.addEventListener('success', () => {
const cursor = cursorRequest.result;
if (cursor) {
if (currentIndex >= lowerBound && currentIndex < upperBound) {
result.data.push({ key: cursor.key, value: cursor.value });
}
currentIndex++;
cursor.continue();
} else {
resolve(result);
}
});
cursorRequest.addEventListener('error', reject);
});
}

private static sendData(info: Omit<SpyDatabase.DataItem, 'id'>) {
const data = makeMessage(DEBUG_MESSAGE_TYPE.DATABASE, info);
// The user wouldn't want to get the stale data, so here we set the 2nd parameter to true.
socketStore.broadcastMessage(data, true);
}
}
Loading

0 comments on commit 7cefbf2

Please sign in to comment.