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

Saddlebag now comes with IndexedDB support #6

Merged
merged 2 commits into from
Apr 5, 2024
Merged
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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pb33f/saddlebag",
"version": "0.0.0",
"version": "0.1.0",
"license": "MIT",
"private": false,
"description": "A tiny, pure JavaScript in-memory object store library for simple application state management",
Expand Down Expand Up @@ -28,7 +28,8 @@
"i": "^0.3.7",
"typescript": "^5.0.2",
"vite": "^4.3.2",
"vitest": "^0.31.1"
"vitest": "^0.31.1",
"fake-indexeddb": "^5.0.2"
},
"files": [
"dist"
Expand Down
85 changes: 76 additions & 9 deletions src/bag.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {CreateBag} from "./saddlebag_engine.ts";
import {Bag} from "./saddlebag.ts";


export const BAG_OBJECT_STORE = 'bags';
export const BAG_DB_NAME = 'saddlebag';

/**
* BagManager is a singleton that manages all the bags in the application.
*/
Expand All @@ -25,31 +28,95 @@ export interface BagManager {
* Reset all bags to their initial state.
*/
resetBags(): void;

/**
* Load all stateful bags from IndexedDB.
* @return {Promise<BagDB>} a promise that resolves when all bags are loaded
*/
loadStatefulBags(): Promise<BagDB>;

/**
* Get the indexedDB database
* @return {IDBDatabase | null} the indexedDB database
*/
get db(): IDBDatabase | null;

}

interface BagDB {
db: IDBDatabase | undefined;
}

class saddlebagManager implements BagManager {
private _bags: Map<string, Bag<any>>;
private readonly _stateful: boolean;
private _db: IDBDatabase | undefined

constructor() {
constructor(stateful: boolean) {
this._bags = new Map<string, Bag<any>>();
this._stateful = stateful;
}

loadStatefulBags(): Promise<BagDB> {
return new Promise<BagDB>((resolve) => {
const request = indexedDB.open(BAG_DB_NAME, 1);
request.onupgradeneeded = () => {
// @ts-ignore
this._db = request.result
this._db.createObjectStore(BAG_OBJECT_STORE);
};

request.onsuccess = () => {
// @ts-ignore
this._db = request.result

if (this._db) {
const tx = this._db.transaction(BAG_OBJECT_STORE)
const cursor = tx.objectStore(BAG_OBJECT_STORE).openCursor()

cursor.onsuccess = (event) => {
// @ts-ignore
let cursor = event.target.result;
if (cursor) {
let key = cursor.primaryKey;
let value = cursor.value;
const bag = this.createBag(key);
bag.populate(value);
this._bags.set(key, bag);
cursor.continue();
} else {
resolve({db: this._db});
}
}
}
}
})
}

get db(): IDBDatabase | null {
if (this._db) {
return this._db;
}
return null;
}

createBag<T>(key: string): Bag<T> {
const store: Bag<T> = CreateBag<T>();
this._bags.set(key, store);
return store;
const bag: Bag<T> = CreateBag<T>(key, this._stateful);
bag.db = this._db;
this._bags.set(key, bag);
return bag;
}

getBag<T>(key: string): Bag<T> | undefined {
if (this._bags.has(key)) {
return this._bags.get(key);
}
return CreateBag<T>();
return this.createBag(key)
}

resetBags() {
this._bags.forEach((store: Bag<any>) => {
store.reset();
this._bags.forEach((bag: Bag<any>) => {
bag.reset();
});
}
}
Expand All @@ -60,9 +127,9 @@ let _bagManagerSingleton: BagManager;
* CreateBagManager creates a singleton BagManager.
* @returns {BagManager} the singleton BagManager
*/
export function CreateBagManager(): BagManager {
export function CreateBagManager(stateful?: boolean): BagManager {
if (!_bagManagerSingleton) {
_bagManagerSingleton = new saddlebagManager();
_bagManagerSingleton = new saddlebagManager(stateful || false);
}
return _bagManagerSingleton;
}
Expand Down
15 changes: 15 additions & 0 deletions src/saddlebag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,23 @@ export interface Bag<T = any> {
* key is changed, the callback will be called with the new value.
* @param {string} key to be monitored
* @param {BagValueSubscriptionFunction<T>} callback to fire on every change, updated value will be passed
* @return {Subscription} a handle to the subscription
*/
subscribe(key: string, callback: BagValueSubscriptionFunction<T>): Subscription;

/**
* onAllChanges will add a subscription to the bag for all changes. Any time that
* any value changes in the bag, subscribers will be notified.
* @param {BagAllChangeSubscriptionFunction<T>} callback to fire on every change
* @return {Subscription} a handle to the subscription
*/
onAllChanges(callback: BagAllChangeSubscriptionFunction<T>): Subscription;

/**
* onPopulated will add a subscription to the bag for when the bag is populated with
* any data. The entire contents of the bag will be passed to the callback.
* @param {BagPopulatedSubscriptionFunction<T>} callback
* @return {Subscription} a handle to the subscription
*/
onPopulated(callback: BagPopulatedSubscriptionFunction<T>): Subscription;

Expand All @@ -81,5 +84,17 @@ export interface Bag<T = any> {
* and popular.
*/
reset(): void;

/**
* id is the unique identifier of the bag.
* @return {string} the unique identifier of the bag
*/
get id(): string;

/**
* db is the IndexedID database that the bag is associated with.
* @param db {IDBDatabase | undefined} indexedDB used to store the bag.
*/
set db(db: IDBDatabase | undefined);
}

79 changes: 72 additions & 7 deletions src/saddlebag_engine.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,74 @@
import {describe, expect, it } from 'vitest'
import {CreateBag} from "./saddlebag_engine";
import {BAG_OBJECT_STORE, CreateBagManager} from "./bag.manager";

import indexeddb from 'fake-indexeddb';

// @ts-ignore
globalThis.indexedDB = indexeddb;

describe('store basics', () => {

it('create a new stateful bag and add an item to it, then check its still there', async (): Promise<void> => {

const bm = CreateBagManager(true)
expect(bm).toBeDefined();
expect(bm.db).toBeNull();

const p = new Promise<void>((resolve) => {

bm.loadStatefulBags().then(() => {

expect(bm.db).toBeDefined();

const bag = bm.createBag<string>('foo');
if (bag) {
expect(bag.id).toEqual('foo');
expect(bag.get('foo')).toBeUndefined();
bag.set('foo', 'bar');
expect(bag.get('foo')).toEqual('bar');
}

bm.db.transaction([BAG_OBJECT_STORE])
.objectStore(BAG_OBJECT_STORE).get('foo').onsuccess = (event: any) => {

const result = event.target.result;
expect(result).toBeDefined();
expect(result.get('foo')).toEqual('bar');

// create a new bag manager and then try again, should be the same result
const bm2 = CreateBagManager(true)
expect(bm2).toBeDefined();

bm2.loadStatefulBags().then(() => {
const bag2 = bm2.getBag<string>('foo');
if (bag2) {
expect(bag2.get('foo')).toEqual('bar');


// now reset the bag and check its gone from the db
bag2.reset();
const bm3 = CreateBagManager(true)
expect(bm3).toBeDefined();

bm3.loadStatefulBags().then(() => {

// should be gone.
expect(bag2.get('foo')).toBeUndefined()
resolve(result)

});
}
})
}
})
})
return p
}, 500)


it('create a new bag and add an item to it', () => {
const bag = CreateBag<string>();
const bag = CreateBag<string>('boo');
expect(bag).toBeDefined();
expect(bag).not.toBeNull();
expect(bag.get('foo')).toBeUndefined();
Expand All @@ -13,10 +77,11 @@ describe('store basics', () => {
})

it('subscribe and unsubscribe to a store', () => {
const bag = CreateBag<string>();
const bag = CreateBag<string>('foo');

let counter = 0;

// @ts-ignore
const sub1 = bag.subscribe('foo', (value: string) => {
counter++;
})
Expand All @@ -41,7 +106,7 @@ describe('store basics', () => {
});

it('subscribe and unsubscribe to a store for all changes', () => {
const bag = CreateBag<string>();
const bag = CreateBag<string>('foo');

let counter = 0;

Expand All @@ -64,7 +129,7 @@ describe('store basics', () => {
});

it('subscribe and unsubscribe to a store on population', () => {
const bag = CreateBag<string>();
const bag = CreateBag<string>('foo');

let counter = 0;

Expand Down Expand Up @@ -92,7 +157,7 @@ describe('store basics', () => {
});

it('reset a bag', () => {
const bag = CreateBag<string>();
const bag = CreateBag<string>('foo');

let counter = 0;

Expand All @@ -108,7 +173,7 @@ describe('store basics', () => {
});

it('check a get value cannot be mutated', () => {
const bag = CreateBag();
const bag = CreateBag('foo');
const bar = { sleepy: 'time' };

bag.set('k', bar);
Expand All @@ -120,7 +185,7 @@ describe('store basics', () => {


it('check population cannot be mutated after storing', () => {
const bag = CreateBag();
const bag = CreateBag('foo');
const bar = { sleepy: 'time' };


Expand Down
33 changes: 30 additions & 3 deletions src/saddlebag_engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {
BagPopulatedSubscriptionFunction,
BagValueSubscriptionFunction, Subscription
} from "./saddlebag.ts";
import {BAG_OBJECT_STORE} from "./bag.manager.ts";

export function CreateBag<T>(): Bag<T> {
return new bag<T>();
export function CreateBag<T>(id: string, stateful?: boolean): Bag<T> {
return new bag<T>(id, stateful);
}

export class BagSubscription<T> {
Expand Down Expand Up @@ -65,21 +66,41 @@ export class BagSubscription<T> {
}

class bag<T> {
private _id: string;
private _stateful: boolean;
private _values: Map<string, T>;
private _db: IDBDatabase | undefined;

_subscriptions: Map<string, BagValueSubscriptionFunction<T>[]>;
_allChangesSubscriptions: BagAllChangeSubscriptionFunction<T>[];
_storePopulatedSubscriptions: BagPopulatedSubscriptionFunction<T>[];

constructor() {
constructor(id: string, stateful?: boolean) {
this._values = new Map<string, T>();
this._subscriptions = new Map<string, BagValueSubscriptionFunction<T>[]>()
this._allChangesSubscriptions = [];
this._storePopulatedSubscriptions = [];
this._stateful = stateful || false;
this._id = id;
}

set(key: string, value: T): void {
this._values.set(key, structuredClone(value));
this.alertSubscribers(key, value)

if (this._stateful && this._db) {
this._db.transaction([BAG_OBJECT_STORE], 'readwrite')
.objectStore(BAG_OBJECT_STORE)
.put(this._values, this._id);
}
}

get id(): string {
return this._id;
}

set db(db: IDBDatabase | undefined) {
this._db = db;
}

reset(): void {
Expand All @@ -88,6 +109,12 @@ class bag<T> {
this.alertSubscribers(key, undefined); // the value is gone!
});
this._values = new Map<string, T>();
// if there is a db, wipe out the bag in the db
if (this._stateful && this._db) {
this._db.transaction([BAG_OBJECT_STORE], 'readwrite')
.objectStore(BAG_OBJECT_STORE)
.delete(this._id);
}
}

private alertSubscribers(key: string, value: T | undefined): void {
Expand Down
Loading
Loading