Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(with-storage-sync): indexeddb support #129

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
>
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
<a mat-list-item routerLink="/reset">withReset</a>
<a mat-list-item routerLink="/indexeddb-sync">withIndexedDBSync</a>
</mat-nav-list>
</mat-drawer>
<mat-drawer-content>
Expand Down
43 changes: 42 additions & 1 deletion apps/demo/src/app/todo-storage-sync/synced-todo-store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { patchState, signalStore, withHooks, withMethods } from '@ngrx/signals';
import {
withEntities,
setEntity,
Expand All @@ -13,6 +13,9 @@ export const SyncedTodoStore = signalStore(
withEntities<Todo>(),
withStorageSync({
key: 'todos',
storage: 'indexedDB',
dbName: 'ngrx-toolkit',
storeName: 'todos',
}),
withMethods((store) => {
let currentId = 0;
Expand All @@ -33,5 +36,43 @@ export const SyncedTodoStore = signalStore(
);
},
};
}),
withHooks({
onInit(store) {
store.add({
name: 'Go for a Walk',
finished: false,
description:
'Go for a walk in the park to relax and enjoy nature. Walking is a great way to clear your mind and get some exercise. It can help reduce stress and improve your mood. Make sure to wear comfortable shoes and bring a bottle of water. Enjoy the fresh air and take in the scenery around you.',
});

store.add({
name: 'Read a Book',
finished: false,
description:
'Spend some time reading a book. It can be a novel, a non-fiction book, or any other genre you enjoy. Reading can help you relax and learn new things.',
});

store.add({
name: 'Write a Journal',
finished: false,
description:
'Take some time to write in your journal. Reflect on your day, your thoughts, and your feelings. Journaling can be a great way to process emotions and document your life.',
});

store.add({
name: 'Exercise',
finished: false,
description:
'Do some physical exercise. It can be a workout, a run, or any other form of exercise you enjoy. Exercise is important for maintaining physical and mental health.',
});

store.add({
name: 'Cook a Meal',
finished: false,
description:
'Prepare a meal for yourself or your family. Cooking can be a fun and rewarding activity. Try out a new recipe or make one of your favorite dishes.',
});
},
})
);
2 changes: 1 addition & 1 deletion libs/ngrx-toolkit/jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

export default {
displayName: 'ngrx-toolkit',
setupFiles: ['fake-indexeddb/auto', 'core-js'],
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/libs/ngrx-toolkit',
Expand Down
5 changes: 4 additions & 1 deletion libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export {
export * from './lib/with-call-state';
export * from './lib/with-undo-redo';
export * from './lib/with-data-service';
export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
export * from './lib/with-pagination';
export { withReset, setResetState } from './lib/with-reset';
export {
withStorageSync,
SyncConfig,
} from './lib/storageSync/with-storage-sync';
124 changes: 124 additions & 0 deletions libs/ngrx-toolkit/src/lib/storageSync/internal/indexeddb.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Injectable } from '@angular/core';

export const keyPath: string = 'ngrxToolkitId' as const;

@Injectable({ providedIn: 'root' })
export class IndexedDBService {
/**
* open indexedDB
* @param dbName
* @param storeName
* @param version
*/
private async openDB(
dbName: string,
storeName: string,
version: number | undefined = 1
): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);

request.onupgradeneeded = () => {
const db = request.result;

if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath });
}
};

request.onsuccess = (): void => {
resolve(request.result);
};

request.onerror = (): void => {
reject(request.error);
};
});
}

/**
* write to indexedDB
* @param dbName
* @param storeName
* @param data
*/
async write<T>(dbName: string, storeName: string, data: T): Promise<void> {
const db = await this.openDB(dbName, storeName);

const tx = db.transaction(storeName, 'readwrite');

const store = tx.objectStore(storeName);

store.put({
[keyPath]: keyPath,
value: data,
});

return new Promise((resolve, reject) => {
tx.oncomplete = (): void => {
db.close();
resolve();
};

tx.onerror = (): void => {
db.close();
reject();
};
});
}

/**
* read from indexedDB
* @param dbName
* @param storeName
*/
async read<T>(dbName: string, storeName: string): Promise<T> {
const db = await this.openDB(dbName, storeName);

const tx = db.transaction(storeName, 'readonly');

const store = tx.objectStore(storeName);

const request = store.get(keyPath);

return new Promise((resolve, reject) => {
request.onsuccess = (): void => {
db.close();
resolve(request.result?.['value']);
};

request.onerror = (): void => {
db.close();
reject();
};
});
}

