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: IndexedDB support #273

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
70 changes: 70 additions & 0 deletions drivers/idb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Class representing an IndexedDB storage, with a simplified API.
*
* @template T - The type of the value to be stored.
*/
export default class indexedDB<T> {
private defaultDb;
private defaultVersion;
private defaultObjectStore;
/**
* Creates an instance of the IndexedDB storage.
*/
constructor() {
this.defaultDb = "svelte-persisted-store";
this.defaultVersion = 1;
this.defaultObjectStore = "keyvaluepairs";
}
/**
* Retrieves an item from the IndexedDB storage.
*
* @param key - The key of the item to retrieve.
* @returns A promise that resolves with the retrieved item or null if the item does not exist.
*/
public getItem(key: string): Promise<string | null> {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(
this.defaultDb,
this.defaultVersion
);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(this.defaultObjectStore, "readonly");
const store = tx.objectStore(this.defaultObjectStore);
const getRequest = store.get(key);
getRequest.onerror = () => reject(getRequest.error);
getRequest.onsuccess = () => resolve(getRequest.result);
};
});
}
/**
* Stores an item in the IndexedDB storage.
*
* @param key - The key under which to store the item.
* @param value - The value to store.
* @returns A promise that resolves when the operation is complete.
*/
public setItem(key: string, value: T): Promise<void> {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(
this.defaultDb,
this.defaultVersion
);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.defaultObjectStore))
db.createObjectStore(this.defaultObjectStore);
};
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(this.defaultObjectStore, "readwrite");
const store = tx.objectStore(this.defaultObjectStore);
const putRequest = store.put(value, key);
putRequest.onerror = () => reject(putRequest.error);
putRequest.onsuccess = () => resolve();
};
});
}
}
245 changes: 116 additions & 129 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,139 +1,126 @@
import { writable as internal, type Writable } from 'svelte/store'

declare type Updater<T> = (value: T) => T;
declare type StoreDict<T> = { [key: string]: Persisted<T> }

interface Persisted<T> extends Writable<T> {
reset: () => void
}

/* eslint-disable @typescript-eslint/no-explicit-any */
interface Stores {
local: StoreDict<any>,
session: StoreDict<any>,
import { get, writable as internal } from "svelte/store";
import indexedDB from "./drivers/idb";
import { AsyncPersisted, Persisted, Stores } from "./types/store";
import { Options, DeprecatedOptions } from "./types/options";
import createState from "./state";
if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
// structured-clone is not supported in jsdom
require("core-js/stable/structured-clone");
require("fake-indexeddb/auto");
}

const stores: Stores = {
local: {},
session: {}
session: {},
indexedDB: {},
};

/**
* @deprecated `writable()` has been renamed to `persisted()`
* @param {string} key - The key for the store.
* @param {StoreType} initialValue - The initial value for the store.
* @param {Options<StoreType, SerializerType>} options - The options for the store.
* @returns {Persisted<StoreType>} - The persisted store.
*/
export function writable<StoreType, SerializerType = StoreType>(
key: string,
initialValue: StoreType,
options?: Options<StoreType, SerializerType>
): Persisted<StoreType> {
console.warn(
"writable() has been deprecated. Please use localState() instead.\n\nchange:\n\nimport { writable } from 'svelte-persisted-store'\n\nto:\n\nimport { persisted } from 'svelte-persisted-store'"
);
return persisted<StoreType, SerializerType>(key, initialValue, options);
}

