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: typed storage interface #509

Merged
merged 14 commits into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from 12 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
20 changes: 19 additions & 1 deletion docs/1.guide/1.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ icon: ph:book-open-duotone

## Introduction

We usually choose one or more storage backends based on our use cases, such as the filesystem, a database, or LocalStorage for browsers. It soon starts to create troubles when supporting and combining multiple options or switching between them. For JavaScript library authors, this usually means that they have to decide how many platforms they are going to support and implement storage for each of them.
We usually choose one or more storage backends based on our use cases, such as the filesystem, a database, or LocalStorage for browsers. It soon starts to create troubles when supporting and combining multiple options or switching between them. For JavaScript library authors, this usually means that they have to decide how many platforms they are going to support and implement storage for each of them.

## Installation

Expand Down Expand Up @@ -348,3 +348,21 @@ In [strict mode](https://www.typescriptlang.org/tsconfig#strict), it will also r

await storage.getItem<string>("k"); // => <string | null>
```

**Specifying namespace:**

```ts
type StorageDefinition = {
items: {
foo: string;
baz: number;
}
}

const storage = createStorage<StorageDefinition>();
await storage.has("foo");// Ts will prompt you that there are two optional keys: "foo" or "baz"
await storage.getItem("baz"); // => string
await storage.setItem("foo", 12); // TS error: <number> is not compatible with <string>
await storage.setItem("foo", "val"); // Check ok
await storage.remove("foo");
```
29 changes: 19 additions & 10 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,19 +161,22 @@ export function createStorage<T extends StorageValue>(

const storage: Storage = {
// Item
hasItem(key, opts = {}) {
hasItem(key: string, opts = {}) {
key = normalizeKey(key);
const { relativeKey, driver } = getMount(key);
return asyncCall(driver.hasItem, relativeKey, opts);
},
getItem(key, opts = {}) {
getItem(key: string, opts = {}) {
key = normalizeKey(key);
const { relativeKey, driver } = getMount(key);
return asyncCall(driver.getItem, relativeKey, opts).then((value) =>
destr(value)
);
},
getItems(items, commonOptions) {
getItems(
items: (string | { key: string; options?: TransactionOptions })[],
commonOptions = {}
) {
return runBatch(items, commonOptions, (batch) => {
if (batch.driver.getItems) {
return asyncCall(
Expand Down Expand Up @@ -214,7 +217,7 @@ export function createStorage<T extends StorageValue>(
deserializeRaw(value)
);
},
async setItem(key, value, opts = {}) {
async setItem(key: string, value: T, opts = {}) {
if (value === undefined) {
return storage.removeItem(key);
}
Expand Down Expand Up @@ -273,7 +276,12 @@ export function createStorage<T extends StorageValue>(
onChange("update", key);
}
},
async removeItem(key, opts = {}) {
async removeItem(
key: string,
opts:
| (TransactionOptions & { removeMeta?: boolean })
| boolean /* legacy: removeMeta */ = {}
) {
// TODO: Remove in next major version
if (typeof opts === "boolean") {
opts = { removeMeta: opts };
Expand Down Expand Up @@ -453,11 +461,12 @@ export function createStorage<T extends StorageValue>(
},
// Aliases
keys: (base, opts = {}) => storage.getKeys(base, opts),
get: (key, opts = {}) => storage.getItem(key, opts),
set: (key, value, opts = {}) => storage.setItem(key, value, opts),
has: (key, opts = {}) => storage.hasItem(key, opts),
del: (key, opts = {}) => storage.removeItem(key, opts),
remove: (key, opts = {}) => storage.removeItem(key, opts),
get: (key: string, opts = {}) => storage.getItem(key, opts),
set: (key: string, value: T, opts = {}) =>
storage.setItem(key, value, opts),
has: (key: string, opts = {}) => storage.hasItem(key, opts),
del: (key: string, opts = {}) => storage.removeItem(key, opts),
remove: (key: string, opts = {}) => storage.removeItem(key, opts),
};

return storage;
Expand Down
59 changes: 47 additions & 12 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,30 @@ export interface Driver<OptionsT = any, InstanceT = any> {
watch?: (callback: WatchCallback) => MaybePromise<Unwatch>;
}

type StorageDefinition = {
items: unknown;
[key: string]: unknown;
};

type StorageItemMap<T extends StorageDefinition> = T["items"];

export interface Storage<T extends StorageValue = StorageValue> {
// Item
hasItem: (key: string, opts?: TransactionOptions) => Promise<boolean>;
getItem: <U extends T>(
hasItem<U extends Extract<T, StorageDefinition>, K extends keyof StorageItemMap<U>>(
key: K,
opts?: TransactionOptions
): Promise<boolean>;
hasItem(key: string, opts?: TransactionOptions): Promise<boolean>;

getItem<U extends Extract<T, StorageDefinition>, K extends keyof StorageItemMap<U>>(
key: K,
ops?: TransactionOptions
): Promise<StorageItemMap<U>[K] | null>;
getItem<U extends T>(
key: string,
opts?: TransactionOptions
) => Promise<U | null>;
): Promise<U | null>;

/** @experimental */
getItems: <U extends T>(
items: (string | { key: string; options?: TransactionOptions })[],
Expand All @@ -78,11 +95,18 @@ export interface Storage<T extends StorageValue = StorageValue> {
key: string,
opts?: TransactionOptions
) => Promise<MaybeDefined<T> | null>;
setItem: <U extends T>(

setItem<U extends Extract<T, StorageDefinition>, K extends keyof StorageItemMap<U>>(
key: K,
value: StorageItemMap<U>[K],
opts?: TransactionOptions
): Promise<void>;
setItem<U extends T>(
key: string,
value: U,
opts?: TransactionOptions
) => Promise<void>;
): Promise<void>;

/** @experimental */
setItems: <U extends T>(
items: { key: string; value: U; options?: TransactionOptions }[],
Expand All @@ -94,12 +118,23 @@ export interface Storage<T extends StorageValue = StorageValue> {
value: MaybeDefined<T>,
opts?: TransactionOptions
) => Promise<void>;
removeItem: (

removeItem<
U extends Extract<T, StorageDefinition>,
K extends keyof StorageItemMap<U>,
>(
key: K,
opts?:
| (TransactionOptions & { removeMeta?: boolean })
| boolean /* legacy: removeMeta */
): Promise<void>;
removeItem(
key: string,
opts?:
| (TransactionOptions & { removeMeta?: boolean })
| boolean /* legacy: removeMeta */
) => Promise<void>;
): Promise<void>;

// Meta
getMeta: (
key: string,
Expand Down Expand Up @@ -130,9 +165,9 @@ export interface Storage<T extends StorageValue = StorageValue> {
) => { base: string; driver: Driver }[];
// Aliases
keys: Storage["getKeys"];
get: Storage["getItem"];
set: Storage["setItem"];
has: Storage["hasItem"];
del: Storage["removeItem"];
remove: Storage["removeItem"];
get: Storage<T>["getItem"];
set: Storage<T>["setItem"];
has: Storage<T>["hasItem"];
del: Storage<T>["removeItem"];
remove: Storage<T>["removeItem"];
}
2 changes: 1 addition & 1 deletion test/drivers/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe("drivers: github", () => {
});

it("can read a json file content", async () => {
const pkg = (await storage.getItem("package.json")) as Record<
const pkg = (await storage.getItem("package.json"))! as Record<
string,
unknown
>;
Expand Down
42 changes: 42 additions & 0 deletions test/storage.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expectTypeOf } from "vitest";
import { createStorage } from "../src";
import type { StorageValue } from "../src";

describe("types", () => {
it("check types", async () => {
type TestObjType = {
a: number;
b: boolean;
};
type MyStorage = {
items: {
foo: string;
bar: number;
baz: TestObjType;
};
};
const storage = createStorage<MyStorage>();

expectTypeOf(await storage.getItem("foo")).toMatchTypeOf<string | null>();
expectTypeOf(await storage.getItem("bar")).toMatchTypeOf<number | null>();
expectTypeOf(
await storage.getItem("unknown")
).toMatchTypeOf<StorageValue | null>();
expectTypeOf(await storage.get("baz")).toMatchTypeOf<TestObjType | null>();
expectTypeOf(
await storage.getItem("aaaaa")
).toMatchTypeOf<MyStorage | null>();

// @ts-expect-error
await storage.setItem("foo", 1); // ts err: Argument of type 'number' is not assignable to parameter of type 'string'
await storage.setItem("foo", "str");
// @ts-expect-error
await storage.set("bar", "str"); // ts err: Argument of type 'string' is not assignable to parameter of type 'number'.
await storage.set("bar", 1);

// should be able to get ts prompts: 'foo' | 'bar' | 'baz'
await storage.removeItem("foo");
await storage.remove("bar");
await storage.del("baz");
});
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
"noImplicitOverride": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true
}
}
Loading