/**
* delete indexedDB
* @param dbName
* @param storeName
* @returns
*/
async clear(dbName: string, storeName: string): Promise<void> {
const db = await this.openDB(dbName, storeName);

const tx = db.transaction(storeName, 'readwrite');

const store = tx.objectStore(storeName);

const request = store.delete(keyPath);

return new Promise((resolve, reject) => {
request.onsuccess = (): void => {
db.close();
resolve();
};

request.onerror = (): void => {
db.close();
reject();
};
});
}
}
92 changes: 92 additions & 0 deletions libs/ngrx-toolkit/src/lib/storageSync/internal/storage.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { inject, Injectable } from '@angular/core';
import { IndexedDBService } from './indexeddb.service';

export type StorageType = 'localStorage' | 'sessionStorage';

export type IndexedDBConfig = {
dbName?: string;
storeName?: string;
};

@Injectable({
providedIn: 'root',
})
export class StorageService {
private readonly indexedDB = inject(IndexedDBService);

private storage: Storage | null = null;

setStorage(storage: Storage): void {
this.storage = storage;
}

// get item from storage(localStorage, sessionStorage, indexedDB)
async getItem(key: string): Promise<string | null>;
async getItem(config: IndexedDBConfig): Promise<string | null>;

async getItem(configOrKey: IndexedDBConfig | string): Promise<string | null> {
if (typeof configOrKey === 'string') {
if (this.storage === null) {
throw new Error('Storage not set');
}

return this.storage.getItem(configOrKey);
}

const { dbName, storeName } = configOrKey;

if (dbName === undefined || storeName === undefined) {
throw new Error('dbName and storeName must be set');
}

return await this.indexedDB.read(dbName, storeName);
}

// set item in storage(localStorage, sessionStorage, indexedDB)
async setItem(key: string, value: string): Promise<void>;
async setItem(config: IndexedDBConfig, value: string): Promise<void>;

async setItem(
configOrKey: IndexedDBConfig | string,
value: string
): Promise<void> {
if (typeof configOrKey === 'string') {
if (this.storage === null) {
throw new Error('Storage not set');
}

this.storage.setItem(configOrKey, value);
return;
}

const { dbName, storeName } = configOrKey;

if (dbName === undefined || storeName === undefined) {
throw new Error('dbName and storeName must be set');
}

await this.indexedDB.write(dbName, storeName, value);
}
//
// // remove item from storage(localStorage, sessionStorage, indexedDB)
async removeItem(key: string): Promise<void>;
async removeItem(config: IndexedDBConfig): Promise<void>;

async removeItem(configOrKey: IndexedDBConfig | string): Promise<void> {
if (typeof configOrKey === 'string') {
if (this.storage === null) {
throw new Error('Storage not set');
}
this.storage.removeItem(configOrKey);
return;
}

const { dbName, storeName } = configOrKey;

if (dbName === undefined || storeName === undefined) {
throw new Error('dbName and storeName must be set');
}

return await this.indexedDB.clear(dbName, storeName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { IndexedDBService, keyPath } from '../internal/indexeddb.service';

describe('IndexedDBService', () => {
const dbName = 'ngrx-toolkit';

const storeName = 'users';

const sampleData = {
foo: 'bar',
users: [
{ name: 'John', age: 30, isAdmin: true },
{ name: 'Jane', age: 25, isAdmin: false },
],
};

let indexedDBService: IndexedDBService;

beforeEach(() => {
indexedDBService = new IndexedDBService();
});

it('It should be possible to write data using write() and then read the data using read()', async (): Promise<void> => {
const expectedData = { [keyPath]: keyPath, value: sampleData };

await indexedDBService.write(dbName, storeName, sampleData);

const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toEqual(expectedData.value);
});

it('It should be possible to delete data using clear()', async (): Promise<void> => {
await indexedDBService.write(dbName, storeName, sampleData);

await indexedDBService.clear(dbName, storeName);

const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toBeUndefined();
});

it('When there is no data, read() should return undefined', async (): Promise<void> => {
const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toBeUndefined();
});

it('write() should handle null data', async (): Promise<void> => {
await indexedDBService.write(dbName, storeName, null);

const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toBeNull();
});

it('write() should handle empty object data', async (): Promise<void> => {
const emptyData = {};
const expectedData = { [keyPath]: keyPath, value: emptyData };

await indexedDBService.write(dbName, storeName, emptyData);

const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toEqual(expectedData.value);
});

it('write() should handle large data objects', async (): Promise<void> => {
const largeData = { foo: 'a'.repeat(1000000) };
const expectedData = { [keyPath]: keyPath, value: largeData };

await indexedDBService.write(dbName, storeName, largeData);

const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toEqual(expectedData.value);
});

it('write() should handle special characters in data', async (): Promise<void> => {
const specialCharData = { foo: 'bar!@#$%^&*()_+{}:"<>?' };
const expectedData = { [keyPath]: keyPath, value: specialCharData };

await indexedDBService.write(dbName, storeName, specialCharData);

const receivedData = await indexedDBService.read(dbName, storeName);

expect(receivedData).toEqual(expectedData.value);
});
});
Loading