diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..5ba1a2dbe --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,182 @@ +# Contribution Guide + + + +> All contributors lead the growth of Unstorage - including you! + +## Discussions + +You can involve in discussions using: + +- [GitHub Discussions](https://github.com/unjs/unstorage/discussions) + +## Contribute to the Code + +> [!IMPORTANT] +> Please discuss your ideas with the maintainers before opening a pull request. + +### Local Development + +- Clone the [`unjs/unstorage`](https://github.com/unjs/unstorage) git repository. +- Install the latest LTS version of [Node.js](https://nodejs.org/en/) (v22+). +- Enable [corepack](https://github.com/nodejs/corepack) using `corepack enable` (run `npm i -g corepack` if it's not available). +- Install dependencies using `pnpm install`. +- Run the generation script after creating a new driver using `pnpm generate`. +- Build the project using `pnpm build`. +- Add, modify, and run tests using `pnpm test`. + +## Reporting Issues + +You might encounter a bug while using Unstorage. + +Although we aim to resolve all known issues, new bugs can emerge over time. Your bug report helps us find and fix them faster — even if you're unable to fix the underlying code yourself. + +Here’s how to report a bug effectively: + +### Ensure It's a Bug + +Sometimes what seems like a bug may actually be expected behavior or a missing feature. Make sure you’re reporting an actual bug by creating a minimal unstorage project and reducing scope. + +### Create a Minimal Reproduction + +Please create a minimal reproduction using the starter templates or a simple repository. + +Sometimes, bugs originate from another layer — not Unstorage itself. A minimal reproduction helps identify the source and speeds up debugging. + +If your bug involves a higher-level framework, please report it there. Maintainers will help narrow it down to an Unstorage-level issue if needed. + +### Search Existing Issues and Discussions + +Before creating a new issue, search existing [issues](https://github.com/unjs/unstorage/issues) and [discussions](https://github.com/unjs/unstorage/discussions) to see if your bug has already been reported. + +If it has already been reported: + +- Add a 👍 reaction to the original post (instead of commenting "me too" or "when will it be fixed"). +- If you can provide additional context or a better/smaller reproduction, please share it. + +> [!NOTE] +> If the issue seems related but different or old or already closed, it's **better to open a new issue**. Maintainers will merge similar issues if needed. + +## Contributing: Developing Drivers for Unstorage + +This guide explains how to develop a custom driver for `unstorage`, including naming conventions, typing options, and best practices. For a practical example, see the [`deno-kv` driver](https://github.com/unjs/unstorage/blob/main/src/drivers/deno-kv.ts). + +## 1. Naming Your Driver and Options + +- **Driver file:** Use a clear, kebab-case name (e.g., `my-custom-driver.ts`). +- **Options interface:** Name it as `MyCustomOptions` (PascalCase, ends with `Options`). +- **Driver interface:** Name it as `MyCustomDriver` (PascalCase, ends with `Driver`). +- **Driver name constant:** It is recommended to use a `DRIVER_NAME` constant as the driver name in the implementation and should match the pattern in [`src/drivers`](https://github.com/unjs/unstorage/tree/main/src/drivers) (see e.g. `deno-kv.ts`, `fs.ts`, `redis.ts`). The file name should match the `DRIVER_NAME` constant. +- **File name transformation:** The file name is used to determine how to access the driver's options in the global options types. Internally, a transformation (is applied to the file name for type access. For example, `deno-kv` becomes `denoKV`, so you would access options as `opts.denoKV`. + +## 2. Typing Driver Options + +Define an options interface for your driver. This describes the configuration users can pass when initializing the driver. + +```ts +// my-custom-driver.ts +export interface MyCustomOptions { + path: string; + ttl?: number; +} +``` + +## 3. Typing Driver Methods + +Define a driver interface with the suffix `Driver`. This interface should have the following properties: + +- `getOptions`: Type for options passed to `getItem`. +- `setOptions`: Type for options passed to `setItem`. +- `removeOptions`: Type for options passed to `removeItem`. +- `listOptions`: Type for options passed to `getKeys`. +- `clearOptions`: Type for options passed to `clear` (if your driver supports it). + +Example: + +```ts +export interface MyCustomDriver { + getOptions: { raw?: boolean }; + setOptions: { foo?: string }; + removeOptions: {}; + listOptions: { prefix?: string }; + clearOptions: { deep?: boolean }; +} +``` + +## 4. Implementing the Driver + +Use the `defineDriver` helper, passing your options and driver types: + +```ts +import { defineDriver } from "unstorage"; +import type { MyCustomOptions, MyCustomDriver } from "./my-custom-driver"; +import type { + GetOptions, + SetOptions, + RemoveOptions, + ListOptions, + ClearOptions, +} from "../types"; + +const DRIVER_NAME = "my-custom-driver"; + +export default defineDriver( + (options) => { + return { + name: DRIVER_NAME, + async getItem(key, opts: GetOptions) { + // Get the raw option + const raw = opts?.["my-custom-driver"]?.raw; + /** Implementation */ + }, + async setItem(key, value, opts: SetOptions) { + /** Implementation */ + }, + async removeItem(key, opts: RemoveOptions) { + /** Implementation */ + }, + async getKeys(base, opts: ListOptions) { + /** Implementation */ + }, + async clear(base, opts: ClearOptions) { + /** Implementation */ + }, + // ...other methods... + }; + } +); +``` + +Take a look at the [deno driver](https://github.com/unjs/unstorage/blob/main/src/drivers/deno-kv.ts) for a complete implementation example with typed options. + +## 5. Generating and Using Types Globally + +After defining your driver and its types: + +1. **Run the code generation script:** + + ```sh + pnpm gen-drivers + ``` + + This will update the generated types so your driver's options are available inside the `GetOptions`, `SetOptions`, `RemoveOptions`, and `ListOptions` types. + +2. **Use the generated types** in your code: + + ```ts + import type { SetOptions } from "unstorage"; + + defineDriver((options) => { + return { + async setItem(key, value, opts: SetOptions) { + // opts is typed for your driver + }, + }; + }); + ``` + +Some important notes: + +- Always use the `Driver` and `Options` suffixes for your types. +- Document your options and method behaviors. +- Document if your driver supports the [common options](https://github.com/unjs/unstorage/blob/src/types.ts#L27) diff --git a/scripts/gen-drivers.ts b/scripts/gen-drivers.ts index b5788f184..08e61bba2 100644 --- a/scripts/gen-drivers.ts +++ b/scripts/gen-drivers.ts @@ -23,6 +23,8 @@ const drivers: { subpath: string; optionsTExport?: string; optionsTName?: string; + driverOptionsExport?: string; + driverName?: string; }[] = []; for (const entry of driverEntries) { @@ -31,18 +33,26 @@ for (const entry of driverEntries) { const fullPath = join(driversDir, `${name}.ts`); const contents = await readFile(fullPath, "utf8"); - const optionsTExport = findTypeExports(contents).find((type) => + const typeExports = findTypeExports(contents); + const optionsTExport = typeExports.find((type) => type.name?.endsWith("Options") )?.name; + const driverOptionsExport = typeExports.find((type) => + type.name?.endsWith("Driver") + )?.name; + const safeName = camelCase(name) .replace(/kv/i, "KV") .replace("localStorage", "localstorage"); const names = [...new Set([name, safeName])]; + // TODO: due to name + safe name, options are duplicated for same driver which is confusing a bit tedious to pass options (e.g. deno-kv or denoKV?) -> currently only (deno-kv works but denoKV is typed) const optionsTName = upperFirst(safeName) + "Options"; + const driverName = upperFirst(safeName) + "Driver"; + drivers.push({ name, safeName, @@ -50,6 +60,8 @@ for (const entry of driverEntries) { subpath, optionsTExport, optionsTName, + driverOptionsExport, + driverName, }); } @@ -64,6 +76,14 @@ ${drivers ) .join("\n")} +${drivers + .filter((d) => d.driverOptionsExport) + .map( + (d) => + /* ts */ `import type { ${d.driverOptionsExport} as ${d.driverName} } from "${d.subpath}";` + ) + .join("\n")} + export type BuiltinDriverName = ${drivers.flatMap((d) => d.names.map((name) => `"${name}"`)).join(" | ")}; export type BuiltinDriverOptions = { @@ -76,6 +96,63 @@ export type BuiltinDriverOptions = { export const builtinDrivers = { ${drivers.flatMap((d) => d.names.map((name) => `"${name}": "${d.subpath}"`)).join(",\n ")}, } as const; + +export type BuiltinDrivers = { + ${drivers + .filter((d) => d.driverOptionsExport) + .flatMap((d) => d.names.map((name) => `"${name}": ${d.driverName};`)) + .join("\n ")} +} + +export type DriverGetOptions = { + ${drivers + .filter((d) => d.driverOptionsExport) + .map( + (d) => + `"${d.safeName}"?: ${d.driverName} extends { getOptions: infer TGet } ? unknown extends TGet ? {} : TGet : {}` + ) + .join("\n ")} +} + +export type DriverSetOptions = { + ${drivers + .filter((d) => d.driverOptionsExport) + .map( + (d) => + `"${d.safeName}"?: ${d.driverName} extends { setOptions: infer TSet } ? unknown extends TSet ? {} : TSet : {}` + ) + .join("\n ")} +} + +export type DriverRemoveOptions = { + ${drivers + .filter((d) => d.driverOptionsExport) + .map( + (d) => + `"${d.safeName}"?: ${d.driverName} extends { removeOptions: infer TRemove } ? unknown extends TRemove ? {} : TRemove : {}` + ) + .join("\n ")} +} + +export type DriverListOptions = { + ${drivers + .filter((d) => d.driverOptionsExport) + .map( + (d) => + `"${d.safeName}"?: ${d.driverName} extends { listOptions: infer TList } ? unknown extends TList ? {} : TList : {}` + ) + .join("\n ")} +} + +export type DriverClearOptions = { + ${drivers + .filter((d) => d.driverOptionsExport) + .map( + (d) => + `"${d.safeName}"?: ${d.driverName} extends { clearOptions: infer TClear } ? unknown extends TClear ? {} : TClear : {}` + ) + .join("\n ")} +} `; await writeFile(driversMetaFile, genCode, "utf8"); diff --git a/src/_drivers.ts b/src/_drivers.ts index 13ac1f028..d0f554e32 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -33,6 +33,8 @@ import type { VercelBlobOptions as VercelBlobOptions } from "unstorage/drivers/v import type { VercelKVOptions as VercelKVOptions } from "unstorage/drivers/vercel-kv"; import type { VercelCacheOptions as VercelRuntimeCacheOptions } from "unstorage/drivers/vercel-runtime-cache"; +import type { DenoKvDriver as DenoKVDriver } from "unstorage/drivers/deno-kv"; + export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache"; export type BuiltinDriverOptions = { @@ -140,3 +142,28 @@ export const builtinDrivers = { "vercel-runtime-cache": "unstorage/drivers/vercel-runtime-cache", "vercelRuntimeCache": "unstorage/drivers/vercel-runtime-cache", } as const; + +export type BuiltinDrivers = { + "deno-kv": DenoKVDriver; + "denoKV": DenoKVDriver; +} + +export type DriverGetOptions = { + "denoKV"?: DenoKVDriver extends { getOptions: infer TGet } ? unknown extends TGet ? {} : TGet : {} +} + +export type DriverSetOptions = { + "denoKV"?: DenoKVDriver extends { setOptions: infer TSet } ? unknown extends TSet ? {} : TSet : {} +} + +export type DriverRemoveOptions = { + "denoKV"?: DenoKVDriver extends { removeOptions: infer TRemove } ? unknown extends TRemove ? {} : TRemove : {} +} + +export type DriverListOptions = { + "denoKV"?: DenoKVDriver extends { listOptions: infer TList } ? unknown extends TList ? {} : TList : {} +} + +export type DriverClearOptions = { + "denoKV"?: DenoKVDriver extends { clearOptions: infer TClear } ? unknown extends TClear ? {} : TClear : {} +} diff --git a/src/drivers/deno-kv.ts b/src/drivers/deno-kv.ts index 0b1808077..30e512774 100644 --- a/src/drivers/deno-kv.ts +++ b/src/drivers/deno-kv.ts @@ -1,5 +1,6 @@ import { defineDriver, createError, normalizeKey } from "./utils/index"; import type { Kv, KvKey } from "@deno/kv"; +import type { SetOptions } from "../types"; // https://docs.deno.com/deploy/kv/manual/ @@ -12,11 +13,11 @@ export interface DenoKvOptions { */ ttl?: number; } -interface DenoKVSetOptions { - /** - * TTL in seconds. - */ - ttl?: number; + +export interface DenoKvDriver { + setOptions: {}; + getOptions: {}; + removeOptions: {}; } const DRIVER_NAME = "deno-kv"; @@ -75,12 +76,12 @@ export default defineDriver>( const value = await kv.get(r(key)); return value.value; }, - async setItem(key, value, tOptions: DenoKVSetOptions) { + async setItem(key, value, tOptions: SetOptions) { const ttl = normalizeTTL(tOptions?.ttl ?? opts?.ttl); const kv = await getKv(); await kv.set(r(key), value, { expireIn: ttl }); }, - async setItemRaw(key, value, tOptions: DenoKVSetOptions) { + async setItemRaw(key, value, tOptions: SetOptions) { const ttl = normalizeTTL(tOptions?.ttl ?? opts?.ttl); const kv = await getKv(); await kv.set(r(key), value, { expireIn: ttl }); diff --git a/src/types.ts b/src/types.ts index d347dbd06..5bf8c6d4e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,11 @@ +import type { + DriverClearOptions, + DriverGetOptions, + DriverListOptions, + DriverRemoveOptions, + DriverSetOptions, +} from "./_drivers"; + export type StorageValue = null | string | number | boolean | object; export type WatchEvent = "update" | "remove"; export type WatchCallback = (event: WatchEvent, key: string) => any; @@ -15,9 +23,45 @@ export interface StorageMeta { [key: string]: StorageValue | Date | undefined; } -// TODO: type ttl export type TransactionOptions = Record; +export interface CommonGetOptions {} + +export interface CommonSetOptions { + /** + * Time to live in seconds. + */ + ttl?: number; +} + +export interface CommonRemoveOptions { + removeMeta?: boolean; +} + +export interface CommonListOptions {} + +export interface CommonClearOptions {} + +export type GetOptions = DriverGetOptions & + CommonGetOptions & + TransactionOptions; + +export type SetOptions = DriverSetOptions & + CommonSetOptions & + TransactionOptions; + +export type RemoveOptions = DriverRemoveOptions & + CommonRemoveOptions & + TransactionOptions; + +export type ListOptions = DriverListOptions & + CommonListOptions & + TransactionOptions; + +export type ClearOptions = DriverClearOptions & + CommonClearOptions & + TransactionOptions; + export type GetKeysOptions = TransactionOptions & { maxDepth?: number; }; @@ -99,22 +143,22 @@ export interface Storage { K extends string & keyof StorageItemMap, >( key: K, - ops?: TransactionOptions + ops?: GetOptions ): Promise | null>; getItem>( key: string, - opts?: TransactionOptions + opts?: GetOptions ): Promise; /** @experimental */ getItems: ( - items: (string | { key: string; options?: TransactionOptions })[], - commonOptions?: TransactionOptions + items: (string | { key: string; options?: GetOptions })[], + commonOptions?: GetOptions ) => Promise<{ key: string; value: U }[]>; /** @experimental See https://github.com/unjs/unstorage/issues/142 */ getItemRaw: ( key: string, - opts?: TransactionOptions + opts?: GetOptions ) => Promise | null>; setItem< @@ -123,24 +167,20 @@ export interface Storage { >( key: K, value: StorageItemType, - opts?: TransactionOptions - ): Promise; - setItem( - key: string, - value: U, - opts?: TransactionOptions + opts?: SetOptions ): Promise; + setItem(key: string, value: U, opts?: SetOptions): Promise; /** @experimental */ setItems: ( - items: { key: string; value: U; options?: TransactionOptions }[], - commonOptions?: TransactionOptions + items: { key: string; value: U; options?: SetOptions }[], + commonOptions?: SetOptions ) => Promise; /** @experimental See https://github.com/unjs/unstorage/issues/142 */ setItemRaw: ( key: string, value: MaybeDefined, - opts?: TransactionOptions + opts?: SetOptions ) => Promise; removeItem< @@ -148,34 +188,31 @@ export interface Storage { K extends keyof StorageItemMap, >( key: K, - opts?: - | (TransactionOptions & { removeMeta?: boolean }) - | boolean /* legacy: removeMeta */ + opts?: RemoveOptions | boolean /* legacy: removeMeta */ ): Promise; removeItem( key: string, - opts?: - | (TransactionOptions & { removeMeta?: boolean }) - | boolean /* legacy: removeMeta */ + opts?: RemoveOptions | boolean /* legacy: removeMeta */ ): Promise; // Meta getMeta: ( key: string, opts?: - | (TransactionOptions & { nativeOnly?: boolean }) + | (GetOptions & { nativeOnly?: boolean }) | boolean /* legacy: nativeOnly */ ) => MaybePromise; setMeta: ( key: string, value: StorageMeta, - opts?: TransactionOptions + opts?: SetOptions ) => Promise; - removeMeta: (key: string, opts?: TransactionOptions) => Promise; + removeMeta: (key: string, opts?: RemoveOptions) => Promise; // Keys getKeys: (base?: string, opts?: GetKeysOptions) => Promise; // Utils - clear: (base?: string, opts?: TransactionOptions) => Promise; + + clear: (base?: string, opts?: ClearOptions) => Promise; dispose: () => Promise; watch: (callback: WatchCallback) => Promise; unwatch: () => Promise;