export interface Serializer<T> {
parse(text: string): T
stringify(object: T): string
/**
* @deprecated `persisted()` has been deprecated.
* @param {string} key - The key for the store.
* @param {StoreType} initialValue - The initial value for the store.
* @param {DeprecatedOptions<StoreType, SerializerType>} options - The options for the store.
* @returns {Persisted<StoreType>} - The persisted store.
*/
export function persisted<StoreType, SerializerType = StoreType>(
key: string,
initialValue: StoreType,
options?: DeprecatedOptions<StoreType, SerializerType>
): Persisted<StoreType> {
console.warn(
"persisted() has been deprecated. Please use localState() or sessionState()) instead."
);
const storageType = options?.storage || "local";
switch (storageType) {
case "local":
return localState(key, initialValue, options);
case "session":
return sessionState(key, initialValue, options);
default:
throw new Error("Invalid storage type. Please use 'local' or 'session'");
}
}

export type StorageType = 'local' | 'session'

export interface Options<StoreType, SerializerType> {
serializer?: Serializer<SerializerType>
storage?: StorageType,
syncTabs?: boolean,
onError?: (e: unknown) => void
onWriteError?: (e: unknown) => void
onParseError?: (newValue: string | null, e: unknown) => void
beforeRead?: (val: SerializerType) => StoreType
beforeWrite?: (val: StoreType) => SerializerType
/**
* Creates a local state.
* @param {string} key - The key for the store.
* @param {StoreType} initialValue - The initial value for the store.
* @param {Options<StoreType, SerializerType>} options - The options for the store.
* @returns {Persisted<StoreType>} - The persisted store.
*/
export function localState<StoreType, SerializerType = StoreType>(
key: string,
initialValue: StoreType,
options?: Options<StoreType, SerializerType>
): Persisted<StoreType> {
return createState(key, initialValue, stores, {
...options,
storage: "local",
});
}

function getStorage(type: StorageType) {
return type === 'local' ? localStorage : sessionStorage
/**
* Creates a session state.
* @param {string} key - The key for the store.
* @param {StoreType} initialValue - The initial value for the store.
* @param {Options<StoreType, SerializerType>} options - The options for the store.
* @returns {Persisted<StoreType>} - The persisted store.
*/
export function sessionState<StoreType, SerializerType = StoreType>(
key: string,
initialValue: StoreType,
options?: Options<StoreType, SerializerType>
): Persisted<StoreType> {
return createState(key, initialValue, stores, {
...options,
storage: "session",
});
}

/** @deprecated `writable()` has been renamed to `persisted()` */
export function writable<StoreType, SerializerType = StoreType>(key: string, initialValue: StoreType, options?: Options<StoreType, SerializerType>): Persisted<StoreType> {
console.warn("writable() has been deprecated. Please use persisted() instead.\n\nchange:\n\nimport { writable } from 'svelte-persisted-store'\n\nto:\n\nimport { persisted } from 'svelte-persisted-store'")
return persisted<StoreType, SerializerType>(key, initialValue, options)
}
export function persisted<StoreType, SerializerType = StoreType>(key: string, initialValue: StoreType, options?: Options<StoreType, SerializerType>): Persisted<StoreType> {
if (options?.onError) console.warn("onError has been deprecated. Please use onWriteError instead")

const serializer = options?.serializer ?? JSON
const storageType = options?.storage ?? 'local'
const syncTabs = options?.syncTabs ?? true
const onWriteError = options?.onWriteError ?? options?.onError ?? ((e) => console.error(`Error when writing value from persisted store "${key}" to ${storageType}`, e))
const onParseError = options?.onParseError ?? ((newVal, e) => console.error(`Error when parsing ${newVal ? '"' + newVal + '"' : "value"} from persisted store "${key}"`, e))

const beforeRead = options?.beforeRead ?? ((val) => val as unknown as StoreType)
const beforeWrite = options?.beforeWrite ?? ((val) => val as unknown as SerializerType)

const browser = typeof (window) !== 'undefined' && typeof (document) !== 'undefined'
const storage = browser ? getStorage(storageType) : null

function updateStorage(key: string, value: StoreType) {
const newVal = beforeWrite(value)

try {
storage?.setItem(key, serializer.stringify(newVal))
} catch (e) {
onWriteError(e)
}
}

function maybeLoadInitial(): StoreType {
function serialize(json: any) {
try {
return <SerializerType>serializer.parse(json)
} catch (e) {
onParseError(json, e)
}
}
const json = storage?.getItem(key)
if (json == null) return initialValue

const serialized = serialize(json)
if (serialized == null) return initialValue

const newVal = beforeRead(serialized)
return newVal
}

if (!stores[storageType][key]) {
const initial = maybeLoadInitial()
const store = internal(initial, (set) => {
if (browser && storageType == 'local' && syncTabs) {
const handleStorage = (event: StorageEvent) => {
if (event.key === key && event.newValue) {
let newVal: any
try {
newVal = serializer.parse(event.newValue)
} catch (e) {
onParseError(event.newValue, e)
return
}
const processedVal = beforeRead(newVal)

set(processedVal)
}
}

window.addEventListener("storage", handleStorage)

return () => window.removeEventListener("storage", handleStorage)
}
})

const { subscribe, set } = store

stores[storageType][key] = {
set(value: StoreType) {
set(value)
updateStorage(key, value)
},
update(callback: Updater<StoreType>) {
return store.update((last) => {
const value = callback(last)

updateStorage(key, value)

return value
})
},
reset() {
this.set(initialValue)
},
subscribe
}
}
return stores[storageType][key]
/**
* Creates an IndexedDB state.
* @param {string} key - The key for the store.
* @param {T} initialValue - The initial value for the store.
* @returns {Promise<Persisted<T> | AsyncPersisted<T>>} - The persisted store.
*/
export async function indexedDBState<T>(
key: string,
initialValue: T
): Promise<Persisted<T> | AsyncPersisted<T>> {
const store = internal(initialValue);
const { subscribe } = store;
const storage = new indexedDB();

stores["indexedDB"][key] = {
async set(value: T): Promise<void> {
store.set(value);
await storage.setItem(key, value);
},

async update(updater: (value: T) => T): Promise<void> {
const updatedValue = updater(get(store));
await this.set(updatedValue);
},

async reset(): Promise<void> {
this.set(initialValue);
},
subscribe,
};
return stores["indexedDB"][key];
}
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"main": "dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"types": "dist/index.d.ts",
Expand All @@ -30,11 +30,15 @@
"devDependencies": {
"@babel/core": "^7.24.6",
"@babel/preset-env": "^7.24.5",
"@types/core-js": "^2.5.8",
"@types/node": "^20.13.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitest/coverage-v8": "^1.6.0",
"codecov": "^3.8.3",
"core-js": "^3.37.1",
"eslint": "^8.57.0",
"fake-indexeddb": "^6.0.0",
"jsdom": "^24.0.0",
"publint": "^0.2.8",
"rollup": "^4.18.0",
Expand All @@ -53,5 +57,6 @@
],
"files": [
"dist"
]
],
"packageManager": "pnpm@9.0.6+sha1.648f6014eb363abb36618f2ba59282a9eeb3e879"
}
Loading
Loading